diff --git a/Cargo.lock b/Cargo.lock index 01722f689..6b8e51672 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,6 +174,7 @@ version = "0.1.0" dependencies = [ "api-macros-impl", "axum", + "bytes", "futures", "oas3", "reqwest", @@ -254,6 +255,28 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -1447,6 +1470,7 @@ dependencies = [ "grep-searcher", "ignore", "libc", + "schemars", "serde", "serde_json", "sha2 0.10.9", @@ -2579,6 +2603,7 @@ version = "0.1.0" dependencies = [ "chrono", "rusqlite", + "schemars", "serde", "serde_json", "tempfile", @@ -3910,6 +3935,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive", @@ -4010,6 +4036,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_cbor_2" version = "0.13.0" @@ -4149,12 +4185,15 @@ name = "server-api" version = "0.1.0" dependencies = [ "api-macros", + "memory", + "merge-request", "protocol", "runtime-api", "schemars", "serde", "serde_json", "sha2 0.11.0", + "ticket", "tokio", "ts-rs", "webauthn-rs-proto", @@ -4840,6 +4879,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -5564,6 +5615,39 @@ dependencies = [ "uuid", ] +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779e9c80ff248c7e12ea967f909249101f6e86f70fccd742d4b66c490c1710b4" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "futures", + "hex", + "nom 7.1.3", + "num-derive", + "num-traits", + "openssl", + "openssl-sys", + "serde", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "url", + "uuid", + "webauthn-rs-core", + "webauthn-rs-proto", +] + [[package]] name = "webauthn-rs" version = "0.5.5" @@ -6099,6 +6183,7 @@ dependencies = [ "fs-operation", "manifest", "reqwest", + "schemars", "serde", "serde_json", "sha2 0.11.0", @@ -6330,6 +6415,7 @@ dependencies = [ "ts-rs", "url", "uuid", + "webauthn-authenticator-rs", "webauthn-rs", "workdir", "worker", diff --git a/Cargo.toml b/Cargo.toml index 9c284b62e..ff5061333 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,6 +118,7 @@ yoi-workspace-server = { path = "crates/workspace-server" } async-trait = "0.1" axum = "0.8" base64 = "0.22.1" +bytes = "1" decodal = "0.4.0" decodal-language-service = "0.4.0" decodal-language-tools = "0.4.0" diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 2395767ef..8014ff556 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -26,6 +26,12 @@ pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum Transport { + Http, + WebSocket, +} + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] enum Method { Get, @@ -105,6 +111,7 @@ struct RouteArgs { operation_id: Option, status: Option, alternate_status: Option, + responses: Vec, error_status: Option, additional_error_statuses: Vec, bearer_auth: Option, @@ -113,6 +120,178 @@ struct RouteArgs { openapi: Option, } +struct WebSocketArgs { + path: LitStr, + operation_id: Option, + method: Option, + client_to_server: Option, + server_to_client: Option, + path_parameters: Vec<(Ident, Type)>, +} + +impl Parse for WebSocketArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let path = input.parse()?; + let mut result = Self { + path, + operation_id: None, + method: None, + client_to_server: None, + server_to_client: None, + path_parameters: Vec::new(), + }; + let mut saw_path_parameters = false; + 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", + )?, + "method" => set_once(&mut result.method, input.parse::()?, &key, "method")?, + "client_to_server" => set_once( + &mut result.client_to_server, + input.parse::()?, + &key, + "client_to_server", + )?, + "server_to_client" => set_once( + &mut result.server_to_client, + input.parse::()?, + &key, + "server_to_client", + )?, + "path_parameters" => { + if saw_path_parameters { + return Err(syn::Error::new( + key.span(), + "duplicate `path_parameters` WebSocket option", + )); + } + saw_path_parameters = true; + let content; + syn::bracketed!(content in input); + while !content.is_empty() { + let name = content.parse::()?; + content.parse::()?; + let ty = content.parse::()?; + result.path_parameters.push((name, ty)); + if !content.is_empty() { + content.parse::()?; + } + } + } + "body" | "binary" => { + return Err(syn::Error::new( + key.span(), + "WebSocket operations cannot declare JSON or binary request bodies", + )); + } + "status" | "alternate_status" | "responses" | "response" => { + return Err(syn::Error::new( + key.span(), + "WebSocket operations cannot declare unary success responses or statuses", + )); + } + _ => { + return Err(syn::Error::new( + key.span(), + "unsupported WebSocket option; expected operation_id, method, client_to_server, server_to_client, or path_parameters", + )); + } + } + } + Ok(result) + } +} + +struct DeclaredResponseHeader { + name: LitStr, + field_ident: Ident, + ty: Type, +} + +struct DeclaredResponse { + status: LitInt, + body: Option, + headers: Vec, +} + +impl Parse for DeclaredResponse { + fn parse(input: ParseStream<'_>) -> syn::Result { + let content; + syn::parenthesized!(content in input); + let mut status = None; + let mut body = None; + let mut headers = Vec::new(); + while !content.is_empty() { + let key: Ident = content.parse()?; + content.parse::()?; + match key.to_string().as_str() { + "status" => set_once( + &mut status, + content.parse::()?, + &key, + "response status", + )?, + "body" => set_once(&mut body, content.parse::()?, &key, "response body")?, + "headers" => { + if !headers.is_empty() { + return Err(syn::Error::new( + key.span(), + "duplicate `headers` response option", + )); + } + let header_content; + syn::bracketed!(header_content in content); + while !header_content.is_empty() { + let pair; + syn::parenthesized!(pair in header_content); + let name = pair.parse::()?; + pair.parse::()?; + let ty = pair.parse::()?; + if !pair.is_empty() { + return Err(pair.error( + "response header entries contain exactly a name and Rust type", + )); + } + let field_ident = response_header_field_ident(&name)?; + headers.push(DeclaredResponseHeader { + name, + field_ident, + ty, + }); + if !header_content.is_empty() { + header_content.parse::()?; + } + } + } + _ => { + return Err(syn::Error::new( + key.span(), + "unsupported response option; expected status, body, or headers", + )); + } + } + if !content.is_empty() { + content.parse::()?; + } + } + Ok(Self { + status: status.ok_or_else(|| input.error("declared response requires `status`"))?, + body, + headers, + }) + } +} + impl Parse for RouteArgs { fn parse(input: ParseStream<'_>) -> syn::Result { let path = input.parse()?; @@ -121,6 +300,7 @@ impl Parse for RouteArgs { operation_id: None, status: None, alternate_status: None, + responses: Vec::new(), error_status: None, additional_error_statuses: Vec::new(), bearer_auth: None, @@ -150,6 +330,26 @@ impl Parse for RouteArgs { &key, "alternate_status", )?, + "responses" => { + if !result.responses.is_empty() { + return Err(syn::Error::new( + key.span(), + "duplicate `responses` route option", + )); + } + let content; + syn::bracketed!(content in input); + result.responses = + Punctuated::::parse_terminated(&content)? + .into_iter() + .collect(); + if result.responses.is_empty() { + return Err(syn::Error::new( + key.span(), + "`responses` must declare at least one successful response", + )); + } + } "error_status" => set_once( &mut result.error_status, input.parse::()?, @@ -199,7 +399,7 @@ impl Parse for RouteArgs { _ => { return Err(syn::Error::new( key.span(), - "unsupported route option; expected operation_id, status, or error_status", + "unsupported route option; expected operation_id, status, alternate_status, responses, error_status, additional_error_statuses, bearer_auth, browser_auth, normalize_body_errors, or openapi", )); } } @@ -225,6 +425,7 @@ enum Location { Query, Header, Body, + Binary, /// Trusted request-local context supplied by the server adapter. Extension /// parameters are not part of the wire contract or generated client signature. Extension, @@ -236,7 +437,7 @@ impl Location { Self::Path => quote!(Path), Self::Query => quote!(Query), Self::Header => quote!(Header), - Self::Body => quote!(Body), + Self::Body | Self::Binary => quote!(Body), Self::Extension => unreachable!("extensions are not wire parameters"), }; quote!(#api_crate::ParameterLocation::#variant) @@ -251,15 +452,43 @@ struct Parameter { ty: Type, } +#[derive(Clone, Copy)] +enum RequestWireKind { + Json, + Binary, +} + +struct RequestBody { + ty: Type, + wire_kind: RequestWireKind, +} + +struct ResponseHeader { + wire_name: String, + field_ident: Ident, + ty: Type, +} + +struct SuccessResponse { + status: u16, + body: Option, + headers: Vec, +} + struct Operation { method_ident: Ident, marker_ident: Ident, operation_id: String, + transport: Transport, method: Method, path: String, parameters: Vec, - request_body: Option, + client_to_server_frame: Option, + server_to_client_frame: Option, + request_body: Option, response_body: Option, + success_responses: Vec, + declared_responses: bool, error_body: Option, fallible: bool, response_status: u16, @@ -306,24 +535,36 @@ fn normalize_api( 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 mut retained_items = Vec::new(); + for mut trait_item in std::mem::take(&mut item.items) { + let operation = match &mut trait_item { + TraitItem::Fn(method) => { + let operation = normalize_operation(method)?; + retained_items.push(trait_item); + operation + } + TraitItem::Type(declaration) if has_websocket_attribute(&declaration.attrs) => { + normalize_websocket_operation(declaration)? + } + _ => { + return Err(syn::Error::new_spanned( + trait_item, + "an #[api] trait may contain HTTP methods and #[websocket] operation declarations only", + )); + } }; - let operation = normalize_operation(method)?; + let source_ident = operation.method_ident.clone(); + let source_name = ident_text(&source_ident); + let source_span = source_ident.span(); 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()), - ) { + if let Some((first_operation, first_span)) = + marker_names.insert(marker_name.clone(), (source_name, source_span)) + { let mut error = syn::Error::new( - method.sig.ident.span(), + source_span, format!( - "generated operation marker `{marker_name}` collides with method `{first_method}`" + "generated operation marker `{marker_name}` collides with method `{first_operation}`" ), ); error.combine(syn::Error::new( @@ -334,23 +575,20 @@ fn normalize_api( } if operation_ids - .insert(operation.operation_id.clone(), method.sig.ident.span()) + .insert(operation.operation_id.clone(), source_span) .is_some() { return Err(syn::Error::new( - method.sig.ident.span(), + source_span, format!("duplicate operation ID `{}`", operation.operation_id), )); } if routes - .insert( - (operation.method, operation.path.clone()), - method.sig.ident.span(), - ) + .insert((operation.method, operation.path.clone()), source_span) .is_some() { return Err(syn::Error::new( - method.sig.ident.span(), + source_span, format!( "duplicate route for {} `{}`", method_name(operation.method), @@ -360,6 +598,7 @@ fn normalize_api( } operations.push(operation); } + item.items = retained_items; if operations.is_empty() { return Err(syn::Error::new_spanned( @@ -400,6 +639,156 @@ fn make_service_futures_send(item: &mut ItemTrait) -> syn::Result<()> { Ok(()) } +fn has_websocket_attribute(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| attr.path().is_ident("websocket")) +} + +fn normalize_websocket_operation(declaration: &mut syn::TraitItemType) -> syn::Result { + if !declaration.generics.params.is_empty() + || declaration.generics.where_clause.is_some() + || !declaration.bounds.is_empty() + || declaration.default.is_some() + { + return Err(syn::Error::new_spanned( + declaration, + "a #[websocket] declaration cannot have generics, bounds, or a default type", + )); + } + + let mut route = None; + for attr in &declaration.attrs { + if attr.path().is_ident("websocket") { + if route.is_some() { + return Err(syn::Error::new_spanned( + attr, + "a WebSocket operation must have exactly one #[websocket] attribute", + )); + } + route = Some(attr.parse_args::()?); + } else if !attr.path().is_ident("doc") { + return Err(syn::Error::new_spanned( + attr, + "a #[websocket] declaration supports only documentation attributes", + )); + } + } + let route = route.expect("caller checked for a WebSocket attribute"); + let method = route.method.ok_or_else(|| { + syn::Error::new_spanned( + &route.path, + "WebSocket operations require explicit `method = GET`", + ) + })?; + if method != "GET" { + return Err(syn::Error::new_spanned( + method, + "WebSocket operations require `method = GET`", + )); + } + let client_to_server_frame = route.client_to_server.ok_or_else(|| { + syn::Error::new_spanned( + &route.path, + "WebSocket operations require `client_to_server = FrameType`", + ) + })?; + let server_to_client_frame = route.server_to_client.ok_or_else(|| { + syn::Error::new_spanned( + &route.path, + "WebSocket operations require `server_to_client = FrameType`", + ) + })?; + validate_frame_type(&client_to_server_frame)?; + validate_frame_type(&server_to_client_frame)?; + + let path = route.path.value(); + let placeholders = parse_path_template(&route.path)?; + let mut path_parameters = BTreeSet::new(); + let mut parameters = Vec::new(); + for (ident, ty) in route.path_parameters { + let rust_name = ident_text(&ident); + if !path_parameters.insert(rust_name.clone()) { + return Err(syn::Error::new( + ident.span(), + format!("duplicate path parameter `{rust_name}`"), + )); + } + parameters.push(Parameter { + rust_ident: ident, + rust_name: rust_name.clone(), + wire_name: rust_name, + location: Location::Path, + ty, + }); + } + 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(", ") + ), + )); + } + + let operation_id = route + .operation_id + .as_ref() + .map(LitStr::value) + .unwrap_or_else(|| to_snake_case(&declaration.ident)); + validate_operation_id( + &operation_id, + route.operation_id.as_ref().unwrap_or(&route.path), + )?; + + Ok(Operation { + method_ident: declaration.ident.clone(), + marker_ident: declaration.ident.clone(), + operation_id, + transport: Transport::WebSocket, + method: Method::Get, + path, + parameters, + client_to_server_frame: Some(client_to_server_frame), + server_to_client_frame: Some(server_to_client_frame), + request_body: None, + response_body: None, + success_responses: Vec::new(), + declared_responses: false, + error_body: None, + fallible: false, + response_status: 101, + alternate_status: None, + error_status: None, + additional_error_statuses: Vec::new(), + bearer_auth: false, + browser_auth: false, + normalize_body_errors: false, + openapi_skip: true, + }) +} + +fn validate_frame_type(ty: &Type) -> syn::Result<()> { + if matches!(ty, Type::Path(path) if path.qself.is_none()) { + Ok(()) + } else { + Err(syn::Error::new_spanned( + ty, + "WebSocket frame authorities must use direct named Rust types", + )) + } +} + fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { validate_signature(method)?; @@ -449,31 +838,130 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { validate_named_body_type(ty, "error response")?; } + let declared_responses = !route.responses.is_empty(); + if declared_responses && (route.status.is_some() || route.alternate_status.is_some()) { + return Err(syn::Error::new_spanned( + &route.path, + "`responses` cannot be combined with `status` or `alternate_status`", + )); + } + if declared_responses && response_body.is_none() { + return Err(syn::Error::new_spanned( + &method.sig.output, + "declared responses require the generated success result type", + )); + } + + let mut success_responses = Vec::new(); + if declared_responses { + let mut statuses = BTreeSet::new(); + for response in route.responses { + let status = response.status.base10_parse::().map_err(|_| { + syn::Error::new( + response.status.span(), + "HTTP status must be an unsigned 16-bit integer", + ) + })?; + if !((200..=299).contains(&status) || status == 304) { + return Err(syn::Error::new( + response.status.span(), + "declared success response status must be between 200 and 299, or exactly 304", + )); + } + if !statuses.insert(status) { + return Err(syn::Error::new( + response.status.span(), + "declared success response statuses must be distinct", + )); + } + if response.body.is_some() && matches!(status, 204 | 205 | 304) { + return Err(syn::Error::new( + response.status.span(), + format!("status {status} cannot have a response body"), + )); + } + if http_method == Method::Head && response.body.is_some() { + return Err(syn::Error::new_spanned( + response.body, + "HEAD operations cannot declare a response body", + )); + } + if let Some(body) = response.body.as_ref() { + validate_named_body_type(body, "response")?; + } + let mut header_names = BTreeSet::new(); + let mut field_names = BTreeSet::new(); + let mut headers = Vec::new(); + for header in response.headers { + let wire_name = header.name.value().to_ascii_lowercase(); + validate_response_header_name(&wire_name, &header.name)?; + if !header_names.insert(wire_name.clone()) { + return Err(syn::Error::new_spanned( + header.name, + "duplicate response header name within one response", + )); + } + if !field_names.insert(header.field_ident.to_string()) { + return Err(syn::Error::new_spanned( + header.name, + "response header names produce duplicate Rust field names", + )); + } + headers.push(ResponseHeader { + wire_name, + field_ident: header.field_ident, + ty: header.ty, + }); + } + success_responses.push(SuccessResponse { + status, + body: response.body, + headers, + }); + } + } + 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 mut alternate_status = parse_status(route.alternate_status.as_ref(), "alternate_status")?; + if !declared_responses { + 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", + )); + } + 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", + )); + } + success_responses.push(SuccessResponse { + status: response_status, + body: response_body.clone(), + headers: Vec::new(), + }); + if let Some(status) = alternate_status { + success_responses.push(SuccessResponse { + status, + body: response_body.clone(), + headers: Vec::new(), + }); + } + } else { + alternate_status = None; } let error_status = parse_status(route.error_status.as_ref(), "error_status")?; if error_status.is_some() && error_body.is_none() { @@ -520,13 +1008,13 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { )); } - if http_method == Method::Head && response_body.is_some() { + if !declared_responses && 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() { + if !declared_responses && 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"), @@ -575,6 +1063,15 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { } (Location::Body, rust_name.clone()) } + Some((Location::Binary, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[binary] does not accept a wire name", + )); + } + (Location::Binary, rust_name.clone()) + } Some((Location::Extension, wire_name)) => { if wire_name.is_some() { return Err(syn::Error::new( @@ -588,7 +1085,7 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { 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", + "API arguments must use #[path], #[query], #[header], #[body], #[binary], or #[extension]; path arguments may omit #[path] when their name matches a placeholder", )); } }; @@ -596,15 +1093,28 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { if matches!(location, Location::Path) { path_parameters.insert(rust_name.clone()); } - if matches!(location, Location::Body) { + if matches!(location, Location::Body | Location::Binary) { if body_type.is_some() { return Err(syn::Error::new( ident.span(), - "an API operation may declare only one request body", + "an API operation may declare only one JSON or binary request body", )); } - validate_named_body_type(&input.ty, "request")?; - body_type = Some((*input.ty).clone()); + let wire_kind = match location { + Location::Body => { + validate_named_body_type(&input.ty, "request")?; + RequestWireKind::Json + } + Location::Binary => { + validate_binary_body_type(&input.ty)?; + RequestWireKind::Binary + } + _ => unreachable!(), + }; + body_type = Some(RequestBody { + ty: (*input.ty).clone(), + wire_kind, + }); } parameters.push(Parameter { rust_ident: ident.clone(), @@ -644,16 +1154,35 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { ), )); } + if route + .normalize_body_errors + .as_ref() + .is_some_and(|value| value.value) + && (body_type.is_none() || error_body.is_none()) + { + return Err(syn::Error::new_spanned( + route + .normalize_body_errors + .as_ref() + .expect("checked normalize_body_errors"), + "normalize_body_errors requires a request body and public error response type", + )); + } Ok(Operation { marker_ident: operation_marker_ident(&method.sig.ident), method_ident: method.sig.ident.clone(), operation_id, + transport: Transport::Http, method: http_method, path, parameters, + client_to_server_frame: None, + server_to_client_frame: None, request_body: body_type, response_body, + success_responses, + declared_responses, error_body, fallible, response_status, @@ -717,8 +1246,9 @@ fn take_location(attrs: &mut Vec) -> syn::Result Some(Location::Query), Some("header") => Some(Location::Header), Some("body") => Some(Location::Body), + Some("binary") => Some(Location::Binary), Some("extension") => Some(Location::Extension), - Some("stream") | Some("multipart") | Some("binary") => { + Some("stream") | Some("multipart") => { return Err(syn::Error::new_spanned( attr, "this wire kind is explicitly unsupported by the initial API contract", @@ -827,6 +1357,71 @@ fn validate_operation_id(operation_id: &str, source: &impl Spanned) -> syn::Resu Ok(()) } +fn response_header_field_ident(name: &LitStr) -> syn::Result { + let value = name.value().to_ascii_lowercase(); + validate_response_header_name(&value, name)?; + let field = value + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() { + byte as char + } else { + '_' + } + }) + .collect::(); + Ok(format_ident!("header_{}", field, span = name.span())) +} + +fn validate_response_header_name(name: &str, source: &impl Spanned) -> syn::Result<()> { + let valid = !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }); + if !valid { + return Err(syn::Error::new( + source.span(), + "response header name must be a valid HTTP field name", + )); + } + if matches!( + name, + "connection" + | "content-length" + | "content-type" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) { + return Err(syn::Error::new( + source.span(), + "response header is transport-controlled and cannot be declared", + )); + } + Ok(()) +} + fn parse_status(value: Option<&LitInt>, name: &str) -> syn::Result> { let Some(value) = value else { return Ok(None); @@ -883,6 +1478,15 @@ fn is_unit(ty: &Type) -> bool { matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty()) } +fn is_option_type(ty: &Type) -> bool { + matches!( + ty, + Type::Path(path) + if path.qself.is_none() + && path.path.segments.last().is_some_and(|segment| segment.ident == "Option") + ) +} + fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> { let Type::Path(path) = ty else { return Err(syn::Error::new_spanned( @@ -930,6 +1534,31 @@ fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> { Ok(()) } +fn validate_binary_body_type(ty: &Type) -> syn::Result<()> { + let Type::Path(path) = ty else { + return Err(syn::Error::new_spanned( + ty, + "binary request bodies must use api_macros::BinaryBody", + )); + }; + let Some(last) = path.path.segments.last() else { + return Err(syn::Error::new_spanned( + ty, + "binary request bodies must use api_macros::BinaryBody", + )); + }; + if path.qself.is_some() + || last.ident != "BinaryBody" + || !matches!(last.arguments, PathArguments::None) + { + return Err(syn::Error::new_spanned( + ty, + "binary request bodies must use api_macros::BinaryBody", + )); + } + Ok(()) +} + fn argument_ident(pat: &Pat) -> syn::Result<&Ident> { let Pat::Ident(ident) = pat else { return Err(syn::Error::new_spanned( @@ -1009,7 +1638,16 @@ fn reqwest_adapter_tokens( 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 responses_module_ident = format_ident!( + "{}_responses", + to_snake_case(trait_ident), + span = trait_ident.span() + ); + let methods = api + .operations + .iter() + .filter(|operation| matches!(operation.transport, Transport::Http)) + .map(|operation| { let method_ident = &operation.method_ident; let arguments = operation.parameters.iter().filter_map(|parameter| { if matches!(parameter.location, Location::Extension) { @@ -1040,31 +1678,40 @@ fn reqwest_adapter_tokens( } 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)?; - }) + if is_option_type(¶meter.ty) { + Some(quote! { + #api_crate::reqwest::insert_optional_header(&mut __headers, #name, &#ident) + .map_err(#api_crate::reqwest::ClientError::from)?; + }) + } else { + 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)) + .find(|parameter| matches!(parameter.location, Location::Body | Location::Binary)) .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)?, - ) + match parameter.location { + Location::Body => quote! { + ::core::option::Option::Some( + #api_crate::reqwest::encode_json(&#ident) + .map_err(#api_crate::reqwest::ClientError::from)?, + ) + }, + Location::Binary => quote! { + ::core::option::Option::Some(#api_crate::reqwest::encode_binary(#ident)) + }, + _ => unreachable!(), } }) .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_status = operation.success_responses[0].status; let response_type = operation .response_body .as_ref() @@ -1075,17 +1722,76 @@ fn reqwest_adapter_tokens( .as_ref() .map(|ty| quote!(#ty)) .unwrap_or_else(|| quote!(#api_crate::NoBody)); - let success = if let Some(response) = operation.response_body.as_ref() { + let success_dispatch = if operation.declared_responses { + let response_ident = &operation.marker_ident; + let arms = operation.success_responses.iter().map(|response| { + let status = response.status; + let variant_ident = format_ident!("Status{}", status); + let header_parsers = response.headers.iter().map(|header| { + let field = &header.field_ident; + let ty = &header.ty; + let name = &header.wire_name; + quote! { + let #field = __response + .parse_header::<#ty>(#name) + .map_err(#api_crate::reqwest::ClientError::from)?; + } + }); + let body_parser = match response.body.as_ref() { + Some(ty) => quote! { + let body = __response + .decode_json::<#ty>(#api_crate::reqwest::DecodeKind::Success) + .map_err(#api_crate::reqwest::ClientError::from)?; + }, + None => quote! { + __response + .require_empty() + .map_err(#api_crate::reqwest::ClientError::from)?; + }, + }; + let body_field = response.body.as_ref().map(|_| quote!(body,)); + let header_fields = response.headers.iter().map(|header| &header.field_ident); + quote! { + #status => { + #(#header_parsers)* + #body_parser + return ::core::result::Result::Ok( + #responses_module_ident::#response_ident::#variant_ident { + #body_field + #(#header_fields),* + }, + ); + } + } + }); quote! { - return __response - .decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success) - .map_err(#api_crate::reqwest::ClientError::from); + match __actual { + #(#arms)* + _ => {} + } } } else { + let accepted_status = operation + .alternate_status + .map(|alternate| quote!(__actual == #response_status || __actual == #alternate)) + .unwrap_or_else(|| quote!(__actual == #response_status)); + 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); + } + }; quote! { - return __response - .require_empty() - .map_err(#api_crate::reqwest::ClientError::from); + if #accepted_status { + #success + } } }; let public_error = match operation.error_body.as_ref() { @@ -1122,9 +1828,7 @@ fn reqwest_adapter_tokens( .await .map_err(#api_crate::reqwest::ClientError::from)?; let __actual = __response.status().as_u16(); - if #accepted_status { - #success - } + #success_dispatch #public_error ::core::result::Result::Err(#api_crate::reqwest::ClientError::from( #api_crate::reqwest::ClientFailure::UnexpectedStatus { @@ -1275,7 +1979,16 @@ fn axum_adapter_tokens( 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 responses_module_ident = format_ident!( + "{}_responses", + to_snake_case(trait_ident), + span = trait_ident.span() + ); + let route_functions: Vec<_> = api + .operations + .iter() + .filter(|operation| matches!(operation.transport, Transport::Http)) + .map(|operation| { let method_ident = &operation.method_ident; let handler_ident = format_ident!("{}_handler", ident_text(method_ident)); let route_path = &operation.path; @@ -1311,8 +2024,13 @@ fn axum_adapter_tokens( let ident = ¶meter.rust_ident; let ty = ¶meter.ty; let name = ¶meter.wire_name; + let parser = if is_option_type(ty) { + quote!(#api_crate::axum::parse_optional_header) + } else { + quote!(#api_crate::axum::parse_header) + }; Some(quote! { - let #ident: #ty = match #api_crate::axum::parse_header(&__headers, #name) { + let #ident: #ty = match #parser(&__headers, #name) { Ok(value) => value, Err(status) => return #api_crate::axum::rejection(status), }; @@ -1329,49 +2047,129 @@ fn axum_adapter_tokens( let (body_extractor, body_rejection) = operation .parameters .iter() - .find(|parameter| matches!(parameter.location, Location::Body)) + .find(|parameter| matches!(parameter.location, Location::Body | Location::Binary)) .map(|parameter| { let ident = ¶meter.rust_ident; let ty = ¶meter.ty; - if !operation.normalize_body_errors { - return ( + match parameter.location { + Location::Body if !operation.normalize_body_errors => ( Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)), None, - ); + ), + Location::Body => { + let error = operation + .error_body + .as_ref() + .expect("normalized 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, + ); + } + }; + }), + ) + } + Location::Binary if !operation.normalize_body_errors => ( + Some(quote!(#api_crate::axum::framework::Bytes(__body): #api_crate::axum::framework::Bytes,)), + Some(quote!(let #ident: #ty = __body.into();)), + ), + Location::Binary => { + let error = operation + .error_body + .as_ref() + .expect("normalized body operations require an error type"); + ( + Some(quote!( + __body: ::core::result::Result< + #api_crate::axum::framework::Bytes, + #api_crate::axum::framework::BytesRejection, + >, + )), + Some(quote! { + let #ident: #ty = match __body { + ::core::result::Result::Ok(value) => value.into(), + ::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, + ); + } + }; + }), + ) + } + _ => unreachable!(), } - 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 { + let success = if operation.declared_responses { + let response_ident = &operation.marker_ident; + let arms = operation.success_responses.iter().map(|response| { + let status = response.status; + let variant_ident = format_ident!("Status{}", status); + let body_pattern = response.body.as_ref().map(|_| quote!(body,)); + let header_fields = response.headers.iter().map(|header| &header.field_ident); + let base_response = if response.body.is_some() { + quote!(#api_crate::axum::json_response(#api_crate::axum::status(#status), body)) + } else { + quote!(#api_crate::axum::empty_response(#api_crate::axum::status(#status))) + }; + let header_insertions = response.headers.iter().map(|header| { + let field = &header.field_ident; + let name = &header.wire_name; + quote! { + if #api_crate::axum::insert_response_header( + __response.headers_mut(), + #name, + &#field, + ).is_err() { + return #api_crate::axum::empty_response( + #api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR, + ); + } + } + }); + quote! { + #responses_module_ident::#response_ident::#variant_ident { + #body_pattern + #(#header_fields),* + } => { + let mut __response = #base_response; + #(#header_insertions)* + __response + } + } + }); + quote! { + match value { + #(#arms),* + } + } + } else if let Some(alternate_status) = operation.alternate_status { quote!({ let status = #api_crate::HttpSuccess::status_code(&value); if status == #response_status || status == #alternate_status { @@ -1433,10 +2231,14 @@ fn axum_adapter_tokens( } } }).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()));) - }); + let operation_merges = api + .operations + .iter() + .filter(|operation| matches!(operation.transport, Transport::Http)) + .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), "`].")] @@ -1479,13 +2281,15 @@ fn openapi_adapter_tokens( let operations = api .operations .iter() - .filter(|operation| !operation.openapi_skip) + .filter(|operation| { + matches!(operation.transport, Transport::Http) && !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) { + if matches!(parameter.location, Location::Body | Location::Binary | Location::Extension) { return None; } let ty = ¶meter.ty; @@ -1494,34 +2298,48 @@ fn openapi_adapter_tokens( Location::Path => "path", Location::Query => "query", Location::Header => "header", - Location::Body | Location::Extension => unreachable!(), + Location::Body | Location::Binary | 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 request = operation.request_body.as_ref().map(|body| match body.wire_kind { + RequestWireKind::Json => { + let ty = &body.ty; + quote! { + operation.request_body::<#ty>("application/json")?; + } } + RequestWireKind::Binary => quote! { + operation.binary_request_body()?; + }, }); - 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() { + let success_responses = operation.success_responses.iter().enumerate().map(|(index, response)| { + let status = response.status; + let description = if index == 0 { + "Successful response" + } else { + "Alternate successful response" + }; + let body = match response.body.as_ref() { Some(ty) => quote! { - operation.response::<#ty>(#status, "application/json", "Alternate successful response")?; + operation.response::<#ty>(#status, "application/json", #description)?; }, None => quote! { - operation.empty_response(#status, "Alternate successful response")?; + operation.empty_response(#status, #description)?; }, + }; + let headers = response.headers.iter().map(|header| { + let ty = &header.ty; + let name = &header.wire_name; + quote! { + operation.response_header::<#ty>(#status, #name)?; + } + }); + quote! { + #body + #(#headers)* } }); let error = operation @@ -1561,8 +2379,7 @@ fn openapi_adapter_tokens( #bearer_auth #browser_auth #request - #response - #alternate_response + #(#success_responses)* #error #(#additional_errors)* operation.finish()?; @@ -1590,6 +2407,56 @@ fn expand_api(api: ApiDefinition) -> syn::Result { 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 responses_module_ident = format_ident!( + "{}_responses", + to_snake_case(&api.item.ident), + span = api.item.ident.span() + ); + let response_items: Vec<_> = api + .operations + .iter() + .filter(|operation| operation.declared_responses) + .map(|operation| { + let response_ident = &operation.marker_ident; + let variants = operation.success_responses.iter().map(|response| { + let variant_ident = format_ident!("Status{}", response.status); + let body = response.body.as_ref().map(|ty| quote!(body: #ty,)); + let headers = response.headers.iter().map(|header| { + let field = &header.field_ident; + let ty = &header.ty; + quote!(#field: #ty,) + }); + quote! { + #variant_ident { + #body + #(#headers)* + } + } + }); + let debug_arms = operation.success_responses.iter().map(|response| { + let variant_ident = format_ident!("Status{}", response.status); + quote! { + Self::#variant_ident { .. } => formatter + .debug_struct(stringify!(#variant_ident)) + .finish_non_exhaustive() + } + }); + quote! { + #[doc = "Typed status/body/header result for one declared-success operation."] + pub enum #response_ident { + #(#variants),* + } + + impl ::core::fmt::Debug for #response_ident { + fn fmt(&self, formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + match self { + #(#debug_arms),* + } + } + } + } + }) + .collect(); let item = api.item; let metadata_ident = api.metadata_ident; let module_ident = api.operations_module_ident; @@ -1599,9 +2466,9 @@ fn expand_api(api: ApiDefinition) -> syn::Result { .iter() .map(|operation| { let marker_ident = &operation.marker_ident; - let method_ident = &operation.method_ident; + let declaration_ident = &operation.method_ident; quote! { - #[doc = concat!("Typed operation marker for Rust method `", stringify!(#method_ident), "`.")] + #[doc = concat!("Typed operation marker for contract declaration `", stringify!(#declaration_ident), "`.")] #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] pub struct #marker_ident; } @@ -1610,6 +2477,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result { let operation_impls: Vec<_> = api .operations .iter() + .filter(|operation| matches!(operation.transport, Transport::Http)) .map(|operation| { let marker_ident = &operation.marker_ident; let metadata = metadata_tokens(operation, &api_crate); @@ -1623,7 +2491,10 @@ fn expand_api(api: ApiDefinition) -> syn::Result { let request_type = operation .request_body .as_ref() - .map(|ty| quote!(#ty)) + .map(|body| { + let ty = &body.ty; + quote!(#ty) + }) .unwrap_or_else(|| quote!(#api_crate::NoBody)); let response_type = operation .response_body @@ -1647,6 +2518,35 @@ fn expand_api(api: ApiDefinition) -> syn::Result { } }) .collect(); + let websocket_operation_impls: Vec<_> = api + .operations + .iter() + .filter(|operation| matches!(operation.transport, Transport::WebSocket)) + .map(|operation| { + let marker_ident = &operation.marker_ident; + let metadata = metadata_tokens(operation, &api_crate); + let path_types = ordered_path_parameters(operation) + .into_iter() + .map(|parameter| ¶meter.ty); + let client_to_server = operation + .client_to_server_frame + .as_ref() + .expect("WebSocket operation has a client frame"); + let server_to_client = operation + .server_to_client_frame + .as_ref() + .expect("WebSocket operation has a server frame"); + quote! { + impl #api_crate::WebSocketOperation for #module_ident::#marker_ident { + type PathParameters = (#(#path_types,)*); + type ClientToServerFrame = #client_to_server; + type ServerToClientFrame = #server_to_client; + + const METADATA: #api_crate::OperationMetadata = #metadata; + } + } + }) + .collect(); let inventory: Vec<_> = api .operations .iter() @@ -1671,7 +2571,15 @@ fn expand_api(api: ApiDefinition) -> syn::Result { #(#marker_items)* } + #[doc = "Typed success results generated for operations with declared responses."] + pub mod #responses_module_ident { + #[allow(unused_imports)] + use super::*; + #(#response_items)* + } + #(#operation_impls)* + #(#websocket_operation_impls)* #reqwest_adapter #axum_adapter @@ -1686,38 +2594,80 @@ fn metadata_tokens( 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 transport = match operation.transport { + Transport::Http => quote!(#api_crate::TransportMetadata::Http), + Transport::WebSocket => { + let client_to_server = operation + .client_to_server_frame + .as_ref() + .expect("WebSocket operation has a client frame"); + let server_to_client = operation + .server_to_client_frame + .as_ref() + .expect("WebSocket operation has a server frame"); + quote! { + #api_crate::TransportMetadata::WebSocket { + client_to_server_frame: stringify!(#client_to_server), + server_to_client_frame: stringify!(#server_to_client), + } + } + } }; - let response_kind = if operation.response_body.is_some() { - quote!(#api_crate::WireKind::Json) - } else { - quote!(#api_crate::WireKind::Empty) + let request_kind = match operation.request_body.as_ref().map(|body| body.wire_kind) { + Some(RequestWireKind::Json) => quote!(#api_crate::WireKind::Json), + Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary), + None => 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 { + let success_responses = operation.success_responses.iter().map(|response| { + let status = response.status; + let wire_kind = if response.body.is_some() { + quote!(#api_crate::WireKind::Json) + } else { + quote!(#api_crate::WireKind::Empty) + }; + let headers = response.headers.iter().map(|header| { + let wire_name = &header.wire_name; + let ty = &header.ty; + quote! { + #api_crate::ResponseHeaderMetadata { + wire_name: #wire_name, + rust_type: stringify!(#ty), + } + } + }); + quote! { + #api_crate::ResponseMetadata { status: #status, - body: #api_crate::BodyMetadata { - wire_kind: #api_crate::WireKind::Json, - }, - }) - }, - None => quote!(::core::option::Option::None), - }; + body: #api_crate::BodyMetadata { wire_kind: #wire_kind }, + headers: &[#(#headers),*], + } + } + }); + let error_statuses = operation + .error_status + .into_iter() + .chain(operation.additional_error_statuses.iter().copied()); + let error_responses = error_statuses.map(|status| { + quote! { + #api_crate::ResponseMetadata { + status: #status, + body: #api_crate::BodyMetadata { wire_kind: #api_crate::WireKind::Json }, + headers: &[], + } + } + }); let parameters = operation.parameters.iter().filter_map(|parameter| { if matches!(parameter.location, Location::Extension) { return None; } let rust_name = ¶meter.rust_name; + let ty = ¶meter.ty; let wire_name = ¶meter.wire_name; let location = parameter.location.tokens(api_crate); Some(quote! { #api_crate::ParameterMetadata { rust_name: #rust_name, + rust_type: stringify!(#ty), wire_name: #wire_name, location: #location, } @@ -1729,13 +2679,11 @@ fn metadata_tokens( operation_id: #operation_id, method: #method, path: #path, + transport: #transport, 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, + success_responses: &[#(#success_responses),*], + error_responses: &[#(#error_responses),*], } } } diff --git a/crates/api-macros/Cargo.toml b/crates/api-macros/Cargo.toml index 0bc19b435..a99fb7bde 100644 --- a/crates/api-macros/Cargo.toml +++ b/crates/api-macros/Cargo.toml @@ -20,6 +20,7 @@ reqwest = [ [dependencies] api-macros-impl = { path = "../api-macros-impl" } +bytes.workspace = true axum = { workspace = true, optional = true } futures = { workspace = true, optional = true } reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] } diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs index aeaa3bfc7..8f8807277 100644 --- a/crates/api-macros/src/axum.rs +++ b/crates/api-macros/src/axum.rs @@ -5,12 +5,14 @@ //! 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 std::{fmt, str::FromStr}; use axum::{ - Json, + Json, Router, + handler::Handler, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, + routing::get, }; use serde::Serialize; @@ -28,6 +30,48 @@ where .map_err(|_| StatusCode::BAD_REQUEST) } +pub fn parse_optional_header( + headers: &HeaderMap, + name: &'static str, +) -> Result, StatusCode> +where + T: FromStr, +{ + headers + .get(name) + .map(|value| { + value + .to_str() + .map_err(|_| StatusCode::BAD_REQUEST)? + .parse() + .map_err(|_| StatusCode::BAD_REQUEST) + }) + .transpose() +} + +/// Opaque failure to encode one declared response header. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResponseHeaderError; + +impl fmt::Display for ResponseHeaderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("declared response header encoding failed") + } +} + +impl std::error::Error for ResponseHeaderError {} + +/// Insert one typed declared response header without retaining its value in an error. +pub fn insert_response_header( + headers: &mut HeaderMap, + name: &'static str, + value: &T, +) -> Result<(), ResponseHeaderError> { + let value = value.to_string().parse().map_err(|_| ResponseHeaderError)?; + headers.insert(name, value); + Ok(()) +} + /// Serialize a typed JSON response with the contract status. pub fn json_response(status: StatusCode, value: T) -> Response { (status, Json(value)).into_response() @@ -48,14 +92,56 @@ pub fn status(code: u16) -> StatusCode { StatusCode::from_u16(code).expect("#[api] validates HTTP status constants") } +/// Mount a manual Axum upgrade handler at a contract-declared WebSocket route. +/// +/// The operation marker supplies the only path and method authority. Requiring +/// [`crate::WebSocketOperation`] prevents HTTP operation markers from being registered through this +/// helper while Axum retains its normal route composition and collision behavior. +pub fn websocket_route(router: Router, handler: H) -> Router +where + O: crate::WebSocketOperation, + H: Handler, + T: 'static, + S: Clone + Send + Sync + 'static, +{ + debug_assert_eq!(O::METADATA.method, crate::HttpMethod::Get); + debug_assert!(matches!( + O::METADATA.transport, + crate::TransportMetadata::WebSocket { .. } + )); + router.route(O::METADATA.path, get(handler)) +} + /// Reexports used by generated router code. pub mod framework { pub use axum::{ Json, Router, - extract::{Extension, Path, Query, State, rejection::JsonRejection}, + body::{Body, Bytes}, + extract::{ + DefaultBodyLimit, Extension, Path, Query, State, + rejection::{BytesRejection, JsonRejection}, + }, http::{HeaderMap, StatusCode}, response::Response, routing::{delete, get, head, options, patch, post, put}, serve, }; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_header_encoding_failure_is_bounded_and_value_safe() { + let mut headers = HeaderMap::new(); + let error = insert_response_header(&mut headers, "set-cookie", &"secret\ninvalid") + .expect_err("invalid header bytes must fail"); + assert_eq!( + error.to_string(), + "declared response header encoding failed" + ); + assert!(!format!("{error:?}").contains("secret")); + assert!(headers.is_empty()); + } +} diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index 318bad6ef..a3227f814 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -39,21 +39,54 @@ //! 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`; +//! outcomes share one schema. Operations whose successful statuses have different body or header +//! shapes use `responses = [(status = 200, body = Widget, headers = [("etag", String)]), +//! (status = 304, headers = [("etag", String)])]`; the macro emits a typed result enum in the +//! `_responses` module. Only explicit body-less `304` declarations extend success +//! beyond 2xx. 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. +//! Arguments are classified with `#[body]`, `#[binary]`, `#[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 or binary body is allowed. JSON request, response, +//! and error bodies must be named Rust types; tuples, references, arrays, and other anonymous +//! structural types are rejected. Binary request bodies use [`BinaryBody`] and remain byte-exact. //! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the //! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated. //! +//! WebSocket routes use a declaration-only associated type rather than a dummy unary method: +//! +//! ``` +//! # mod websocket_example { +//! # use api_macros::api; +//! # pub struct ClientFrame; +//! # pub struct ServerFrame; +//! #[api] +//! pub trait EventApi { +//! #[websocket( +//! "/events/{stream_id}", +//! operation_id = "events.stream", +//! method = GET, +//! client_to_server = ClientFrame, +//! server_to_client = ServerFrame, +//! path_parameters = [stream_id: u64] +//! )] +//! type EventStream; +//! } +//! # } +//! ``` +//! +//! The declaration is removed from the emitted service trait and becomes a typed marker implementing +//! [`WebSocketOperation`]. It participates in the same operation/route inventory and path validation, +//! but never generates a Reqwest method, unary Axum adapter, or OpenAPI path. Under the `axum` +//! feature, [`axum::websocket_route`] mounts an existing manual upgrade handler at its declared GET +//! path without exposing framework WebSocket types to the contract. +//! //! # Generated names //! //! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`], @@ -63,9 +96,9 @@ //! 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. +//! Empty, JSON, and bounded binary request bodies are supported. [`WireKind`] reserves explicit +//! variants for other future transport work; accepting one requires a deliberate macro and adapter +//! change rather than silently treating it as JSON. //! //! # OpenAPI 3.1 export //! @@ -115,6 +148,70 @@ pub mod openapi; #[cfg(feature = "reqwest")] pub mod reqwest; +/// Framework-neutral owned bytes used by explicit `#[binary]` request bodies. +/// +/// The wrapper preserves the exact byte sequence while keeping payload content out of `Debug` +/// output. Its `bytes::Bytes` storage lets generated Axum adapters transfer an extracted body +/// without reserialization and lets generated Reqwest clients authorize and send the same storage. +#[derive(Clone, Default, Eq, Hash, PartialEq)] +pub struct BinaryBody(bytes::Bytes); + +impl BinaryBody { + pub const fn new(bytes: bytes::Bytes) -> Self { + Self(bytes) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn into_bytes(self) -> bytes::Bytes { + self.0 + } +} + +impl AsRef<[u8]> for BinaryBody { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl std::ops::Deref for BinaryBody { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl From for BinaryBody { + fn from(value: bytes::Bytes) -> Self { + Self(value) + } +} + +impl From> for BinaryBody { + fn from(value: Vec) -> Self { + Self(value.into()) + } +} + +impl From<&'static [u8]> for BinaryBody { + fn from(value: &'static [u8]) -> Self { + Self(bytes::Bytes::from_static(value)) + } +} + +impl std::fmt::Debug for BinaryBody { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BinaryBody()") + } +} + /// A transport-neutral HTTP method. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum HttpMethod { @@ -147,25 +244,48 @@ pub enum WireKind { Binary, } -/// Stable metadata for one Rust method argument. +/// Stable metadata for one operation argument. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct ParameterMetadata { pub rust_name: &'static str, + pub rust_type: &'static str, pub wire_name: &'static str, pub location: ParameterLocation, } +/// Transport semantics declared by one operation. +/// +/// WebSocket frame directions remain distinct even when both directions use the same Rust type. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TransportMetadata { + Http, + WebSocket { + client_to_server_frame: &'static str, + server_to_client_frame: &'static str, + }, +} + /// 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 explicitly declared response header. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ResponseHeaderMetadata { + /// Canonical wire name declared in the API contract. + pub wire_name: &'static str, + /// Rust type spelling declared alongside the wire name. + pub rust_type: &'static str, +} + /// Stable metadata for one HTTP response. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct ResponseMetadata { pub status: u16, pub body: BodyMetadata, + pub headers: &'static [ResponseHeaderMetadata], } /// Complete transport metadata for one operation. @@ -174,10 +294,13 @@ pub struct OperationMetadata { pub operation_id: &'static str, pub method: HttpMethod, pub path: &'static str, + pub transport: TransportMetadata, pub parameters: &'static [ParameterMetadata], pub request_body: BodyMetadata, - pub response: ResponseMetadata, - pub error_response: Option, + /// Every declared successful response, including status-specific body and header shape. + pub success_responses: &'static [ResponseMetadata], + /// Every declared public-error status. Error responses currently have no declared headers. + pub error_responses: &'static [ResponseMetadata], } /// Deterministic operation inventory emitted for an `#[api]` trait. @@ -189,9 +312,9 @@ pub trait ApiContract { pub trait Operation { /// Tuple of all non-receiver parameter types in declaration order. type Parameters; - /// JSON request body type, or [`NoBody`]. + /// Typed JSON or binary request body, or [`NoBody`]. type RequestBody; - /// JSON success response body type, or [`NoBody`]. + /// JSON success response body type, generated declared-response result, or [`NoBody`]. type ResponseBody; /// JSON public error body type, or [`NoBody`]. type ErrorBody; @@ -199,6 +322,21 @@ pub trait Operation { const METADATA: OperationMetadata; } +/// Compile-time connection between a WebSocket route and its directional frame authorities. +/// +/// This is intentionally separate from [`Operation`]: WebSocket routes do not have unary request, +/// response, or error bodies and therefore cannot be passed to HTTP adapter surfaces. +pub trait WebSocketOperation { + /// Tuple of path parameter types in path-template order. + type PathParameters; + /// Frame received by the server from the connected client. + type ClientToServerFrame; + /// Frame sent by the server to the connected client. + type ServerToClientFrame; + + 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 diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs index 4f5158289..81b6a096a 100644 --- a/crates/api-macros/src/openapi.rs +++ b/crates/api-macros/src/openapi.rs @@ -385,6 +385,24 @@ impl OpenApiOperation<'_> { Ok(()) } + pub fn binary_request_body(&mut self) -> Result<(), OpenApiError> { + if self.request_body.is_some() { + return Err(OpenApiError::InvalidContract(format!( + "multiple request bodies for `{}`", + self.operation_id + ))); + } + self.request_body = Some(json!({ + "required": true, + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + }, + })); + Ok(()) + } + pub fn response( &mut self, status: u16, @@ -409,6 +427,42 @@ impl OpenApiOperation<'_> { self.insert_response(status, json!({ "description": description })) } + pub fn response_header( + &mut self, + status: u16, + name: &'static str, + ) -> Result<(), OpenApiError> { + let schema = self.parent.schema_ref::()?; + let response = self + .responses + .get_mut(&status.to_string()) + .and_then(Value::as_object_mut) + .ok_or_else(|| { + OpenApiError::InvalidContract(format!( + "response header `{name}` precedes status `{status}` for `{}`", + self.operation_id + )) + })?; + let headers = response + .entry("headers") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| { + OpenApiError::InvalidContract(format!( + "response headers for status `{status}` are not an object for `{}`", + self.operation_id + )) + })?; + if headers.contains_key(name) { + return Err(OpenApiError::InvalidContract(format!( + "duplicate response header `{name}` for status `{status}` on `{}`", + self.operation_id + ))); + } + headers.insert(name.to_owned(), json!({ "schema": schema })); + Ok(()) + } + fn insert_response(&mut self, status: u16, response: Value) -> Result<(), OpenApiError> { if !(100..=599).contains(&status) { return Err(OpenApiError::InvalidContract(format!( diff --git a/crates/api-macros/src/reqwest.rs b/crates/api-macros/src/reqwest.rs index dd54b58d6..2a93c984a 100644 --- a/crates/api-macros/src/reqwest.rs +++ b/crates/api-macros/src/reqwest.rs @@ -6,6 +6,7 @@ use std::{fmt, time::Duration}; +use bytes::Bytes; use futures::StreamExt as _; use reqwest::{ Client, Method, StatusCode, Url, @@ -319,15 +320,50 @@ impl ClientCore { } } +/// A prepared request body whose bytes are authorized and transmitted without further encoding. +pub struct EncodedBody { + bytes: Bytes, + content_type: &'static str, +} + +impl EncodedBody { + fn json(bytes: Vec) -> Self { + Self { + bytes: bytes.into(), + content_type: "application/json", + } + } + + fn binary(body: crate::BinaryBody) -> Self { + Self { + bytes: body.into_bytes(), + content_type: "application/octet-stream", + } + } +} + +impl fmt::Debug for EncodedBody { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EncodedBody") + .field("bytes", &"") + .field("content_type", &self.content_type) + .finish() + } +} + impl ClientCore { pub async fn send( &self, method: Method, url: Url, mut headers: HeaderMap, - body: Option>, + body: Option, ) -> Result { - let body_bytes = body.as_deref().unwrap_or_default(); + let body_bytes = body + .as_ref() + .map(|body| body.bytes.as_ref()) + .unwrap_or_default(); let path_and_query = &url[url::Position::BeforePath..url::Position::AfterQuery]; let authorization = self .authorizer @@ -338,6 +374,12 @@ impl ClientCore { }) .map_err(|_| ClientFailure::Authorization)?; headers.extend(authorization); + if let Some(body) = body.as_ref() { + headers.insert( + reqwest::header::CONTENT_TYPE, + HeaderValue::from_static(body.content_type), + ); + } let mut request = self .client @@ -345,9 +387,7 @@ impl ClientCore { .headers(headers) .timeout(self.request_timeout); if let Some(body) = body { - request = request - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body); + request = request.body(body.bytes); } let response = request.send().await.map_err(|error| { if error.is_timeout() { @@ -359,6 +399,7 @@ impl ClientCore { } })?; let status = response.status(); + let headers = response.headers().clone(); let mut stream = response.bytes_stream(); let mut bytes = Vec::new(); while let Some(chunk) = stream.next().await { @@ -381,7 +422,11 @@ impl ClientCore { } bytes.extend_from_slice(&chunk); } - Ok(ReceivedResponse { status, bytes }) + Ok(ReceivedResponse { + status, + headers, + bytes, + }) } } @@ -413,14 +458,34 @@ pub fn insert_header( 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) +/// Insert an optional string-form header value when present. +pub fn insert_optional_header( + headers: &mut HeaderMap, + name: &str, + value: &Option, +) -> Result<(), ClientFailure> { + match value { + Some(value) => insert_header(headers, name, value), + None => Ok(()), + } +} + +/// Serialize a JSON request body once. These exact bytes are authorized and transmitted. +pub fn encode_json(value: &T) -> Result { + serde_json::to_vec(value) + .map(EncodedBody::json) + .map_err(|_| ClientFailure::RequestEncoding) +} + +/// Move an explicit binary request body into the exact authorized/transmitted representation. +pub fn encode_binary(value: crate::BinaryBody) -> EncodedBody { + EncodedBody::binary(value) } /// Response received under the configured byte limit. Its content is always redacted in Debug. pub struct ReceivedResponse { status: StatusCode, + headers: HeaderMap, bytes: Vec, } @@ -429,6 +494,24 @@ impl ReceivedResponse { self.status } + pub fn parse_header(&self, name: &'static str) -> Result + where + T: std::str::FromStr, + { + let mut values = self.headers.get_all(name).iter(); + let value = values + .next() + .ok_or(ClientFailure::MissingResponseHeader { name })?; + if values.next().is_some() { + return Err(ClientFailure::DuplicateResponseHeader { name }); + } + value + .to_str() + .map_err(|_| ClientFailure::InvalidResponseHeader { name })? + .parse() + .map_err(|_| ClientFailure::InvalidResponseHeader { name }) + } + pub fn decode_json(&self, kind: DecodeKind) -> Result { serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind }) } @@ -447,6 +530,7 @@ impl fmt::Debug for ReceivedResponse { formatter .debug_struct("ReceivedResponse") .field("status", &self.status) + .field("headers", &"") .field("bytes", &"") .finish() } @@ -470,6 +554,9 @@ pub enum ClientFailure { UnexpectedStatus { expected: u16, actual: u16 }, ErrorResponseDecode { status: u16 }, Decode { kind: DecodeKind }, + MissingResponseHeader { name: &'static str }, + DuplicateResponseHeader { name: &'static str }, + InvalidResponseHeader { name: &'static str }, UnexpectedBody, } @@ -505,6 +592,18 @@ impl fmt::Display for ClientFailure { Self::Decode { kind: DecodeKind::PublicError, } => formatter.write_str("public error response JSON was malformed"), + Self::MissingResponseHeader { name } => { + write!(formatter, "response header `{name}` was missing") + } + Self::DuplicateResponseHeader { name } => { + write!( + formatter, + "response header `{name}` occurred more than once" + ) + } + Self::InvalidResponseHeader { name } => { + write!(formatter, "response header `{name}` was invalid") + } Self::UnexpectedBody => formatter.write_str("empty response contained a body"), } } @@ -569,3 +668,19 @@ impl std::error::Error for ClientError {} pub mod framework { pub use reqwest::{Client, Method, StatusCode, Url, header}; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bodyless_response_validation_rejects_retained_bytes_without_exposing_them() { + let response = ReceivedResponse { + status: StatusCode::NOT_MODIFIED, + headers: HeaderMap::new(), + bytes: b"sensitive-body".to_vec(), + }; + assert_eq!(response.require_empty(), Err(ClientFailure::UnexpectedBody)); + assert!(!format!("{response:?}").contains("sensitive-body")); + } +} diff --git a/crates/api-macros/tests/expansion.rs b/crates/api-macros/tests/expansion.rs index a52ae4a78..200b202e3 100644 --- a/crates/api-macros/tests/expansion.rs +++ b/crates/api-macros/tests/expansion.rs @@ -1,12 +1,33 @@ #![allow(async_fn_in_trait)] -use api_macros::{ApiContract, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api}; +use api_macros::{ + ApiContract, BinaryBody, HttpMethod, NoBody, Operation, ParameterLocation, TransportMetadata, + WebSocketOperation, WireKind, api, +}; pub struct CreateWidget; pub struct Widget; pub struct Lookup; pub struct PublicError; pub struct SearchResult(std::marker::PhantomData); +pub struct ClientFrame; +pub struct ServerFrame; + +#[api] +pub trait MixedTransportApi { + #[get("/status")] + async fn status(&self) -> Widget; + + #[websocket( + "/widgets/{widget_id}/events", + operation_id = "widgets.events", + method = GET, + client_to_server = ClientFrame, + server_to_client = ServerFrame, + path_parameters = [widget_id: u64] + )] + type WidgetEvents; +} #[api] pub trait WidgetApi { @@ -35,6 +56,25 @@ pub trait WrapperApi { async fn search(&self) -> SearchResult; } +#[api] +pub trait BinaryApi { + #[put("/payload", operation_id = "payload.upload")] + async fn upload(&self, #[binary] payload: BinaryBody) -> Widget; +} + +#[api] +pub trait ConditionalApi { + #[get( + "/conditional", + operation_id = "conditional.get", + responses = [ + (status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]), + (status = 304, headers = [("etag", String), ("cache-control", String)]) + ] + )] + async fn fetch(&self) -> conditional_api_responses::Fetch; +} + #[api] pub trait RawIdentifierApi { #[get("/type", operation_id = "raw.type")] @@ -85,11 +125,13 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() { assert_eq!(operations[0], create); assert_eq!(create.method, HttpMethod::Post); assert_eq!(create.path, "/widgets"); + assert_eq!(create.transport, TransportMetadata::Http); 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.success_responses[0].status, 201); + assert_eq!(create.success_responses[0].body.wire_kind, WireKind::Json); + assert_eq!(create.error_responses[0].status, 422); assert_eq!(create.parameters[0].location, ParameterLocation::Body); + assert_eq!(create.parameters[0].rust_type, "CreateWidget"); assert_eq!(create.parameters[1].location, ParameterLocation::Header); assert_eq!(create.parameters[1].wire_name, "x-request-id"); @@ -97,13 +139,70 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() { assert_eq!(lookup.parameters[0].location, ParameterLocation::Path); assert_eq!(lookup.parameters[1].location, ParameterLocation::Query); + let upload = ::METADATA; + assert_eq!(upload.request_body.wire_kind, WireKind::Binary); + assert_eq!(upload.parameters[0].location, ParameterLocation::Body); + fn assert_binary_request>() {} + assert_binary_request::(); + + let conditional = ::METADATA; + assert_eq!(conditional.success_responses.len(), 2); + assert_eq!(conditional.success_responses[0].status, 200); + assert_eq!( + conditional.success_responses[0].body.wire_kind, + WireKind::Json + ); + assert_eq!(conditional.success_responses[0].headers.len(), 2); + assert_eq!( + conditional.success_responses[0].headers[0].wire_name, + "etag" + ); + assert_eq!( + conditional.success_responses[0].headers[0].rust_type, + "String" + ); + assert_eq!(conditional.success_responses[1].status, 304); + assert_eq!( + conditional.success_responses[1].body.wire_kind, + WireKind::Empty + ); + fn assert_conditional_response< + O: Operation, + >() { + } + assert_conditional_response::(); + let delete = ::METADATA; - assert_eq!(delete.response.status, 204); - assert_eq!(delete.response.body.wire_kind, WireKind::Empty); - assert_eq!(delete.error_response, None); + assert_eq!(delete.success_responses[0].status, 204); + assert_eq!(delete.success_responses[0].body.wire_kind, WireKind::Empty); + assert!(delete.error_responses.is_empty()); fn assert_empty_request>() {} fn assert_empty_response>() {} assert_empty_request::(); assert_empty_response::(); + + fn assert_websocket_types< + O: WebSocketOperation< + PathParameters = (u64,), + ClientToServerFrame = ClientFrame, + ServerToClientFrame = ServerFrame, + >, + >() { + } + assert_websocket_types::(); + let websocket = ::METADATA; + assert_eq!(websocket.method, HttpMethod::Get); + assert_eq!(websocket.path, "/widgets/{widget_id}/events"); + assert_eq!(websocket.parameters[0].rust_name, "widget_id"); + assert_eq!(websocket.parameters[0].rust_type, "u64"); + assert!(websocket.success_responses.is_empty()); + assert!(websocket.error_responses.is_empty()); + assert_eq!( + websocket.transport, + TransportMetadata::WebSocket { + client_to_server_frame: "ClientFrame", + server_to_client_frame: "ServerFrame", + } + ); } diff --git a/crates/api-macros/tests/http_adapters.rs b/crates/api-macros/tests/http_adapters.rs index efc9d3a68..806550b3a 100644 --- a/crates/api-macros/tests/http_adapters.rs +++ b/crates/api-macros/tests/http_adapters.rs @@ -8,6 +8,26 @@ use std::{ use api_macros::{api, reqwest as api_reqwest}; use serde::{Deserialize, Serialize}; +pub struct NonSerdeClientFrame; +pub struct NonSerdeServerFrame; + +#[api(reqwest, axum)] +pub trait WebSocketOnlyApi { + #[websocket( + "/events/{stream_id}", + operation_id = "events.websocket", + method = GET, + client_to_server = NonSerdeClientFrame, + server_to_client = NonSerdeServerFrame, + path_parameters = [stream_id: u64] + )] + type Events; +} + +#[derive(Clone)] +pub struct WebSocketOnlyService; +impl WebSocketOnlyApi for WebSocketOnlyService {} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CreateWidget { name: String, @@ -38,6 +58,12 @@ impl api_macros::HttpError for PublicError { } } +impl api_macros::HttpRequestError for PublicError { + fn from_request_rejection(status: u16, message: String) -> Self { + Self { message, status } + } +} + #[api(reqwest, axum)] pub trait WidgetApi { #[post( @@ -86,6 +112,123 @@ impl WidgetApi for WidgetService { async fn delete(&self, _widget_id: u64) {} } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BinaryReceipt { + bytes: Vec, + content_type: String, +} + +#[api(reqwest, axum)] +pub trait BinaryApi { + #[put( + "/binary", + operation_id = "binary.put", + status = 200, + error_status = 400, + additional_error_statuses = [413], + normalize_body_errors = true + )] + async fn upload( + &self, + #[header("content-type")] content_type: String, + #[binary] body: api_macros::BinaryBody, + ) -> Result; +} + +#[derive(Clone)] +pub struct BinaryService; + +impl BinaryApi for BinaryService { + async fn upload( + &self, + content_type: String, + body: api_macros::BinaryBody, + ) -> Result { + Ok(BinaryReceipt { + bytes: body.as_ref().to_vec(), + content_type, + }) + } +} + +#[api(reqwest, axum)] +pub trait ResponseApi { + #[get( + "/conditional", + operation_id = "responses.conditional", + responses = [ + (status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]), + (status = 304, headers = [("etag", String), ("cache-control", String)]) + ] + )] + async fn conditional(&self, #[query] lookup: Lookup) -> response_api_responses::Conditional; + + #[post( + "/cookie", + operation_id = "responses.cookie", + responses = [ + (status = 200, body = Widget, headers = [("set-cookie", String)]) + ] + )] + async fn cookie(&self) -> response_api_responses::Cookie; +} + +#[derive(Clone)] +pub struct ResponseService; + +impl ResponseApi for ResponseService { + async fn conditional(&self, lookup: Lookup) -> response_api_responses::Conditional { + if lookup.mode == "cached" { + response_api_responses::Conditional::Status304 { + header_etag: "\"widget-v1\"".to_owned(), + header_cache_control: "no-cache".to_owned(), + } + } else { + response_api_responses::Conditional::Status200 { + body: Widget { + id: 42, + name: "conditional".to_owned(), + mode: lookup.mode, + }, + header_etag: "\"widget-v1\"".to_owned(), + header_cache_control: "no-cache".to_owned(), + } + } + } + + async fn cookie(&self) -> response_api_responses::Cookie { + response_api_responses::Cookie::Status200 { + body: Widget { + id: 7, + name: "cookie".to_owned(), + mode: "session".to_owned(), + }, + header_set_cookie: "session=top-secret; HttpOnly; SameSite=Lax".to_owned(), + } + } +} + +#[api(reqwest)] +pub trait ResponseFailureApi { + #[get( + "/missing-header", + responses = [(status = 200, headers = [("x-count", u32)])] + )] + async fn missing_header(&self) -> response_failure_api_responses::MissingHeader; + + #[get( + "/invalid-header", + responses = [(status = 200, headers = [("x-count", u32)])] + )] + async fn invalid_header(&self) -> response_failure_api_responses::InvalidHeader; + + #[get( + "/duplicate-header", + responses = [(status = 200, headers = [("x-count", u32)])] + )] + async fn duplicate_header(&self) -> response_failure_api_responses::DuplicateHeader; +} + #[api(reqwest)] pub trait FailureApi { #[get("/malformed", operation_id = "failure.malformed", status = 200)] @@ -130,6 +273,27 @@ impl api_reqwest::RequestAuthorizer for RecordingAuthorizer { } } +async fn missing_header() -> api_macros::axum::framework::StatusCode { + api_macros::axum::framework::StatusCode::OK +} + +async fn invalid_header() -> api_macros::axum::framework::Response { + api_macros::axum::framework::Response::builder() + .status(api_macros::axum::framework::StatusCode::OK) + .header("x-count", "not-a-number") + .body(api_macros::axum::framework::Body::empty()) + .expect("valid fixture response") +} + +async fn duplicate_header() -> api_macros::axum::framework::Response { + api_macros::axum::framework::Response::builder() + .status(api_macros::axum::framework::StatusCode::OK) + .header("x-count", "1") + .header("x-count", "2") + .body(api_macros::axum::framework::Body::empty()) + .expect("valid fixture response") +} + async fn malformed() -> &'static str { "this is not json" } @@ -143,6 +307,40 @@ async fn slow() -> &'static str { "{}" } +async fn manual_websocket_route( + api_macros::axum::framework::Path(stream_id): api_macros::axum::framework::Path, +) -> String { + format!("stream:{stream_id}") +} + +#[tokio::test] +async fn websocket_declarations_skip_unary_adapters_and_mount_manual_handlers() { + let _client = WebSocketOnlyApiClient::try_new("http://127.0.0.1:1") + .expect("WebSocket-only contracts still provide a client with no unary methods"); + let app = api_macros::axum::websocket_route::( + api_macros::axum::framework::Router::new(), + manual_websocket_route, + ); + 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 WebSocket route"); + }); + + let body = reqwest::get(format!("http://{address}/events/42")) + .await + .expect("manual route response") + .text() + .await + .expect("manual route body"); + assert_eq!(body, "stream:42"); + server.abort(); +} + #[tokio::test] async fn generated_client_and_router_follow_the_normalized_contract() { let app = WidgetApiAxum::router(WidgetService).merge( @@ -276,6 +474,184 @@ async fn generated_client_and_router_follow_the_normalized_contract() { server.abort(); } +#[tokio::test] +async fn declared_response_status_bodies_and_headers_round_trip_without_secret_diagnostics() { + let app = ResponseApiAxum::router(ResponseService).merge( + api_macros::axum::framework::Router::new() + .route( + "/missing-header", + api_macros::axum::framework::get(missing_header), + ) + .route( + "/invalid-header", + api_macros::axum::framework::get(invalid_header), + ) + .route( + "/duplicate-header", + api_macros::axum::framework::get(duplicate_header), + ), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind declared-response 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 declared-response fixture API"); + }); + let base_url = format!("http://{address}/"); + let client = ResponseApiClient::try_new(&base_url).expect("response client"); + + let fresh = client + .conditional(Lookup { + mode: "fresh".to_owned(), + }) + .await + .expect("200 declared response"); + match fresh { + response_api_responses::Conditional::Status200 { + body, + header_etag, + header_cache_control, + } => { + assert_eq!(body.id, 42); + assert_eq!(header_etag, "\"widget-v1\""); + assert_eq!(header_cache_control, "no-cache"); + } + other => panic!("unexpected fresh variant: {other:?}"), + } + + let cached = client + .conditional(Lookup { + mode: "cached".to_owned(), + }) + .await + .expect("304 declared response"); + assert!(matches!( + cached, + response_api_responses::Conditional::Status304 { + ref header_etag, + ref header_cache_control, + } if header_etag == "\"widget-v1\"" && header_cache_control == "no-cache" + )); + + let cookie = client.cookie().await.expect("cookie response"); + let cookie_debug = format!("{cookie:?}"); + assert!(!cookie_debug.contains("top-secret")); + match cookie { + response_api_responses::Cookie::Status200 { + body, + header_set_cookie, + } => { + assert_eq!(body.name, "cookie"); + assert_eq!( + header_set_cookie, + "session=top-secret; HttpOnly; SameSite=Lax" + ); + } + } + + let failures = ResponseFailureApiClient::try_new(&base_url).expect("failure client"); + let missing = failures + .missing_header() + .await + .expect_err("missing header must fail"); + assert!(matches!( + missing, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::MissingResponseHeader { + name: "x-count" + }) + )); + let invalid = failures + .invalid_header() + .await + .expect_err("invalid header must fail"); + assert!(matches!( + invalid, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::InvalidResponseHeader { + name: "x-count" + }) + )); + let duplicate = failures + .duplicate_header() + .await + .expect_err("duplicate header must fail"); + assert!(matches!( + duplicate, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::DuplicateResponseHeader { + name: "x-count" + }) + )); + + server.abort(); +} + +#[tokio::test] +async fn binary_adapter_preserves_exact_bytes_content_type_and_route_limit() { + let app = binary_api_axum::upload(Arc::new(BinaryService)) + .layer(api_macros::axum::framework::DefaultBodyLimit::max(4)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind binary fixture server"); + let address = listener.local_addr().expect("binary fixture address"); + let server = tokio::spawn(async move { + api_macros::axum::framework::serve(listener, app) + .await + .expect("serve binary fixture API"); + }); + + let recorded = Arc::new(Mutex::new(None)); + let client = BinaryApiClient::builder(format!("http://{address}/")) + .expect("validated base URL") + .authorizer(RecordingAuthorizer { + request: recorded.clone(), + credential: "Bearer binary-test".to_owned(), + }) + .build() + .expect("binary client build"); + + for expected in [vec![], vec![0, 1], vec![0, 255, 1, 2]] { + let receipt = client + .upload( + "text/plain".to_owned(), + api_macros::BinaryBody::from(expected.clone()), + ) + .await + .expect("binary body within the route limit"); + assert_eq!(receipt.bytes, expected); + assert_eq!(receipt.content_type, "application/octet-stream"); + assert_eq!( + recorded + .lock() + .expect("recording lock") + .as_ref() + .expect("authorized request") + .body, + expected + ); + } + + let oversized = client + .upload( + "text/plain".to_owned(), + api_macros::BinaryBody::from(vec![0, 1, 2, 3, 4]), + ) + .await + .expect_err("one byte over the route limit must be rejected"); + assert!(matches!( + oversized, + api_reqwest::ClientError::Public { status, .. } + if status == api_reqwest::framework::StatusCode::PAYLOAD_TOO_LARGE + )); + assert_eq!( + format!("{:?}", api_macros::BinaryBody::from(vec![1, 2, 3])), + "BinaryBody()" + ); + + 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()); diff --git a/crates/api-macros/tests/openapi.rs b/crates/api-macros/tests/openapi.rs index 1a79d6d30..db9bc5e74 100644 --- a/crates/api-macros/tests/openapi.rs +++ b/crates/api-macros/tests/openapi.rs @@ -2,7 +2,7 @@ #![allow(async_fn_in_trait, dead_code)] use api_macros::{ - api, + BinaryBody, api, openapi::{OpenApiInfo, OpenApiSchema}, }; use schemars::JsonSchema; @@ -81,6 +81,9 @@ impl OpenApiSchema for PublicError {} type OptionalRequestId = Option; +pub struct ClientFrame; +pub struct ServerFrame; + #[api(openapi)] pub trait FixtureApi { #[post( @@ -100,6 +103,29 @@ pub trait FixtureApi { #[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)] async fn delete(&self, #[path] widget_id: u32) -> (); + + #[put("/uploads", operation_id = "uploads.put", status = 200)] + async fn upload(&self, #[binary] body: BinaryBody) -> Widget; + + #[get( + "/conditional", + operation_id = "conditional.get", + responses = [ + (status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]), + (status = 304, headers = [("etag", String), ("cache-control", String)]) + ] + )] + async fn conditional(&self) -> fixture_api_responses::Conditional; + + #[websocket( + "/widgets/{widget_id}/events", + operation_id = "widgets.events", + method = GET, + client_to_server = ClientFrame, + server_to_client = ServerFrame, + path_parameters = [widget_id: u32] + )] + type WidgetEvents; } fn document() -> api_macros::openapi::OpenApiDocument { @@ -175,6 +201,10 @@ fn collect_schema_references<'a>(value: &'a Value, references: &mut Vec<&'a str> #[test] fn operations_and_components_preserve_the_wire_contract() { let value = document().as_value().clone(); + assert!( + value["paths"]["/widgets/{widget_id}/events"].is_null(), + "WebSocket transports must not be projected as unary OpenAPI operations" + ); let create = &value["paths"]["/widgets/{widget_id}"]["post"]; assert_eq!(create["operationId"], "widgets.create"); assert_eq!(create["security"], json!([{ "bearerAuth": [] }])); @@ -282,6 +312,44 @@ fn operations_and_components_preserve_the_wire_contract() { ); } + let upload = &value["paths"]["/uploads"]["put"]; + assert_eq!(upload["operationId"], "uploads.put"); + assert_eq!( + upload["requestBody"], + json!({ + "required": true, + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + } + }) + ); + assert!( + !schemas.contains_key("BinaryBody"), + "binary payloads are not JSON Schema components" + ); + + let conditional = &value["paths"]["/conditional"]["get"]; + assert_eq!( + conditional["responses"]["200"]["content"]["application/json"]["schema"], + json!({ "$ref": "#/components/schemas/Widget" }) + ); + assert_eq!( + conditional["responses"]["200"]["headers"]["etag"]["schema"], + json!({ "$ref": "#/components/schemas/string" }) + ); + assert_eq!( + conditional["responses"]["304"], + json!({ + "description": "Alternate successful response", + "headers": { + "cache-control": { "schema": { "$ref": "#/components/schemas/string" } }, + "etag": { "schema": { "$ref": "#/components/schemas/string" } } + } + }) + ); + let delete = &value["paths"]["/widgets/{widget_id}"]["delete"]; assert_eq!(delete["operationId"], "widgets.delete"); assert!(delete.get("security").is_none()); diff --git a/crates/api-macros/tests/ui/duplicate_response_header.rs b/crates/api-macros/tests/ui/duplicate_response_header.rs new file mode 100644 index 000000000..f900ba1ba --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_response_header.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Body; + +#[api] +pub trait DuplicateResponseHeaderApi { + #[get( + "/duplicate", + responses = [(status = 200, body = Body, headers = [("etag", String), ("ETag", String)])] + )] + async fn duplicate(&self) -> duplicate_response_header_api_responses::Duplicate; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_response_header.stderr b/crates/api-macros/tests/ui/duplicate_response_header.stderr new file mode 100644 index 000000000..f899088d7 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_response_header.stderr @@ -0,0 +1,5 @@ +error: duplicate response header name within one response + --> tests/ui/duplicate_response_header.rs:9:80 + | +9 | responses = [(status = 200, body = Body, headers = [("etag", String), ("ETag", String)])] + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.rs b/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.rs new file mode 100644 index 000000000..d9b8ea4bd --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait DuplicateWebSocketPathParameterApi { + #[websocket( + "/widgets/{widget_id}/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [widget_id: u64, widget_id: String] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.stderr b/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.stderr new file mode 100644 index 000000000..09c4ce60a --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.stderr @@ -0,0 +1,5 @@ +error: duplicate path parameter `widget_id` + --> tests/ui/duplicate_websocket_path_parameter.rs:12:44 + | +12 | path_parameters = [widget_id: u64, widget_id: String] + | ^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/duplicate_websocket_route.rs b/crates/api-macros/tests/ui/duplicate_websocket_route.rs new file mode 100644 index 000000000..353b545f0 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_websocket_route.rs @@ -0,0 +1,21 @@ +use api_macros::api; + +pub struct Frame; +pub struct Response; + +#[api] +pub trait DuplicateTransportRouteApi { + #[get("/widgets/{widget_id}/events")] + async fn events(&self, #[path] widget_id: u64) -> Response; + + #[websocket( + "/widgets/{widget_id}/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [widget_id: u64] + )] + type WidgetEvents; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_websocket_route.stderr b/crates/api-macros/tests/ui/duplicate_websocket_route.stderr new file mode 100644 index 000000000..e10d9389e --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_websocket_route.stderr @@ -0,0 +1,5 @@ +error: duplicate route for GET `/widgets/{widget_id}/events` + --> tests/ui/duplicate_websocket_route.rs:18:10 + | +18 | type WidgetEvents; + | ^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_binary_method.rs b/crates/api-macros/tests/ui/invalid_binary_method.rs new file mode 100644 index 000000000..fecca3b7e --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_binary_method.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait InvalidBinaryMethod { + #[get("/payload")] + async fn download(&self, #[binary] body: api_macros::BinaryBody) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_binary_method.stderr b/crates/api-macros/tests/ui/invalid_binary_method.stderr new file mode 100644 index 000000000..d972e5b71 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_binary_method.stderr @@ -0,0 +1,5 @@ +error: GET operations cannot declare a request body + --> tests/ui/invalid_binary_method.rs:8:14 + | +8 | async fn download(&self, #[binary] body: api_macros::BinaryBody) -> Response; + | ^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_binary_type.rs b/crates/api-macros/tests/ui/invalid_binary_type.rs new file mode 100644 index 000000000..dd84af489 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_binary_type.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait InvalidBinaryType { + #[put("/payload")] + async fn upload(&self, #[binary] body: Vec) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_binary_type.stderr b/crates/api-macros/tests/ui/invalid_binary_type.stderr new file mode 100644 index 000000000..8f6fb514d --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_binary_type.stderr @@ -0,0 +1,5 @@ +error: binary request bodies must use api_macros::BinaryBody + --> tests/ui/invalid_binary_type.rs:8:44 + | +8 | async fn upload(&self, #[binary] body: Vec) -> Response; + | ^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_response_header.rs b/crates/api-macros/tests/ui/invalid_response_header.rs new file mode 100644 index 000000000..5901fc26c --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_response_header.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Body; + +#[api] +pub trait InvalidResponseHeaderApi { + #[get( + "/invalid", + responses = [(status = 200, body = Body, headers = [("bad header", String)])] + )] + async fn invalid(&self) -> invalid_response_header_api_responses::Invalid; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_response_header.stderr b/crates/api-macros/tests/ui/invalid_response_header.stderr new file mode 100644 index 000000000..90da20c9a --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_response_header.stderr @@ -0,0 +1,5 @@ +error: response header name must be a valid HTTP field name + --> tests/ui/invalid_response_header.rs:9:62 + | +9 | responses = [(status = 200, body = Body, headers = [("bad header", String)])] + | ^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_alternate_status.rs b/crates/api-macros/tests/ui/invalid_websocket_alternate_status.rs new file mode 100644 index 000000000..7eb7ff1c2 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_alternate_status.rs @@ -0,0 +1,18 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketAlternateStatusApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + alternate_status = 204 + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_alternate_status.stderr b/crates/api-macros/tests/ui/invalid_websocket_alternate_status.stderr new file mode 100644 index 000000000..7dfbd16f7 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_alternate_status.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare unary success responses or statuses + --> tests/ui/invalid_websocket_alternate_status.rs:13:9 + | +13 | alternate_status = 204 + | ^^^^^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_binary_body.rs b/crates/api-macros/tests/ui/invalid_websocket_binary_body.rs new file mode 100644 index 000000000..2de2e719a --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_binary_body.rs @@ -0,0 +1,19 @@ +use api_macros::api; + +pub struct Frame; +pub struct Body; + +#[api] +pub trait InvalidWebSocketBinaryBodyApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + binary = Body + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_binary_body.stderr b/crates/api-macros/tests/ui/invalid_websocket_binary_body.stderr new file mode 100644 index 000000000..4975f4300 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_binary_body.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare JSON or binary request bodies + --> tests/ui/invalid_websocket_binary_body.rs:14:9 + | +14 | binary = Body + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_body.rs b/crates/api-macros/tests/ui/invalid_websocket_body.rs new file mode 100644 index 000000000..72c38ceb5 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_body.rs @@ -0,0 +1,19 @@ +use api_macros::api; + +pub struct Frame; +pub struct Body; + +#[api] +pub trait InvalidWebSocketBodyApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + body = Body + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_body.stderr b/crates/api-macros/tests/ui/invalid_websocket_body.stderr new file mode 100644 index 000000000..6433a24f6 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_body.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare JSON or binary request bodies + --> tests/ui/invalid_websocket_body.rs:14:9 + | +14 | body = Body + | ^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_frame.rs b/crates/api-macros/tests/ui/invalid_websocket_frame.rs new file mode 100644 index 000000000..3950d95e2 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_frame.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketFrameApi { + #[websocket( + "/events", + method = GET, + client_to_server = (Frame, Frame), + server_to_client = Frame, + path_parameters = [] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_frame.stderr b/crates/api-macros/tests/ui/invalid_websocket_frame.stderr new file mode 100644 index 000000000..85ff68cb4 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_frame.stderr @@ -0,0 +1,5 @@ +error: WebSocket frame authorities must use direct named Rust types + --> tests/ui/invalid_websocket_frame.rs:10:28 + | +10 | client_to_server = (Frame, Frame), + | ^^^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_method.rs b/crates/api-macros/tests/ui/invalid_websocket_method.rs new file mode 100644 index 000000000..a5c9fa289 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_method.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketMethodApi { + #[websocket( + "/events", + method = POST, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_method.stderr b/crates/api-macros/tests/ui/invalid_websocket_method.stderr new file mode 100644 index 000000000..cefbd9834 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_method.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations require `method = GET` + --> tests/ui/invalid_websocket_method.rs:9:18 + | +9 | method = POST, + | ^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_response.rs b/crates/api-macros/tests/ui/invalid_websocket_response.rs new file mode 100644 index 000000000..75ed0d613 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_response.rs @@ -0,0 +1,18 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketResponseApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + status = 101 + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_response.stderr b/crates/api-macros/tests/ui/invalid_websocket_response.stderr new file mode 100644 index 000000000..1270a0a24 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_response.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare unary success responses or statuses + --> tests/ui/invalid_websocket_response.rs:13:9 + | +13 | status = 101 + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_response_body.rs b/crates/api-macros/tests/ui/invalid_websocket_response_body.rs new file mode 100644 index 000000000..30a5e8a96 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_response_body.rs @@ -0,0 +1,19 @@ +use api_macros::api; + +pub struct Frame; +pub struct Response; + +#[api] +pub trait InvalidWebSocketResponseBodyApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + response = Response + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_response_body.stderr b/crates/api-macros/tests/ui/invalid_websocket_response_body.stderr new file mode 100644 index 000000000..f8b499716 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_response_body.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare unary success responses or statuses + --> tests/ui/invalid_websocket_response_body.rs:14:9 + | +14 | response = Response + | ^^^^^^^^ diff --git a/crates/api-macros/tests/ui/missing_websocket_frame.rs b/crates/api-macros/tests/ui/missing_websocket_frame.rs new file mode 100644 index 000000000..9561e7e0e --- /dev/null +++ b/crates/api-macros/tests/ui/missing_websocket_frame.rs @@ -0,0 +1,16 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait MissingWebSocketFrameApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + path_parameters = [] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/missing_websocket_frame.stderr b/crates/api-macros/tests/ui/missing_websocket_frame.stderr new file mode 100644 index 000000000..25f27a29a --- /dev/null +++ b/crates/api-macros/tests/ui/missing_websocket_frame.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations require `server_to_client = FrameType` + --> tests/ui/missing_websocket_frame.rs:8:9 + | +8 | "/events", + | ^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/mixed_request_bodies.rs b/crates/api-macros/tests/ui/mixed_request_bodies.rs new file mode 100644 index 000000000..68eacab4f --- /dev/null +++ b/crates/api-macros/tests/ui/mixed_request_bodies.rs @@ -0,0 +1,16 @@ +use api_macros::api; + +pub struct JsonBody; +pub struct Response; + +#[api] +pub trait MixedRequestBodies { + #[post("/payload")] + async fn upload( + &self, + #[body] json: JsonBody, + #[binary] binary: api_macros::BinaryBody, + ) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/mixed_request_bodies.stderr b/crates/api-macros/tests/ui/mixed_request_bodies.stderr new file mode 100644 index 000000000..f87f947f6 --- /dev/null +++ b/crates/api-macros/tests/ui/mixed_request_bodies.stderr @@ -0,0 +1,5 @@ +error: an API operation may declare only one JSON or binary request body + --> tests/ui/mixed_request_bodies.rs:12:19 + | +12 | #[binary] binary: api_macros::BinaryBody, + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/multiple_binary_bodies.rs b/crates/api-macros/tests/ui/multiple_binary_bodies.rs new file mode 100644 index 000000000..027eb2680 --- /dev/null +++ b/crates/api-macros/tests/ui/multiple_binary_bodies.rs @@ -0,0 +1,15 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait MultipleBinaryBodies { + #[post("/payload")] + async fn upload( + &self, + #[binary] first: api_macros::BinaryBody, + #[binary] second: api_macros::BinaryBody, + ) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/multiple_binary_bodies.stderr b/crates/api-macros/tests/ui/multiple_binary_bodies.stderr new file mode 100644 index 000000000..50b23843c --- /dev/null +++ b/crates/api-macros/tests/ui/multiple_binary_bodies.stderr @@ -0,0 +1,5 @@ +error: an API operation may declare only one JSON or binary request body + --> tests/ui/multiple_binary_bodies.rs:11:19 + | +11 | #[binary] second: api_macros::BinaryBody, + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/multiple_bodies.stderr b/crates/api-macros/tests/ui/multiple_bodies.stderr index 1b259838b..76eec403f 100644 --- a/crates/api-macros/tests/ui/multiple_bodies.stderr +++ b/crates/api-macros/tests/ui/multiple_bodies.stderr @@ -1,4 +1,4 @@ -error: an API operation may declare only one request body +error: an API operation may declare only one JSON or binary request body --> tests/ui/multiple_bodies.rs:13:17 | 13 | #[body] second: SecondBody, diff --git a/crates/api-macros/tests/ui/not_modified_body.rs b/crates/api-macros/tests/ui/not_modified_body.rs new file mode 100644 index 000000000..4e3870d55 --- /dev/null +++ b/crates/api-macros/tests/ui/not_modified_body.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Body; + +#[api] +pub trait NotModifiedBodyApi { + #[get( + "/not-modified", + responses = [(status = 304, body = Body)] + )] + async fn not_modified(&self) -> not_modified_body_api_responses::NotModified; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/not_modified_body.stderr b/crates/api-macros/tests/ui/not_modified_body.stderr new file mode 100644 index 000000000..c5e89f291 --- /dev/null +++ b/crates/api-macros/tests/ui/not_modified_body.stderr @@ -0,0 +1,5 @@ +error: status 304 cannot have a response body + --> tests/ui/not_modified_body.rs:9:32 + | +9 | responses = [(status = 304, body = Body)] + | ^^^ diff --git a/crates/api-macros/tests/ui/undeclared_response_status.rs b/crates/api-macros/tests/ui/undeclared_response_status.rs new file mode 100644 index 000000000..6fcf60e34 --- /dev/null +++ b/crates/api-macros/tests/ui/undeclared_response_status.rs @@ -0,0 +1,18 @@ +use api_macros::api; + +pub struct Body; + +#[api] +pub trait UndeclaredStatusApi { + #[get( + "/status", + responses = [(status = 200, body = Body)] + )] + async fn status(&self) -> undeclared_status_api_responses::Status; +} + +fn invalid() -> undeclared_status_api_responses::Status { + undeclared_status_api_responses::Status::Status201 { body: Body } +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/undeclared_response_status.stderr b/crates/api-macros/tests/ui/undeclared_response_status.stderr new file mode 100644 index 000000000..b9a5e77e3 --- /dev/null +++ b/crates/api-macros/tests/ui/undeclared_response_status.stderr @@ -0,0 +1,14 @@ +error[E0599]: no variant named `Status201` found for enum `undeclared_status_api_responses::Status` + --> tests/ui/undeclared_response_status.rs:15:46 + | + 5 | #[api] + | ------ variant `Status201` not found here +... +15 | undeclared_status_api_responses::Status::Status201 { body: Body } + | ^^^^^^^^^ + | +help: there is a variant with a similar name + | +15 - undeclared_status_api_responses::Status::Status201 { body: Body } +15 + undeclared_status_api_responses::Status::Status200 { body: Body } + | diff --git a/crates/api-macros/tests/ui/websocket_path_mismatch.rs b/crates/api-macros/tests/ui/websocket_path_mismatch.rs new file mode 100644 index 000000000..23f67b75d --- /dev/null +++ b/crates/api-macros/tests/ui/websocket_path_mismatch.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketPathApi { + #[websocket( + "/widgets/{widget_id}/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [other_id: u64] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/websocket_path_mismatch.stderr b/crates/api-macros/tests/ui/websocket_path_mismatch.stderr new file mode 100644 index 000000000..8f6b75609 --- /dev/null +++ b/crates/api-macros/tests/ui/websocket_path_mismatch.stderr @@ -0,0 +1,5 @@ +error: path placeholder(s) have no matching path parameter: widget_id + --> tests/ui/websocket_path_mismatch.rs:8:9 + | +8 | "/widgets/{widget_id}/events", + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/websocket_unused_path_parameter.rs b/crates/api-macros/tests/ui/websocket_unused_path_parameter.rs new file mode 100644 index 000000000..ba91d139f --- /dev/null +++ b/crates/api-macros/tests/ui/websocket_unused_path_parameter.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait UnusedWebSocketPathParameterApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [unused: u64] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/websocket_unused_path_parameter.stderr b/crates/api-macros/tests/ui/websocket_unused_path_parameter.stderr new file mode 100644 index 000000000..49e24c040 --- /dev/null +++ b/crates/api-macros/tests/ui/websocket_unused_path_parameter.stderr @@ -0,0 +1,5 @@ +error: path parameter(s) have no matching placeholder: unused + --> tests/ui/websocket_unused_path_parameter.rs:8:9 + | +8 | "/events", + | ^^^^^^^^^ diff --git a/crates/client/src/backend_auth.rs b/crates/client/src/backend_auth.rs index 9d0f51ba0..9452ff6ba 100644 --- a/crates/client/src/backend_auth.rs +++ b/crates/client/src/backend_auth.rs @@ -1,9 +1,11 @@ use crate::BackendOrigin; -use serde::Deserialize; use std::fmt; use std::time::Duration; -use server_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest}; +use server_api::{ + DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest, RepositoryApiError, + ServerApiClient, +}; pub use server_api::{DeviceLoginPollResponse, DeviceLoginStartResponse}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -19,21 +21,12 @@ impl BackendAuthTarget { .unwrap_or(base_url); Self { base_url } } - - fn api_url(&self, path: &str) -> String { - format!( - "{}{}", - self.base_url.trim_end_matches('/'), - path.strip_prefix('/') - .map(|path| format!("/{path}")) - .unwrap_or_else(|| path.to_string()) - ) - } } #[derive(Debug)] pub enum BackendAuthClientError { - Http(reqwest::Error), + InvalidTarget(String), + ServerApi(server_api::client_support::ClientError), BackendStatus { status: u16, body: String }, MissingAccessToken, } @@ -41,7 +34,8 @@ pub enum BackendAuthClientError { impl fmt::Display for BackendAuthClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Http(error) => write!(f, "Backend auth request failed: {error}"), + Self::InvalidTarget(message) => f.write_str(message), + Self::ServerApi(error) => write!(f, "{error}"), Self::BackendStatus { status, body } => { write!(f, "Backend auth returned HTTP {status}: {body}") } @@ -54,40 +48,38 @@ impl fmt::Display for BackendAuthClientError { impl std::error::Error for BackendAuthClientError {} -impl From for BackendAuthClientError { - fn from(value: reqwest::Error) -> Self { - Self::Http(value) - } +const AUTH_RESPONSE_LIMIT: usize = 2 * 1024 * 1024; + +fn auth_client(target: &BackendAuthTarget) -> Result { + ServerApiClient::builder(&target.base_url) + .map_err(|error| BackendAuthClientError::InvalidTarget(error.to_string()))? + .response_body_limit(AUTH_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendAuthClientError::InvalidTarget(error.to_string())) } pub async fn start_device_login( target: &BackendAuthTarget, client_name: Option<&str>, ) -> Result { - let client = reqwest::Client::new(); - let response = client - .post(target.api_url("/api/auth/device-login/start")) - .json(&DeviceLoginStartRequest { + auth_client(target)? + .auth_device_login_start(DeviceLoginStartRequest { client_name: client_name.map(ToOwned::to_owned), }) - .send() - .await?; - parse_json_response(response).await + .await + .map_err(BackendAuthClientError::ServerApi) } pub async fn poll_device_login( target: &BackendAuthTarget, device_code: &str, ) -> Result { - let client = reqwest::Client::new(); - let response = client - .post(target.api_url("/api/auth/device-login/poll")) - .json(&DeviceLoginPollRequest { + auth_client(target)? + .auth_device_login_poll(DeviceLoginPollRequest { device_code: device_code.to_string(), }) - .send() - .await?; - parse_json_response(response).await + .await + .map_err(BackendAuthClientError::ServerApi) } fn device_login_poll_result( @@ -136,24 +128,13 @@ pub async fn wait_for_device_login( } } -async fn parse_json_response Deserialize<'de>>( - response: reqwest::Response, -) -> Result { - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(BackendAuthClientError::BackendStatus { - status: status.as_u16(), - body, - }); - } - Ok(response.json::().await?) -} - #[cfg(test)] mod tests { use super::*; use server_api::DeviceAccessTokenType; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse { DeviceLoginPollResponse { @@ -163,6 +144,34 @@ mod tests { } } + #[tokio::test] + async fn device_login_start_uses_generated_server_api_client() { + 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]); + assert!(request.starts_with("POST /api/auth/device-login/start HTTP/1.1")); + assert!(request.contains(r#"{"client_name":"yoi-cli"}"#)); + let body = r#"{"device_code":"device-secret","user_code":"ABCD-EFGH","verification_uri":"https://yoi.example/login/device","verification_uri_complete":"https://yoi.example/login/device?user_code=ABCD-EFGH","expires_in":600,"interval":5}"#; + 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 response = start_device_login(&BackendAuthTarget::new(base_url), Some("yoi-cli")) + .await + .unwrap(); + assert_eq!(response.user_code, "ABCD-EFGH"); + handle.join().unwrap(); + } + #[test] fn device_login_start_response_enforces_shared_expiry_bounds() { let valid = serde_json::json!({ diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 515b27c73..76cc5884a 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -1,7 +1,5 @@ use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError}; use crate::{BackendApiClient, BackendApiClientError, Client}; -use reqwest::Method as HttpMethod; -use serde::Deserialize; pub use server_api::{ BrowserCreateWorkerResponse as BackendCreateWorkerResponse, CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic, @@ -88,44 +86,30 @@ impl BackendRuntimeTarget { content: Vec, ) -> Result { let api = BackendApiClient::from_stored_token(&self.base_url)?; - let worker_path = format!( - "/api/w/{}/runtimes/{}/workers/{}", - path_segment_encode(&self.workspace_id), - path_segment_encode(&self.runtime_id), - path_segment_encode(&self.worker_id), - ); - let grant_path = format!( - "{worker_path}/attachment-upload-grants?file_name={}&media_type={}&upload_id={}", - path_segment_encode(file_name), - path_segment_encode(media_type), - path_segment_encode(&upload_id), - ); - let grant_response = api - .request(HttpMethod::POST, &grant_path)? - .send() - .await - .map_err(BackendRuntimeClientError::Http)?; - api.check_status(grant_response.status())?; - let grant = grant_response - .json::() - .await - .map_err(BackendRuntimeClientError::Http)?; - let upload_path = format!( - "{worker_path}/attachment-uploads/{}", - path_segment_encode(&grant.upload_id), - ); - let response = api - .request(HttpMethod::PUT, &upload_path)? - .body(content) - .send() - .await - .map_err(BackendRuntimeClientError::Http)?; - api.check_status(response.status())?; - response - .json::() + let client = runtime_server_api_client(&self.base_url, &api)?; + let grant = client + .runtime_worker_attachment_upload_grant( + self.workspace_id.clone(), + self.runtime_id.clone(), + self.worker_id.clone(), + server_api::WorkerFileUploadQuery { + file_name: file_name.to_string(), + media_type: media_type.to_string(), + upload_id: Some(upload_id.to_string()), + }, + ) + .await?; + client + .runtime_worker_attachment_upload( + self.workspace_id.clone(), + self.runtime_id.clone(), + self.worker_id.clone(), + grant.upload_id, + server_api::BinaryBody::from(content), + ) .await .map(|response| response.file) - .map_err(BackendRuntimeClientError::Http) + .map_err(BackendRuntimeClientError::from) } pub async fn cancel_file_upload( @@ -133,19 +117,14 @@ impl BackendRuntimeTarget { upload_id: &str, ) -> Result<(), BackendRuntimeClientError> { let api = BackendApiClient::from_stored_token(&self.base_url)?; - let path = format!( - "/api/w/{}/runtimes/{}/workers/{}/attachment-uploads/{}", - path_segment_encode(&self.workspace_id), - path_segment_encode(&self.runtime_id), - path_segment_encode(&self.worker_id), - path_segment_encode(upload_id), - ); - let response = api - .request(HttpMethod::DELETE, &path)? - .send() - .await - .map_err(BackendRuntimeClientError::Http)?; - api.check_status(response.status())?; + runtime_server_api_client(&self.base_url, &api)? + .runtime_worker_attachment_upload_cancel( + self.workspace_id.clone(), + self.runtime_id.clone(), + self.worker_id.clone(), + upload_id.to_string(), + ) + .await?; Ok(()) } @@ -154,35 +133,18 @@ impl BackendRuntimeTarget { artifact_id: &str, ) -> Result<(), BackendRuntimeClientError> { let api = BackendApiClient::from_stored_token(&self.base_url)?; - let path = format!( - "/api/w/{}/runtimes/{}/workers/{}/attachments/{}", - path_segment_encode(&self.workspace_id), - path_segment_encode(&self.runtime_id), - path_segment_encode(&self.worker_id), - path_segment_encode(artifact_id), - ); - let response = api - .request(HttpMethod::DELETE, &path)? - .send() - .await - .map_err(BackendRuntimeClientError::Http)?; - api.check_status(response.status())?; + runtime_server_api_client(&self.base_url, &api)? + .runtime_worker_attachment_delete( + self.workspace_id.clone(), + self.runtime_id.clone(), + self.worker_id.clone(), + artifact_id.to_string(), + ) + .await?; Ok(()) } } -#[derive(Deserialize)] -struct AttachmentUploadGrantResponse { - upload_id: String, - #[allow(dead_code)] - expires_at_ms: u64, -} - -#[derive(Deserialize)] -struct UploadedFileResponse { - file: protocol::UploadedFileRef, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendWorkerLaunchTarget { pub base_url: String, @@ -280,6 +242,7 @@ pub enum BackendRuntimeClientError { InvalidTarget(String), Api(BackendApiClientError), SessionApi(server_api::ServerApiClientError), + ContractApi(server_api::client_support::ClientError), Http(reqwest::Error), Protocol(String), } @@ -290,6 +253,7 @@ impl fmt::Display for BackendRuntimeClientError { Self::InvalidTarget(message) => f.write_str(message), Self::Api(error) => write!(f, "{error}"), Self::SessionApi(error) => write!(f, "{error}"), + Self::ContractApi(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"), Self::Protocol(message) => f.write_str(message), } @@ -304,6 +268,16 @@ impl From for BackendRuntimeClientError { } } +impl From> + for BackendRuntimeClientError +{ + fn from( + value: server_api::client_support::ClientError, + ) -> Self { + Self::ContractApi(value) + } +} + impl From for BackendRuntimeClientError { fn from(error: BackendApiClientError) -> Self { Self::Api(error) @@ -318,6 +292,34 @@ impl From for BackendRuntimeClientError { const WORKER_SESSION_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; +fn runtime_server_api_client( + base_url: &str, + backend: &BackendApiClient, +) -> Result, BackendRuntimeClientError> { + server_api::ServerApiClient::builder(base_url) + .map_err(|error| BackendRuntimeClientError::Protocol(error.to_string()))? + .client(backend.asynchronous_client()) + .authorizer(StoredBearerAuthorizer { + authorization: backend.authorization_header_value(), + }) + .response_body_limit(WORKER_SESSION_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendRuntimeClientError::Protocol(error.to_string())) +} + +fn server_api_error_is_auth( + error: &server_api::client_support::ClientError, +) -> bool { + matches!( + error, + server_api::client_support::ClientError::Public { status, .. } + if matches!( + *status, + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) + ) +} + #[derive(Clone, Debug)] struct StoredBearerAuthorizer { authorization: String, @@ -351,15 +353,7 @@ 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 + runtime_server_api_client(&target.base_url, backend)? .worker_session( target.workspace_id.clone(), target.runtime_id.clone(), @@ -381,15 +375,16 @@ async fn get_backend_worker_launch_options_with_client( target: &BackendWorkerLaunchTarget, api: &BackendApiClient, ) -> Result { - let path = backend_workspace_workers_launch_options_path( - target - .workspace_id - .as_deref() - .expect("validated Backend Workspace scope"), - ); - let response = api.request(HttpMethod::GET, &path)?.send().await?; - let response = api.require_success(response).await?; - Ok(response.json::().await?) + runtime_server_api_client(&target.base_url, api)? + .workspace_worker_launch_options( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope") + .to_string(), + ) + .await + .map_err(Into::into) } pub async fn create_backend_worker( @@ -406,19 +401,17 @@ async fn create_backend_worker_with_client( request: &BackendCreateWorkerRequest, api: &BackendApiClient, ) -> Result { - let path = backend_workspace_workers_path( - target - .workspace_id - .as_deref() - .expect("validated Backend Workspace scope"), - ); - let response = api - .request(HttpMethod::POST, &path)? - .json(request) - .send() - .await?; - let response = api.require_success(response).await?; - Ok(response.json::().await?) + runtime_server_api_client(&target.base_url, api)? + .workspace_worker_create( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope") + .to_string(), + request.clone(), + ) + .await + .map_err(Into::into) } pub async fn list_backend_workers( @@ -426,80 +419,50 @@ pub async fn list_backend_workers( ) -> Result, BackendRuntimeClientError> { validate_list_target(target)?; let api = BackendApiClient::from_stored_token(&target.base_url)?; + let client = runtime_server_api_client(&target.base_url, &api)?; + let workspace_id = target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"); if let Some(runtime_id) = target.runtime_id.as_deref() { - let path = backend_runtime_workers_path( - target - .workspace_id - .as_deref() - .expect("validated Backend Workspace scope"), - runtime_id, - ); - let response = api.request(HttpMethod::GET, &path)?.send().await?; - api.check_status(response.status())?; - return Ok(response - .json::>() - .await?); + return client + .runtime_worker_list( + workspace_id.to_string(), + runtime_id.to_string(), + server_api::RuntimeWorkersQuery::default(), + ) + .await + .map(runtime_worker_list_response) + .map_err(Into::into); } - let runtime_path = backend_runtimes_path( - target - .workspace_id - .as_deref() - .expect("validated Backend Workspace scope"), - ); - let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?; - api.check_status(response.status())?; - let runtimes = response - .json::>() - .await?; - + let runtimes = client.runtime_list(workspace_id.to_string()).await?; let mut items = Vec::new(); let mut diagnostics = runtimes.diagnostics; - for runtime in runtimes.items { - let path = backend_runtime_workers_path( - target - .workspace_id - .as_deref() - .expect("validated Backend Workspace scope"), - &runtime.runtime_id, - ); - let response = match api.request(HttpMethod::GET, &path)?.send().await { - Ok(response) => response, - Err(error) => { - diagnostics.push(BackendDiagnostic { - code: "runtime_worker_list_failed".to_string(), - severity: BackendDiagnosticSeverity::Error, - message: format!( - "failed to list workers for runtime {}: {error}", - runtime.runtime_id - ), - }); - continue; + for resource in runtimes.items { + let runtime = resource.runtime; + match client + .runtime_worker_list( + workspace_id.to_string(), + runtime.runtime_id.clone(), + server_api::RuntimeWorkersQuery::default(), + ) + .await + { + Ok(response) => { + diagnostics.extend(response.diagnostics); + items.extend(response.items); } - }; - if matches!( - response.status(), - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN - ) { - api.check_status(response.status())?; - } - if !response.status().is_success() { - diagnostics.push(BackendDiagnostic { + Err(error) if server_api_error_is_auth(&error) => return Err(error.into()), + Err(error) => diagnostics.push(BackendDiagnostic { code: "runtime_worker_list_failed".to_string(), severity: BackendDiagnosticSeverity::Error, message: format!( - "failed to list workers for runtime {}: Backend returned HTTP {}", - runtime.runtime_id, - response.status().as_u16() + "failed to list workers for runtime {}: {error}", + runtime.runtime_id ), - }); - continue; + }), } - let response = response - .json::>() - .await?; - diagnostics.extend(response.diagnostics); - items.extend(response.items); } Ok(BackendRuntimeListResponse { @@ -521,21 +484,21 @@ pub async fn list_backend_stopped_workers( )); }; let api = BackendApiClient::from_stored_token(&target.base_url)?; - let path = backend_runtime_workers_path( - target - .workspace_id - .as_deref() - .expect("validated Backend Workspace scope"), - runtime_id, - ); - let response = api - .request(HttpMethod::GET, &format!("{path}?status=stopped"))? - .send() - .await?; - api.check_status(response.status())?; - Ok(response - .json::>() - .await?) + runtime_server_api_client(&target.base_url, &api)? + .runtime_worker_list( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope") + .to_string(), + runtime_id.to_string(), + server_api::RuntimeWorkersQuery { + status: Some(server_api::RuntimeWorkersStatusFilter::Stopped), + }, + ) + .await + .map(runtime_worker_list_response) + .map_err(Into::into) } pub async fn restore_backend_worker( @@ -543,18 +506,18 @@ pub async fn restore_backend_worker( ) -> Result { validate_target(target)?; let api = BackendApiClient::from_stored_token(&target.base_url)?; - let path = backend_runtime_worker_restore_path( - &target.workspace_id, - &target.runtime_id, - &target.worker_id, - ); - let response = api - .request(HttpMethod::POST, &path)? - .json(&serde_json::json!({})) - .send() - .await?; - let response = api.require_success(response).await?; - Ok(response.json::().await?) + runtime_server_api_client(&target.base_url, &api)? + .runtime_worker_restore( + target.workspace_id.clone(), + target.runtime_id.clone(), + target.worker_id.clone(), + server_api::RestoreTicketAssignmentQuery { + ticket_id: None, + assignment_operation_id: None, + }, + ) + .await + .map_err(Into::into) } pub async fn connect_backend_runtime( @@ -599,6 +562,18 @@ fn protocol_connect_error_message( ) } +fn runtime_worker_list_response( + response: server_api::RuntimeWorkerListResponse, +) -> BackendRuntimeListResponse { + BackendRuntimeListResponse { + workspace_id: response.workspace_id, + limit: response.limit, + items: response.items, + source: response.source, + diagnostics: response.diagnostics, + } +} + fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> { if target.base_url.trim().is_empty() { return Err(BackendRuntimeClientError::InvalidTarget( @@ -686,42 +661,6 @@ fn validate_list_target( Ok(()) } -fn backend_workspace_workers_path(workspace_id: &str) -> String { - format!("/api/w/{}/workers", path_segment_encode(workspace_id)) -} - -fn backend_workspace_workers_launch_options_path(workspace_id: &str) -> String { - format!( - "{}/launch-options", - backend_workspace_workers_path(workspace_id) - ) -} - -fn backend_runtimes_path(workspace_id: &str) -> String { - format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)) -} - -fn backend_runtime_workers_path(workspace_id: &str, runtime_id: &str) -> String { - format!( - "/api/w/{}/runtimes/{}/workers", - path_segment_encode(workspace_id), - path_segment_encode(runtime_id) - ) -} - -fn backend_runtime_worker_restore_path( - workspace_id: &str, - runtime_id: &str, - worker_id: &str, -) -> String { - format!( - "/api/w/{}/runtimes/{}/workers/{}/restore", - path_segment_encode(workspace_id), - path_segment_encode(runtime_id), - path_segment_encode(worker_id) - ) -} - fn protocol_ws_request( target: &BackendRuntimeTarget, api: &BackendApiClient, @@ -1051,21 +990,4 @@ mod tests { serde_json::json!(64); assert!(serde_json::from_value::(stale).is_err()); } - - #[test] - fn workers_path_requires_workspace_scope_for_status_queries() { - let path = backend_runtime_workers_path("team main", "runtime/one"); - assert_eq!( - format!("{path}?status=stopped"), - "/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped" - ); - } - - #[test] - fn restore_worker_path_requires_workspace_scope() { - assert_eq!( - backend_runtime_worker_restore_path("team main", "runtime/one", "worker one"), - "/api/w/team%20main/runtimes/runtime%2Fone/workers/worker%20one/restore" - ); - } } diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index edd6cc75a..639e7cc75 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -1,8 +1,7 @@ use crate::{BackendApiClient, BackendApiClientError}; -use reqwest::Method; use server_api::{ - InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse, - WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary, + InitialRepositoryIntent, RepositorySummary, WorkspaceCreateRequest, WorkspaceCreateResponse, + WorkspaceListQuery, WorkspaceSummary, }; use std::fmt; @@ -31,7 +30,10 @@ pub enum BackendWorkspaceClientError { InvalidTarget(String), Api(BackendApiClientError), Http(reqwest::Error), - RepositoryApi(server_api::client_support::ClientError), + ServerApi(server_api::client_support::ClientError), + RuntimeManagementApi( + server_api::client_support::ClientError, + ), } impl fmt::Display for BackendWorkspaceClientError { @@ -40,7 +42,8 @@ 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}"), + Self::ServerApi(error) => write!(f, "{error}"), + Self::RuntimeManagementApi(error) => write!(f, "{error}"), } } } @@ -65,11 +68,11 @@ impl From, ) -> Self { - Self::RepositoryApi(error) + Self::ServerApi(error) } } -fn repository_client_error( +pub(crate) fn server_client_error( backend: &BackendApiClient, error: server_api::client_support::ClientError, ) -> BackendWorkspaceClientError { @@ -88,16 +91,39 @@ fn repository_client_error( origin: backend.origin().clone(), }) } - _ => BackendWorkspaceClientError::RepositoryApi(error), + _ => BackendWorkspaceClientError::ServerApi(error), + } +} + +pub(crate) fn runtime_management_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::RuntimeManagementApi(error), } } #[derive(Clone, Debug)] -struct RepositoryBearerAuthorizer { +pub(crate) struct ServerBearerAuthorizer { authorization: String, } -impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorizer { +impl server_api::client_support::RequestAuthorizer for ServerBearerAuthorizer { fn authorize( &self, _request: server_api::client_support::AuthorizerRequest<'_>, @@ -113,20 +139,70 @@ impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorize } } -const REPOSITORY_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; +const SERVER_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; + +pub(crate) fn server_api_client( + backend: &BackendApiClient, +) -> Result, BackendWorkspaceClientError> { + server_api::ServerApiClient::builder(backend.origin().as_str()) + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))? + .client(backend.asynchronous_client()) + .authorizer(ServerBearerAuthorizer { + authorization: backend.authorization_header_value(), + }) + .response_body_limit(SERVER_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string())) +} + +pub(crate) fn memory_document_blocking( + backend: &BackendApiClient, + workspace_id: &str, +) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + runtime.block_on(async { + server_api_client(backend)? + .memory_document(workspace_id.to_owned()) + .await + .map_err(|error| server_client_error(backend, error)) + }) +} + +pub(crate) fn memory_staging_list_blocking( + backend: &BackendApiClient, + workspace_id: &str, + limit: usize, +) -> Result { + let limit = u32::try_from(limit).map_err(|_| { + BackendWorkspaceClientError::InvalidTarget("Memory staging limit exceeds u32".to_string()) + })?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + runtime.block_on(async { + server_api_client(backend)? + .memory_staging_list( + workspace_id.to_owned(), + server_api::MemoryStagingQuery { limit: Some(limit) }, + ) + .await + .map_err(|error| server_client_error(backend, error)) + }) +} pub fn list_backend_workspaces_blocking( target: &BackendWorkspaceCatalogTarget, ) -> Result, BackendWorkspaceClientError> { let client = BackendApiClient::from_stored_token(&target.base_url)?; - let response = client - .blocking_request( - Method::GET, - &format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"), - )? - .send()?; - client.check_status(response.status())?; - Ok(response.json::()?.0) + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + runtime.block_on(list_backend_workspaces_with_client(&client)) } pub fn list_backend_workspace_repositories_blocking( @@ -158,19 +234,10 @@ async fn list_backend_workspace_repositories_with_client( "Workspace id returned by Backend is invalid".to_string(), )); } - 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 + let response = server_api_client(backend)? .repository_list(workspace_id.to_owned()) .await - .map_err(|error| repository_client_error(backend, error))?; + .map_err(|error| server_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(), @@ -189,15 +256,13 @@ pub async fn list_backend_workspaces( async fn list_backend_workspaces_with_client( client: &BackendApiClient, ) -> Result, BackendWorkspaceClientError> { - let response = client - .request( - Method::GET, - &format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"), - )? - .send() - .await?; - client.check_status(response.status())?; - Ok(response.json::().await?.0) + let response = server_api_client(client)? + .workspace_catalog_list(WorkspaceListQuery { + limit: Some(DEFAULT_WORKSPACE_LIMIT as u32), + }) + .await + .map_err(|error| server_client_error(client, error))?; + Ok(response.0) } pub async fn create_backend_workspace( @@ -205,13 +270,10 @@ pub async fn create_backend_workspace( request: &CreateBackendWorkspaceRequest, ) -> Result { let client = BackendApiClient::from_stored_token(&target.base_url)?; - let response = client - .request(Method::POST, "/api/workspaces")? - .json(request) - .send() - .await?; - client.check_status(response.status())?; - Ok(response.json::().await?) + server_api_client(&client)? + .workspace_catalog_create(request.clone()) + .await + .map_err(|error| server_client_error(&client, error)) } #[cfg(test)] @@ -292,12 +354,12 @@ mod tests { 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, + SERVER_RESPONSE_LIMIT + 1, ) .unwrap(); stream.flush().unwrap(); let chunk = [b' '; 8192]; - let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1; + let mut remaining = SERVER_RESPONSE_LIMIT + 1; while remaining > 0 { let length = remaining.min(chunk.len()); if stream.write_all(&chunk[..length]).is_err() { @@ -315,10 +377,10 @@ mod tests { assert!( matches!( &error, - BackendWorkspaceClientError::RepositoryApi( + BackendWorkspaceClientError::ServerApi( server_api::client_support::ClientError::Failure( server_api::client_support::ClientFailure::ResponseTooLarge { - limit: REPOSITORY_RESPONSE_LIMIT + limit: SERVER_RESPONSE_LIMIT } ) ) @@ -329,11 +391,11 @@ mod tests { } #[test] - fn repository_client_preserves_authentication_error_taxonomy() { + fn server_client_preserves_authentication_error_taxonomy() { let client = BackendApiClient::from_access_token_for_test("https://backend.example.test", "token") .unwrap(); - let unauthorized = repository_client_error( + let unauthorized = server_client_error( &client, server_api::client_support::ClientError::public( reqwest::StatusCode::UNAUTHORIZED, @@ -349,7 +411,7 @@ mod tests { unauthorized, BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. }) )); - let forbidden = repository_client_error( + let forbidden = server_client_error( &client, server_api::client_support::ClientError::public( reqwest::StatusCode::FORBIDDEN, diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index f87e62dab..32a360451 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -1,13 +1,9 @@ -use reqwest::Method; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; use server_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest, - RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, + RuntimeTrustKeyRevealResponse, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, }; use ticket::{ @@ -22,21 +18,6 @@ use crate::{BackendApiClient, BackendWorkspaceClientError}; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; -#[derive(Debug, Deserialize)] -struct BackendObjectiveListResponse { - workspace_id: String, - limit: usize, - items: Vec, - invalid_records: Vec, - record_authority: String, -} - -#[derive(Debug, Deserialize)] -struct BackendInvalidProjectRecord { - label: String, - reason: String, -} - /// Workspace-scoped Backend client for Ticket and Objective product state. /// /// Construction requires both the selected Backend URL and Workspace identity. @@ -85,26 +66,71 @@ impl BackendWorkspaceProductClient { &self.workspace_id } + fn generated(&self, operation: F) -> Result + where + F: FnOnce( + server_api::ServerApiClient, + ) -> Fut, + Fut: std::future::Future< + Output = Result< + T, + server_api::client_support::ClientError, + >, + >, + { + let client = crate::backend_workspace::server_api_client(&self.api)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + runtime + .block_on(operation(client)) + .map_err(|error| crate::backend_workspace::server_client_error(&self.api, error)) + } + pub fn list_tickets( &self, query: &TicketListQuery, ) -> Result, BackendWorkspaceClientError> { + let workspace_id = self.workspace_id.clone(); let state = ticket_list_state_query(query); - self.get_json(&format!("/tickets/search?state={state}")) + self.generated(move |client| async move { + client + .ticket_summary_search( + workspace_id, + server_api::TicketSummarySearchQuery { + state: Some(state), + limit: None, + }, + ) + .await + .map(|response| response.0) + }) } pub fn show_ticket(&self, id: &TicketIdOrSlug) -> Result { - self.get_json(&format!( - "/tickets/{}/record", - encode_path_segment(&ticket_reference(id)) - )) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(id); + self.generated(move |client| async move { + client + .ticket_record_get(workspace_id, id) + .await + .map(|response| response.0) + }) } pub fn create_ticket( &self, input: &NewTicket, ) -> Result { - self.send_json(Method::POST, "/tickets", Some(input)) + let workspace_id = self.workspace_id.clone(); + let input = input.clone(); + self.generated(move |client| async move { + client + .ticket_create_record(workspace_id, server_api::CreateTicketRecordRequest(input)) + .await + .map(|response| response.0) + }) } pub fn add_ticket_event( @@ -112,14 +138,18 @@ impl BackendWorkspaceProductClient { id: &TicketIdOrSlug, event: &NewTicketEvent, ) -> Result<(), BackendWorkspaceClientError> { - self.send_unit( - Method::POST, - &format!( - "/tickets/{}/thread-events", - encode_path_segment(&ticket_reference(id)) - ), - Some(event), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(id); + let event = event.clone(); + self.generated(move |client| async move { + client + .ticket_thread_event_add( + workspace_id, + id, + server_api::TicketThreadEventRequest(event), + ) + .await + }) } pub fn set_ticket_workflow_state( @@ -127,14 +157,18 @@ impl BackendWorkspaceProductClient { id: &TicketIdOrSlug, change: &TicketStateChange, ) -> Result<(), BackendWorkspaceClientError> { - self.send_unit( - Method::POST, - &format!( - "/tickets/{}/workflow-state", - encode_path_segment(&ticket_reference(id)) - ), - Some(change), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(id); + let change = change.clone(); + self.generated(move |client| async move { + client + .ticket_workflow_state_set( + workspace_id, + id, + server_api::TicketStateChangeRequest(change), + ) + .await + }) } pub fn close_ticket( @@ -142,14 +176,18 @@ impl BackendWorkspaceProductClient { id: &TicketIdOrSlug, resolution: &MarkdownText, ) -> Result<(), BackendWorkspaceClientError> { - self.send_unit( - Method::POST, - &format!( - "/tickets/{}/workflow/close", - encode_path_segment(&ticket_reference(id)) - ), - Some(resolution), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(id); + let resolution = resolution.clone(); + self.generated(move |client| async move { + client + .ticket_close_record( + workspace_id, + id, + server_api::TicketCloseRecordRequest(resolution), + ) + .await + }) } pub fn add_ticket_relation( @@ -157,14 +195,19 @@ impl BackendWorkspaceProductClient { id: &TicketIdOrSlug, relation: &NewTicketRelation, ) -> Result { - self.send_json( - Method::POST, - &format!( - "/tickets/{}/relations", - encode_path_segment(&ticket_reference(id)) - ), - Some(relation), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(id); + let relation = relation.clone(); + self.generated(move |client| async move { + client + .ticket_relation_record( + workspace_id, + id, + server_api::CreateTicketRelationRequest(relation), + ) + .await + .map(|response| response.0) + }) } pub fn query_ticket_relations( @@ -172,28 +215,42 @@ impl BackendWorkspaceProductClient { ticket: Option<&TicketIdOrSlug>, kind: Option, ) -> Result, BackendWorkspaceClientError> { - #[derive(Serialize)] - struct Query<'a> { - ticket: Option<&'a TicketIdOrSlug>, - kind: Option, - } - self.send_json( - Method::POST, - TICKET_RELATIONS_QUERY_PATH, - Some(&Query { ticket, kind }), - ) + let workspace_id = self.workspace_id.clone(); + let ticket = ticket.cloned(); + self.generated(move |client| async move { + client + .ticket_relation_query( + workspace_id, + server_api::TicketRelationSearchRequest { ticket, kind }, + ) + .await + .map(|response| response.0) + }) } pub fn ticket_doctor(&self) -> Result { - self.get_json("/tickets/doctor") + let workspace_id = self.workspace_id.clone(); + self.generated(move |client| async move { + client + .ticket_doctor(workspace_id) + .await + .map(|response| response.0) + }) } pub fn list_objectives( &self, limit: usize, ) -> Result, BackendWorkspaceClientError> { - let response: BackendObjectiveListResponse = - self.get_json(&format!("/objectives?limit={limit}"))?; + let workspace_id = self.workspace_id.clone(); + let response = self.generated(move |client| async move { + client + .objective_list( + workspace_id, + server_api::ObjectiveListQuery { limit: Some(limit) }, + ) + .await + })?; Ok(ListResponse { workspace_id: response.workspace_id, limit: response.limit, @@ -212,14 +269,20 @@ impl BackendWorkspaceProductClient { } pub fn show_objective(&self, id: &str) -> Result { - self.get_json(&format!("/objectives/{}", encode_path_segment(id))) + let workspace_id = self.workspace_id.clone(); + let id = id.to_string(); + self.generated(move |client| async move { client.objective_get(workspace_id, id).await }) } pub fn create_objective( &self, input: &ObjectiveCreateRequest, ) -> Result { - self.send_json(Method::POST, "/objectives", Some(input)) + let workspace_id = self.workspace_id.clone(); + let input = input.clone(); + self.generated( + move |client| async move { client.objective_create(workspace_id, input).await }, + ) } pub fn edit_objective( @@ -227,10 +290,11 @@ impl BackendWorkspaceProductClient { id: &str, input: &ObjectiveEditRequest, ) -> Result { - self.send_json( - Method::PATCH, - &format!("/objectives/{}", encode_path_segment(id)), - Some(input), + let workspace_id = self.workspace_id.clone(); + let id = id.to_string(); + let input = input.clone(); + self.generated( + move |client| async move { client.objective_edit(workspace_id, id, input).await }, ) } @@ -239,11 +303,12 @@ impl BackendWorkspaceProductClient { id: &str, input: &ObjectiveStateRequest, ) -> Result { - self.send_json( - Method::POST, - &format!("/objectives/{}/state", encode_path_segment(id)), - Some(input), - ) + let workspace_id = self.workspace_id.clone(); + let id = id.to_string(); + let input = input.clone(); + self.generated(move |client| async move { + client.objective_state_set(workspace_id, id, input).await + }) } pub fn link_objective_ticket( @@ -251,11 +316,12 @@ impl BackendWorkspaceProductClient { id: &str, input: &ObjectiveLinkTicketRequest, ) -> Result { - self.send_json( - Method::POST, - &format!("/objectives/{}/ticket-links", encode_path_segment(id)), - Some(input), - ) + let workspace_id = self.workspace_id.clone(); + let id = id.to_string(); + let input = input.clone(); + self.generated(move |client| async move { + client.objective_ticket_link(workspace_id, id, input).await + }) } pub fn unlink_objective_ticket( @@ -263,38 +329,53 @@ impl BackendWorkspaceProductClient { id: &str, ticket_id: &str, ) -> Result { - self.send_json::<(), _>( - Method::DELETE, - &format!( - "/objectives/{}/ticket-links/{}", - encode_path_segment(id), - encode_path_segment(ticket_id) - ), - None, - ) + let workspace_id = self.workspace_id.clone(); + let id = id.to_string(); + let ticket_id = ticket_id.to_string(); + self.generated(move |client| async move { + client + .objective_ticket_unlink(workspace_id, id, ticket_id) + .await + }) } pub fn list_runtimes( &self, ) -> Result, BackendWorkspaceClientError> { - self.get_json("/runtimes") + let workspace_id = self.workspace_id.clone(); + let response = + self.generated(move |client| async move { client.runtime_list(workspace_id).await })?; + Ok(ListResponse { + workspace_id: response.workspace_id, + limit: response.limit, + items: response.items, + source: response.source, + diagnostics: response.diagnostics, + }) } pub fn runtime_detail( &self, runtime_id: &str, ) -> Result { - self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id))) + let workspace_id = self.workspace_id.clone(); + let runtime_id = runtime_id.to_string(); + self.generated(move |client| async move { + client.runtime_detail(workspace_id, runtime_id).await + }) } pub fn reveal_runtime_trust_key( &self, runtime_id: &str, ) -> Result { - self.get_json(&format!( - "/runtimes/{}/trust-key", - encode_path_segment(runtime_id) - )) + let workspace_id = self.workspace_id.clone(); + let runtime_id = runtime_id.to_string(); + self.generated(move |client| async move { + client + .runtime_trust_key_reveal(workspace_id, runtime_id) + .await + }) } pub fn revoke_runtime_trust_key( @@ -302,29 +383,41 @@ impl BackendWorkspaceProductClient { runtime_id: &str, request: &RevokeRuntimeTrustKeyRequest, ) -> Result { - self.send_json( - Method::DELETE, - &format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)), - Some(request), - ) + let client = crate::backend_workspace::server_api_client(&self.api)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + runtime + .block_on(client.runtime_trust_key_revoke( + self.workspace_id.clone(), + runtime_id.to_string(), + request.clone(), + )) + .map_err(|error| { + crate::backend_workspace::runtime_management_client_error(&self.api, error) + }) } pub fn memory_document(&self) -> Result { - self.get_json("/memory") + crate::backend_workspace::memory_document_blocking(&self.api, &self.workspace_id) } pub fn list_memory_staging( &self, limit: usize, ) -> Result { - self.get_json(&format!("/memory/staging?limit={limit}")) + crate::backend_workspace::memory_staging_list_blocking(&self.api, &self.workspace_id, limit) } pub fn launch_ticket_intake( &self, ticket_id: &str, ) -> Result { - let options: WorkerLaunchOptionsResponse = self.get_json("/workers/launch-options")?; + let workspace_id = self.workspace_id.clone(); + let options: WorkerLaunchOptionsResponse = self.generated(move |client| async move { + client.workspace_worker_launch_options(workspace_id).await + })?; let runtime = options .runtimes .iter() @@ -346,8 +439,10 @@ impl BackendWorkspaceProductClient { workdir_attachments: Vec::new(), control_operation_id: None, }; - let response: BrowserCreateWorkerResponse = - self.send_json(Method::POST, "/workers", Some(&request))?; + let workspace_id = self.workspace_id.clone(); + let response: BrowserCreateWorkerResponse = self.generated(move |client| async move { + client.workspace_worker_create(workspace_id, request).await + })?; Ok(format!( "Started Intake Worker {}/{} for Ticket {ticket_id}", response.runtime_id, response.worker_id @@ -355,8 +450,11 @@ impl BackendWorkspaceProductClient { } pub fn start_workspace_orchestrator(&self) -> Result { + let workspace_id = self.workspace_id.clone(); let response: BrowserWorkspaceOrchestratorResponse = - self.send_json::<(), _>(Method::POST, "/orchestrator", None)?; + self.generated(move |client| async move { + client.workspace_orchestrator_start(workspace_id).await + })?; let worker = response.worker.ok_or_else(|| { BackendWorkspaceClientError::InvalidTarget( "Backend accepted the Orchestrator request without returning a Worker".to_string(), @@ -371,63 +469,21 @@ impl BackendWorkspaceProductClient { pub fn default_product_list_limit() -> usize { DEFAULT_PRODUCT_LIST_LIMIT } - - fn get_json(&self, path: &str) -> Result { - self.send_json::<(), R>(Method::GET, path, None) - } - - fn send_json( - &self, - method: Method, - path: &str, - body: Option<&B>, - ) -> Result { - let response = self.request(method, path, body)?.send()?; - self.api.check_status(response.status())?; - response.json().map_err(BackendWorkspaceClientError::Http) - } - - fn send_unit( - &self, - method: Method, - path: &str, - body: Option<&B>, - ) -> Result<(), BackendWorkspaceClientError> { - let response = self.request(method, path, body)?.send()?; - self.api.check_status(response.status())?; - Ok(()) - } - - fn request( - &self, - method: Method, - path: &str, - body: Option<&B>, - ) -> Result { - let path = format!( - "/api/w/{}/{}", - encode_path_segment(&self.workspace_id), - path.trim_start_matches('/') - ); - let request = self.api.blocking_request(method, &path)?; - Ok(match body { - Some(body) => request.json(body), - None => request, - }) - } } impl TicketBackend for BackendWorkspaceProductClient { fn default_intake_ready_state_change_body(&self, from: &str) -> String { - #[derive(Serialize)] - struct Request<'a> { - from: &'a str, - } - self.send_json( - Method::POST, - "/tickets/default-intake-ready-body", - Some(&Request { from }), - ) + let workspace_id = self.workspace_id.clone(); + let from = from.to_string(); + self.generated(move |client| async move { + client + .ticket_default_intake_ready_body( + workspace_id, + server_api::DefaultIntakeReadyBodyRequest { from }, + ) + .await + .map(|response| response.0) + }) .unwrap_or_else(|error| error.to_string()) } @@ -444,22 +500,30 @@ impl TicketBackend for BackendWorkspaceProductClient { } fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> ticket::Result { - self.send_json( - Method::PATCH, - &format!( - "/tickets/{}/item", - encode_path_segment(&ticket_reference(&id)) - ), - Some(&edit), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + self.generated(move |client| async move { + client + .ticket_record_item_edit( + workspace_id, + id, + server_api::EditTicketRecordItemRequest(edit), + ) + .await + .map(|response| response.0) + }) .map_err(ticket_client_error) } fn dependency_check(&self, id: TicketIdOrSlug) -> ticket::Result { - self.get_json(&format!( - "/tickets/{}/dependency-check", - encode_path_segment(&ticket_reference(&id)) - )) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + self.generated(move |client| async move { + client + .ticket_dependency_check(workspace_id, id) + .await + .map(|response| response.0) + }) .map_err(ticket_client_error) } @@ -473,14 +537,17 @@ impl TicketBackend for BackendWorkspaceProductClient { id: TicketIdOrSlug, change: TicketStateChange, ) -> ticket::Result<()> { - self.send_unit( - Method::POST, - &format!( - "/tickets/{}/state-changes", - encode_path_segment(&ticket_reference(&id)) - ), - Some(&change), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + self.generated(move |client| async move { + client + .ticket_state_change_add( + workspace_id, + id, + server_api::TicketStateChangeRequest(change), + ) + .await + }) .map_err(ticket_client_error) } @@ -489,14 +556,17 @@ impl TicketBackend for BackendWorkspaceProductClient { id: TicketIdOrSlug, summary: TicketIntakeSummary, ) -> ticket::Result<()> { - self.send_unit( - Method::POST, - &format!( - "/tickets/{}/intake-summaries", - encode_path_segment(&ticket_reference(&id)) - ), - Some(&summary), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + self.generated(move |client| async move { + client + .ticket_intake_summary_add( + workspace_id, + id, + server_api::TicketIntakeSummaryRequest(summary), + ) + .await + }) .map_err(ticket_client_error) } @@ -506,15 +576,19 @@ impl TicketBackend for BackendWorkspaceProductClient { field: &str, change: TicketStateChange, ) -> ticket::Result<()> { - self.send_unit( - Method::POST, - &format!( - "/tickets/{}/state-fields/{}", - encode_path_segment(&ticket_reference(&id)), - encode_path_segment(field) - ), - Some(&change), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + let field = field.to_string(); + self.generated(move |client| async move { + client + .ticket_state_field_set( + workspace_id, + id, + field, + server_api::TicketStateChangeRequest(change), + ) + .await + }) .map_err(ticket_client_error) } @@ -528,14 +602,19 @@ impl TicketBackend for BackendWorkspaceProductClient { } fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result { - self.send_json( - Method::POST, - &format!( - "/tickets/{}/workflow/mark-ready", - encode_path_segment(&ticket_reference(&id)) - ), - Some(&request), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + let request = server_api::TicketMarkReadyRequest { + operation_key: request.operation_key, + reason: request.reason, + intake_summary: request.intake_summary, + }; + self.generated(move |client| async move { + client + .ticket_mark_ready_record(workspace_id, id, request) + .await + .map(|response| response.0) + }) .map_err(ticket_client_error) } @@ -544,14 +623,14 @@ impl TicketBackend for BackendWorkspaceProductClient { id: TicketIdOrSlug, _queued_by: &str, ) -> ticket::Result { - self.send_json::<(), _>( - Method::POST, - &format!( - "/tickets/{}/workflow/queue", - encode_path_segment(&ticket_reference(&id)) - ), - None, - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + self.generated(move |client| async move { + client + .ticket_queue_record(workspace_id, id) + .await + .map(|response| response.0) + }) .map_err(ticket_client_error) } @@ -575,22 +654,19 @@ impl TicketBackend for BackendWorkspaceProductClient { kind: TicketRelationKind, target: TicketIdOrSlug, ) -> ticket::Result { - #[derive(Serialize)] - struct Request { - kind: TicketRelationKind, - target: String, - } - self.send_json( - Method::DELETE, - &format!( - "/tickets/{}/relations", - encode_path_segment(&ticket_reference(&id)) - ), - Some(&Request { - kind, - target: ticket_reference(&target), - }), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + let target = ticket_reference(&target); + self.generated(move |client| async move { + client + .ticket_relation_remove( + workspace_id, + id, + server_api::TicketRelationRemoveRequest { kind, target }, + ) + .await + .map(|response| response.0) + }) .map_err(ticket_client_error) } @@ -604,10 +680,14 @@ impl TicketBackend for BackendWorkspaceProductClient { } fn relation_view(&self, id: TicketIdOrSlug) -> ticket::Result { - self.get_json(&format!( - "/tickets/{}/relation-view", - encode_path_segment(&ticket_reference(&id)) - )) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + self.generated(move |client| async move { + client + .ticket_relation_view(workspace_id, id) + .await + .map(|response| response.0) + }) .map_err(ticket_client_error) } @@ -616,14 +696,18 @@ impl TicketBackend for BackendWorkspaceProductClient { id: TicketIdOrSlug, record: NewOrchestrationPlanRecord, ) -> ticket::Result { - self.send_json( - Method::POST, - &format!( - "/tickets/{}/orchestration-plans", - encode_path_segment(&ticket_reference(&id)) - ), - Some(&record), - ) + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + self.generated(move |client| async move { + client + .ticket_orchestration_plan_record( + workspace_id, + id, + server_api::CreateTicketOrchestrationPlanRequest(record), + ) + .await + .map(|response| response.0) + }) .map_err(ticket_client_error) } @@ -632,16 +716,16 @@ impl TicketBackend for BackendWorkspaceProductClient { ticket: Option, kind: Option, ) -> ticket::Result> { - #[derive(Serialize)] - struct Query { - ticket: Option, - kind: Option, - } - self.send_json( - Method::POST, - TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - Some(&Query { ticket, kind }), - ) + let workspace_id = self.workspace_id.clone(); + self.generated(move |client| async move { + client + .ticket_orchestration_plan_query( + workspace_id, + server_api::TicketOrchestrationPlanSearchRequest { ticket, kind }, + ) + .await + .map(|response| response.0) + }) .map_err(ticket_client_error) } @@ -649,7 +733,6 @@ impl TicketBackend for BackendWorkspaceProductClient { self.ticket_doctor().map_err(ticket_client_error) } } - fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError { TicketError::Sqlite(format!("Backend request failed: {error}")) } @@ -674,6 +757,7 @@ fn ticket_list_state_query(query: &TicketListQuery) -> String { } } +#[cfg(test)] fn encode_path_segment(value: &str) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value.bytes() { @@ -813,7 +897,16 @@ mod tests { let error = client.list_memory_staging(10).unwrap_err(); - assert!(matches!(error, BackendWorkspaceClientError::Http(_))); + assert!(matches!( + error, + BackendWorkspaceClientError::ServerApi( + server_api::client_support::ClientError::Failure( + server_api::client_support::ClientFailure::Decode { + kind: server_api::client_support::DecodeKind::Success, + }, + ), + ) + )); assert!( request .recv() diff --git a/crates/fs-operation/Cargo.toml b/crates/fs-operation/Cargo.toml index f61b8be76..6d360b536 100644 --- a/crates/fs-operation/Cargo.toml +++ b/crates/fs-operation/Cargo.toml @@ -11,6 +11,7 @@ grep-regex = "0.1.14" grep-searcher = "0.1.16" ignore = "0.4.25" libc = "0.2" +schemars.workspace = true serde = { workspace = true, features = ["derive"] } sha2 = "0.10.9" tempfile.workspace = true diff --git a/crates/fs-operation/src/operation.rs b/crates/fs-operation/src/operation.rs index 4d74184b9..f5765b6f6 100644 --- a/crates/fs-operation/src/operation.rs +++ b/crates/fs-operation/src/operation.rs @@ -1,6 +1,7 @@ use std::fmt; use std::path::{Component, Path, PathBuf}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::FsError; @@ -11,9 +12,11 @@ use crate::FsError; /// while computing line and content metadata. `max_response_bytes` limits the /// bytes retained for the response independently of the caller's requested /// `ReadRequest::max_bytes`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] pub struct BoundedReadLimits { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub max_source_bytes: u64, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub max_response_bytes: usize, } @@ -72,7 +75,7 @@ impl<'de> Deserialize<'de> for BoundedReadLimits { /// Scope-checked filesystem path. Relative paths resolve below the bound /// Workdir root; absolute paths require an explicit matching scope rule. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)] #[serde(transparent)] pub struct FsPath(String); @@ -153,21 +156,22 @@ impl fmt::Display for FsPath { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct StatRequest { pub path: FsPath, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct StatResult { pub path: FsPath, pub kind: EntryKind, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub size: u64, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum EntryKind { File, @@ -178,27 +182,32 @@ pub enum EntryKind { pub type ContentHash = [u8; 32]; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ReadRequest { pub path: FsPath, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub offset: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub max_bytes: usize, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ReadResult { pub path: FsPath, pub bytes: Vec, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub start_line: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub total_lines: usize, pub content_hash: ContentHash, pub truncated: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WriteRequest { pub path: FsPath, @@ -206,13 +215,14 @@ pub struct WriteRequest { pub expected_hash: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct WriteResult { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub bytes_written: usize, pub created: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct EditRequest { pub path: FsPath, @@ -222,54 +232,61 @@ pub struct EditRequest { pub expected_hash: ContentHash, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct EditResult { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub replacements: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub bytes_written: usize, pub content_hash: ContentHash, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ListRequest { pub path: FsPath, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ListEntry { pub path: FsPath, pub kind: EntryKind, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub size: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ListResult { pub entries: Vec, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub total_entries: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub total_bytes: u64, pub truncated: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct GlobRequest { pub pattern: String, pub path: FsPath, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct GlobResult { pub paths: Vec, pub truncated: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum GrepOutputMode { Content, @@ -283,7 +300,7 @@ impl Default for GrepOutputMode { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct GrepRequest { pub pattern: String, @@ -291,21 +308,27 @@ pub struct GrepRequest { pub glob: Option, pub file_type: Option, pub case_insensitive: bool, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub before_context: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub after_context: usize, pub multiline: bool, pub output_mode: GrepOutputMode, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub offset: usize, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct GrepResult { /// Provider-rendered bounded grep report. Keeping rendering here avoids /// transferring candidate files across a remote provider boundary. pub output: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub match_count: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub matched_files: usize, pub truncated: bool, } diff --git a/crates/memory/src/audit.rs b/crates/memory/src/audit.rs index bd3b1887e..dbbce0b3e 100644 --- a/crates/memory/src/audit.rs +++ b/crates/memory/src/audit.rs @@ -4,6 +4,7 @@ //! backend operation boundary. Repository-local audit logs are not an authority. use chrono::{DateTime, Utc}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -11,7 +12,7 @@ fn is_zero_usize(value: &usize) -> bool { *value == 0 } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AuditWorker { MemoryExtract, @@ -27,7 +28,7 @@ impl AuditWorker { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum WorkerLifecycleStatus { Started, @@ -49,7 +50,7 @@ impl WorkerLifecycleStatus { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AuditTrigger { SessionEnd, @@ -77,7 +78,7 @@ impl AuditTrigger { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AuditStatus { Success, @@ -85,7 +86,7 @@ pub enum AuditStatus { Skipped, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct ModelAudit { #[serde(default, skip_serializing_if = "Option::is_none")] pub ref_: Option, @@ -95,31 +96,39 @@ pub struct ModelAudit { pub model_id: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct UsageAudit { #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub input_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub output_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub total_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub cache_read_input_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub cache_creation_input_tokens: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct ExtractAudit { #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub segment_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] pub entry_range: Option<[u64; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] pub history_range: Option<[u64; 2]>, #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub staging_count: usize, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub staging_ids: Vec, @@ -127,13 +136,16 @@ pub struct ExtractAudit { pub staging_paths: Vec, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct ConsolidationAudit { #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub staging_count: usize, #[serde(default, skip_serializing_if = "is_zero_usize")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub invalid_staging_count: usize, #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub staging_bytes: u64, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub consumed_staging_ids: Vec, @@ -141,19 +153,25 @@ pub struct ConsolidationAudit { pub operations: OperationCounts, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct OperationCounts { #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub write: usize, #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub edit: usize, #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub delete: usize, #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub drop: usize, #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub merge: usize, #[serde(default)] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub trim: usize, } @@ -163,15 +181,17 @@ impl OperationCounts { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct MemorySettingsAudit { pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub settings_revision: u64, pub language: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct WorkerLifecycleAudit { + #[schemars(with = "String")] pub run_id: Uuid, pub worker: AuditWorker, pub status: WorkerLifecycleStatus, @@ -189,7 +209,7 @@ pub struct WorkerLifecycleAudit { pub consolidation: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct RecordOperationAudit { pub op: String, pub status: AuditStatus, @@ -204,7 +224,7 @@ pub struct RecordOperationAudit { pub reason: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct RecordUsageAudit { pub op: String, pub status: AuditStatus, @@ -216,12 +236,13 @@ pub struct RecordUsageAudit { #[serde(default, skip_serializing_if = "Option::is_none")] pub query: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub result_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "event", rename_all = "snake_case")] pub enum AuditPayload { WorkerLifecycle(WorkerLifecycleAudit), @@ -229,9 +250,11 @@ pub enum AuditPayload { RecordUsage(RecordUsageAudit), } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct AuditEvent { + #[schemars(with = "String")] pub id: Uuid, + #[schemars(with = "String")] pub occurred_at: DateTime, #[serde(flatten)] pub payload: AuditPayload, diff --git a/crates/memory/src/backend.rs b/crates/memory/src/backend.rs index 32d987e57..8b7b8a546 100644 --- a/crates/memory/src/backend.rs +++ b/crates/memory/src/backend.rs @@ -11,7 +11,7 @@ use crate::audit::AuditEvent; use crate::extract::{ExtractedCandidate, ExtractedPayload, StagingEvidence}; use crate::schema::{SourceEvidenceRef, SourceRef}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", rename_all = "snake_case")] pub enum MemoryBackendOperation { Query(MemoryQueryOperation), @@ -26,7 +26,7 @@ pub enum MemoryBackendOperation { StagingClose(MemoryStagingCloseOperation), } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "status", rename_all = "snake_case")] pub enum MemoryBackendHttpResponse { Ok { @@ -37,7 +37,7 @@ pub enum MemoryBackendHttpResponse { }, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryBackendOperationResult { ToolOutput(MemoryToolOutput), @@ -45,14 +45,14 @@ pub enum MemoryBackendOperationResult { StagingWritten(MemoryStagingWriteOutput), } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryToolOutput { pub summary: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub content: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryQueryOperation { #[serde(default, skip_serializing_if = "Option::is_none")] pub query: Option, @@ -61,8 +61,10 @@ pub struct MemoryQueryOperation { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryDocumentReadOperation { #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub offset: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: Option, } @@ -74,15 +76,15 @@ pub struct MemoryDocumentUpdateOperation { pub replace_all: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)] pub struct MemoryResidentSummaryOperation {} -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryAppendAuditOperation { pub event: AuditEvent, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryStageCandidateOperation { pub source: SourceRef, pub extract_run_id: String, @@ -93,7 +95,7 @@ pub struct MemoryStageCandidateOperation { pub source_refs: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryStageExtractedOperation { pub source: SourceRef, pub payload: ExtractedPayload, @@ -102,6 +104,7 @@ pub struct MemoryStageExtractedOperation { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryStagingListOperation { #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: Option, } @@ -140,28 +143,31 @@ pub enum MemoryStagingAffectedMemoryOperation { Edit, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct MemoryConsolidateStagingOperation { #[serde(default)] pub force: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryConsolidationOutput { pub status: String, pub summary: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub candidate_count: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub total_bytes: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryBackendAckOutput { pub summary: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MemoryStagingWriteOutput { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub staging_count: usize, pub staging_ids: Vec, } diff --git a/crates/memory/src/extract/payload.rs b/crates/memory/src/extract/payload.rs index 745712855..310ecce13 100644 --- a/crates/memory/src/extract/payload.rs +++ b/crates/memory/src/extract/payload.rs @@ -73,12 +73,13 @@ impl ExtractedPayload { } /// Bounded evidence snippet copied into a flat staging record. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct StagingEvidence { pub id: String, pub kind: EvidenceKind, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] pub entry_range: Option<[u64; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] pub origin: Option, diff --git a/crates/memory/src/schema/common.rs b/crates/memory/src/schema/common.rs index 5c0ec3cc8..0bc05360d 100644 --- a/crates/memory/src/schema/common.rs +++ b/crates/memory/src/schema/common.rs @@ -4,14 +4,23 @@ use lint_common::RecordLintError; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// Schema-only representation for JSON integers that must remain exactly +/// representable by JavaScript consumers. +#[allow(dead_code)] +#[derive(JsonSchema)] +pub struct MemoryJsonSafeU64(#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] u64); + +pub type JsonSafeU64Pair = [MemoryJsonSafeU64; 2]; + pub use lint_common::Frontmatter; /// Reference to a session-store entry range. Stored in `sources` / /// `last_sources` arrays for traceability back to raw session logs. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] pub struct SourceRef { pub segment_id: String, /// `[start_entry, end_entry]` inclusive range of session-store entry indices. + #[schemars(with = "crate::schema::JsonSafeU64Pair")] pub range: [u64; 2], } @@ -99,6 +108,7 @@ pub struct EvidenceOrigin { #[serde(default, skip_serializing_if = "Option::is_none")] pub flow_definition_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub flow_definition_revision: Option, } @@ -118,6 +128,7 @@ pub struct SourceEvidenceRef { pub segment_id: Option, /// `[start_entry, end_entry]` inclusive range of session-store entry indices. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] pub entry_range: Option<[u64; 2]>, /// Host-assigned evidence id within the referenced evidence set. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/memory/src/schema/mod.rs b/crates/memory/src/schema/mod.rs index 34b75ca8d..695b53c32 100644 --- a/crates/memory/src/schema/mod.rs +++ b/crates/memory/src/schema/mod.rs @@ -8,6 +8,7 @@ mod decision; mod request; mod summary; +pub(crate) use common::JsonSafeU64Pair; pub use common::{ EvidenceKind, EvidenceOrigin, EvidenceOriginKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter, diff --git a/crates/merge-request/Cargo.toml b/crates/merge-request/Cargo.toml index 7c0b5664e..61fa80fe0 100644 --- a/crates/merge-request/Cargo.toml +++ b/crates/merge-request/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } rusqlite.workspace = true +schemars = { workspace = true, features = ["chrono04"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index c2777e4a2..154f3a773 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -18,7 +18,7 @@ const DOMAIN_TABLES: [&str; 5] = [ "merge_request_reviewer_child_sessions", ]; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum MergeRequestState { Open, @@ -43,13 +43,13 @@ impl MergeRequestState { } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { Approve, RequestChanges, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum FindingSeverity { Blocker, @@ -57,7 +57,7 @@ pub enum FindingSeverity { Minor, Note, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ReviewFinding { pub severity: FindingSeverity, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -68,7 +68,7 @@ pub struct ReviewFinding { pub line: Option, pub body: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct WorkerIdentity { pub runtime_id: String, pub worker_id: String, @@ -91,18 +91,20 @@ impl MergeRequestAuth { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ReviewRequestedEvent { pub event_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub sequence: u64, pub subject_ref: String, pub requested_by: WorkerIdentity, pub reviewer: WorkerIdentity, pub created_at: DateTime, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ReviewEvent { pub event_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub sequence: u64, pub request_event_id: String, pub subject_ref: String, @@ -112,9 +114,10 @@ pub struct ReviewEvent { pub reviewer: WorkerIdentity, pub created_at: DateTime, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ReviewRevokedEvent { pub event_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub sequence: u64, pub review_event_id: String, pub subject_ref: String, @@ -122,39 +125,42 @@ pub struct ReviewRevokedEvent { pub revoked_by: WorkerIdentity, pub created_at: DateTime, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ReviewCancelledEvent { pub event_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub sequence: u64, pub request_event_id: String, pub subject_ref: String, pub reason: String, pub created_at: DateTime, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct CommentEvent { pub event_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub sequence: u64, pub body: String, pub author: WorkerIdentity, pub created_at: DateTime, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum MergeStrategy { FastForward, Merge, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ConflictResolution { None, Clean, ConflictsResolved, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct MergeEvent { pub event_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub sequence: u64, pub operation_id: String, pub approval_event_id: String, @@ -166,7 +172,7 @@ pub struct MergeEvent { pub merged_by: WorkerIdentity, pub created_at: DateTime, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MergeRequestThreadEvent { ReviewRequested(ReviewRequestedEvent), @@ -203,7 +209,7 @@ impl MergeRequestThreadEvent { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct MergeRequest { pub workspace_id: String, pub merge_request_id: String, @@ -313,7 +319,7 @@ pub struct ReadinessCheck { pub current_subject_ref: Option, pub auth: MergeRequestAuth, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ReadinessReport { pub ready: bool, pub blockers: Vec, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index e616231c4..dc89f04f5 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -126,6 +126,7 @@ pub struct WorkerCommandAcknowledgement { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", content = "state", rename_all = "snake_case")] pub enum WorkerState { Idle, @@ -134,6 +135,7 @@ pub enum WorkerState { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", content = "state", rename_all = "snake_case")] pub enum WorkerBusyState { Run(WorkerRunState), @@ -142,6 +144,7 @@ pub enum WorkerBusyState { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WorkerRunState { Running, @@ -152,6 +155,7 @@ pub enum WorkerRunState { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum WorkerMaintenanceState { Compacting, @@ -159,8 +163,13 @@ pub enum WorkerMaintenanceState { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct WorkerStateSnapshot { /// Highest lifecycle command id observed by this controller instance. + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 9_007_199_254_740_991_u64)) + )] pub last_command_id: u64, pub state: WorkerState, } @@ -455,13 +464,29 @@ impl PasteArtifactAvailability { #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] pub struct PasteArtifactRef { pub artifact_id: String, + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 9_007_199_254_740_991_u64)) + )] pub created_at_ms: u64, pub media_type: PasteArtifactMediaType, /// Availability observed when this immutable reference was committed. /// Reads revalidate storage and integrity rather than trusting this field. pub availability: PasteArtifactAvailability, + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 9_007_199_254_740_991_u64)) + )] pub byte_len: u64, + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 9_007_199_254_740_991_u64)) + )] pub char_count: u64, + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 9_007_199_254_740_991_u64)) + )] pub line_count: u64, pub sha256: String, pub source_entry_id: String, @@ -501,8 +526,16 @@ pub struct UploadedFileRef { pub artifact_id: String, pub file_name: String, pub media_type: String, + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 9_007_199_254_740_991_u64)) + )] pub created_at_ms: u64, pub availability: UploadedFileAvailability, + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 9_007_199_254_740_991_u64)) + )] pub byte_len: u64, pub sha256: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -521,8 +554,20 @@ pub enum Segment { /// `[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage` /// re-broadcast. Paste { + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 4_294_967_295_u32)) + )] id: u32, + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 4_294_967_295_u32)) + )] chars: u32, + #[cfg_attr( + feature = "json-schema", + schemars(range(min = 0, max = 4_294_967_295_u32)) + )] lines: u32, content: String, }, diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 9520f37ec..f8364c2ce 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -357,7 +357,9 @@ pub struct LogicalWorkdirAttachment { } fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities { - workdir::WorkdirSessionCapabilities::ALL + // Capability-less wire and persisted records must not silently regain write + // or command authority when read by a newer Runtime. + workdir::WorkdirSessionCapabilities::READ_ONLY } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -367,6 +369,9 @@ pub struct WorkingDirectoryAttachmentClaim { pub working_directory_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub relative_cwd: Option, + /// Backend-authoritative maximum capabilities for the attachment session. + #[serde(default = "default_workdir_session_capabilities")] + pub capabilities: workdir::WorkdirSessionCapabilities, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -956,6 +961,50 @@ mod tests { RuntimeApiError::new(status, "test_error", "test error") } + #[test] + fn capability_less_attachment_claims_default_to_read_only() { + let claim: WorkingDirectoryAttachmentClaim = serde_json::from_value(serde_json::json!({ + "alias": "docs", + "working_directory_id": "workdir-docs" + })) + .unwrap(); + assert_eq!( + claim.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + + let logical: LogicalWorkdirAttachment = serde_json::from_value(serde_json::json!({ + "alias": "docs", + "working_directory_id": "workdir-docs" + })) + .unwrap(); + assert_eq!( + logical.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + } + + #[test] + fn attachment_claim_serializes_backend_authored_capabilities() { + let claim = WorkingDirectoryAttachmentClaim { + alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(), + working_directory_id: "workdir-main".to_string(), + relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, + }; + let value = serde_json::to_value(&claim).unwrap(); + assert_eq!( + value.get("capabilities"), + Some(&serde_json::to_value(workdir::WorkdirSessionCapabilities::ALL).unwrap()) + ); + assert_eq!( + serde_json::from_value::(value) + .unwrap() + .capabilities, + workdir::WorkdirSessionCapabilities::ALL + ); + } + impl RuntimeApi for RoundTripService { async fn ping( &self, diff --git a/crates/server-api/Cargo.toml b/crates/server-api/Cargo.toml index d371d6db5..718005b08 100644 --- a/crates/server-api/Cargo.toml +++ b/crates/server-api/Cargo.toml @@ -11,12 +11,15 @@ typescript = ["dep:ts-rs", "protocol/typescript"] [dependencies] api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] } -protocol.workspace = true +memory.workspace = true +merge-request.workspace = true +protocol = { workspace = true, features = ["json-schema"] } runtime-api.workspace = true schemars.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true sha2.workspace = true +ticket.workspace = true ts-rs = { version = "12.0.1", optional = true } webauthn-rs-proto = "0.5.5" workdir.workspace = true @@ -30,15 +33,12 @@ required-features = ["typescript"] [[example]] name = "generate_workdir_api_types" -required-features = ["typescript"] [[example]] name = "generate_worker_launch_api_types" -required-features = ["typescript"] [[example]] name = "generate_companion_api_types" -required-features = ["typescript"] [[example]] name = "generate_memory_api_types" diff --git a/crates/server-api/examples/generate_companion_api_types.rs b/crates/server-api/examples/generate_companion_api_types.rs index fd402438a..bae574c38 100644 --- a/crates/server-api/examples/generate_companion_api_types.rs +++ b/crates/server-api/examples/generate_companion_api_types.rs @@ -1,6 +1,55 @@ +use std::fs; +use std::path::Path; + +use server_api::repository_openapi_typescript::{ + COMPANION_OUTPUT_PATH, INPUT_PATH, generate_companion_typescript, +}; + fn main() { - print!( - "{}", - server_api::normalized_typescript(server_api::companion_api_typescript()) - ); + 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 Companion API TypeScript artifact.\n\nUsage: cargo run -q -p server-api --example generate_companion_api_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(COMPANION_OUTPUT_PATH); + let input = fs::read_to_string(&input_path) + .map_err(|error| format!("failed to read {}: {error}", input_path.display()))?; + let generated = generate_companion_typescript(&input) + .map_err(|error| format!("failed to generate Companion 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_runtime_api_types.rs b/crates/server-api/examples/generate_runtime_api_types.rs new file mode 100644 index 000000000..69305365a --- /dev/null +++ b/crates/server-api/examples/generate_runtime_api_types.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::Path; + +use server_api::repository_openapi_typescript::{ + INPUT_PATH, RUNTIME_OUTPUT_PATH, generate_runtime_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 Runtime API TypeScript artifact.\n\n\ + Usage: cargo run -q -p server-api --example generate_runtime_api_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(RUNTIME_OUTPUT_PATH); + let input = fs::read_to_string(&input_path) + .map_err(|error| format!("failed to read {}: {error}", input_path.display()))?; + let generated = generate_runtime_typescript(&input) + .map_err(|error| format!("failed to generate Runtime 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_ticket_api_types.rs b/crates/server-api/examples/generate_ticket_api_types.rs new file mode 100644 index 000000000..a60f9120e --- /dev/null +++ b/crates/server-api/examples/generate_ticket_api_types.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::Path; + +use server_api::repository_openapi_typescript::{ + INPUT_PATH, TICKET_BROWSER_OUTPUT_PATH, generate_ticket_browser_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 Ticket Browser API TypeScript artifact.\n\n\ + Usage: cargo run -q -p server-api --example generate_ticket_api_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(TICKET_BROWSER_OUTPUT_PATH); + let input = fs::read_to_string(&input_path) + .map_err(|error| format!("failed to read {}: {error}", input_path.display()))?; + let generated = generate_ticket_browser_typescript(&input) + .map_err(|error| format!("failed to generate Ticket Browser 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_workdir_api_types.rs b/crates/server-api/examples/generate_workdir_api_types.rs index 13ed5f08b..ff8dff334 100644 --- a/crates/server-api/examples/generate_workdir_api_types.rs +++ b/crates/server-api/examples/generate_workdir_api_types.rs @@ -1,6 +1,59 @@ +use std::fs; +use std::path::Path; + +use server_api::repository_openapi_typescript::{ + INPUT_PATH, WORKDIR_OUTPUT_PATH, generate_workdir_typescript, +}; + fn main() { - print!( - "{}", - server_api::normalized_typescript(server_api::workdir_api_typescript()) - ); + 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 Workdir API TypeScript artifact.\n\n\ + Usage: cargo run -q -p server-api --example generate_workdir_api_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(WORKDIR_OUTPUT_PATH); + let input = fs::read_to_string(&input_path) + .map_err(|error| format!("failed to read {}: {error}", input_path.display()))?; + let generated = generate_workdir_typescript(&input) + .map_err(|error| format!("failed to generate Workdir 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_worker_launch_api_types.rs b/crates/server-api/examples/generate_worker_launch_api_types.rs index 5e326b368..33ea2a545 100644 --- a/crates/server-api/examples/generate_worker_launch_api_types.rs +++ b/crates/server-api/examples/generate_worker_launch_api_types.rs @@ -1,6 +1,60 @@ +use std::fs; +use std::path::Path; + +use server_api::repository_openapi_typescript::{ + INPUT_PATH, WORKER_OUTPUT_PATH, generate_worker_typescript, +}; + fn main() { - print!( - "{}", - server_api::normalized_typescript(server_api::worker_launch_api_typescript()) - ); + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let check = parse_check_argument()?; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let input_path = root.join(INPUT_PATH); + let output_path = root.join(WORKER_OUTPUT_PATH); + let input = fs::read_to_string(&input_path) + .map_err(|error| format!("failed to read {}: {error}", input_path.display()))?; + let generated = generate_worker_typescript(&input) + .map_err(|error| format!("failed to generate Worker 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(()) +} + +fn parse_check_argument() -> Result { + 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 Worker API TypeScript artifact.\n\nUsage: cargo run -q -p server-api --example generate_worker_launch_api_types -- [--check]" + ); + return Ok(true); + } + other => return Err(format!("unknown argument `{other}`")), + } + } + Ok(check) } diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 26e94a7f3..883707b29 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4,10 +4,12 @@ //! by Rust clients. Runtime-internal projections remain in their owning crates; //! callers must explicitly construct these Server-authoritative resources. +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}; +pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub mod repository_openapi_typescript; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -17,11 +19,17 @@ use webauthn_rs_proto::{ RequestChallengeResponse, }; +#[allow(dead_code)] +#[derive(JsonSchema)] +struct ServerJsonSafeU64(#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] u64); +type JsonSafeU64Pair = [ServerJsonSafeU64; 2]; + 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)] +#[serde(deny_unknown_fields)] pub struct RepositoryApiError { pub error: String, pub message: String, @@ -69,6 +77,64 @@ impl api_macros::HttpRequestError for RepositoryApiError { } } +/// Error body for current-Worker Workdir operations. +/// +/// Domain/service failures preserve the existing Repository-style API envelope, +/// while provider failures preserve the established typed Workdir transport +/// envelope and HTTP status classification. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorkdirOperationApiError { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diagnostics: Option>, + #[serde(skip, default = "default_repository_error_status")] + status: u16, +} + +impl WorkdirOperationApiError { + pub fn api(error: RepositoryApiError) -> Self { + Self { + error: Some(error.error), + code: None, + message: error.message, + diagnostics: Some(error.diagnostics), + status: error.status, + } + } + + pub fn provider(error: workdir::http::WorkdirTransportError) -> Self { + let status = error.code.http_status(); + Self { + error: None, + code: Some(error.code), + message: error.message, + diagnostics: None, + status, + } + } +} + +impl api_macros::HttpError for WorkdirOperationApiError { + fn status_code(&self) -> u16 { + self.code.map_or(self.status, |code| code.http_status()) + } +} + +impl api_macros::HttpRequestError for WorkdirOperationApiError { + fn from_request_rejection(status: u16, message: String) -> Self { + Self::api( + ::from_request_rejection( + status, message, + ), + ) + } +} + macro_rules! impl_openapi_schema { ($($ty:ty),+ $(,)?) => { $(impl api_macros::openapi::OpenApiSchema for $ty {})+ @@ -76,11 +142,242 @@ macro_rules! impl_openapi_schema { } impl_openapi_schema!( + AuthBootstrapUserRequest, + AuthPublicConfig, + AuthUserResponse, + DeviceLoginApproveRequest, + DeviceLoginApproveResponse, + DeviceLoginPollRequest, + DeviceLoginPollResponse, + DeviceLoginStartRequest, + DeviceLoginStartResponse, + HealthResponse, + PasskeyLoginOptionsRequest, + PasskeyLoginOptionsResponse, + PasskeyLoginCompleteRequest, + PasskeyRegistrationOptionsRequest, + PasskeyRegistrationOptionsResponse, + PasskeyRegistrationCompleteRequest, + LogoutResponse, + SetCookieHeader, + WorkspaceRuntimeConfigQuery, + WorkspaceRuntimeConfigResponse, + WhoamiResponse, + WorkspaceCreateRequest, + WorkspaceCreateResponse, + WorkspaceDeletionOperationResponse, + WorkspaceDeletionPreflightResponse, + WorkspaceDeletionRequest, + WorkspaceListQuery, + WorkspaceCatalogListResponse, CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, RepositoryApiError, + WorkdirOperationApiError, RepositoryListResponse, RepositoryDetailResponse, + RepositorySshConnectionProbeRequest, + RepositorySshConnectionProbeResponse, + ConfirmRepositorySshHostTrustRequest, + RepositoryLogQuery, + RepositoryLogResponse, + HostListResponse, + HostWorkerListResponse, + HostSummary, + WorkingDirectoryRemovalRequest, + WorkingDirectoryRemovalResponse, + WorkingDirectoryCreateRequest, + WorkingDirectoryListResponse, + WorkingDirectoryDetailResponse, + WorkingDirectoryCreateResponse, + CurrentWorkerWorkdirAttachRequest, + CurrentWorkerWorkdirAttachmentResponse, + CurrentWorkerWorkdirOperationRequest, + CurrentWorkerWorkdirOperationResponse, + ExternalWorkdirGrantCreateRequest, + ExternalWorkdirGrantResponse, + WorkspaceResponse, + WorkspaceMetadataSettingsResponse, + UpdateWorkspaceMetadataRequest, + WorkspaceMetadataMutationResponse, + WorkspaceSigningIdentityResponse, + WorkspaceMemorySettings, + UpdateWorkspaceMemorySettingsRequest, + RepositoryAccessProjection, + RepositorySshCredentialListResponse, + RepositorySshCredential, + CreateRepositorySshCredentialRequest, + GenerateRepositorySshCredentialRequest, + RepositorySshPublicKey, + RotateRepositorySshCredentialRequest, + DeleteRepositorySshCredentialRequest, + RepositorySshHostTrustListResponse, + RepositorySshHostTrust, + PutRepositorySshHostTrustRequest, + RepositorySshHostTrustMutationResponse, + DeleteRepositorySshHostTrustRequest, + WorkspaceConfigTreeResponse, + WorkspacePromptProjection, + ConfigCommitRequest, + ConfigTreeSnapshot, + ConfigEntry, + ProfileSettingsResponse, + FlowSourceListResponse, + FlowSourceRecord, + PutFlowRequest, + FlowSourceResolveRequest, + ResolvedFlowSource, + MemoryDocumentResponse, + MemoryStagingQuery, + MemoryStagingListResponse, + MemoryBackendRequest, + MemoryBackendResponse, + MemoryConsolidateStagingRequest, + MemoryConsolidationResponse, + SkillCatalogResponse, + SkillDetailResponse, + SkillActivationResponse, + BrowserAppendTicketEventRequest, + BrowserCloseTicketRequest, + BrowserEditTicketRequest, + BrowserQueueTicketRequest, + BrowserTransitionTicketStateRequest, + CancelTicketImplementationRequest, + ClearTicketRoleAssignmentQuery, + CompleteMergeRequestRequest, + CreateTicketOrchestrationPlanRequest, + CreateTicketRecordRequest, + CreateTicketRelationRequest, + DefaultIntakeReadyBodyRequest, + EditTicketRecordItemRequest, + MergeEvent, + MergeRequestDetailResponse, + MergeRequestListQuery, + MergeRequestListResponse, + MergeRequestReadinessResponse, + MergeRequestThreadQuery, + MergeRequestThreadResponse, + ObjectiveCreateRequest, + ObjectiveDetail, + ObjectiveEditRequest, + ObjectiveLinkTicketRequest, + ObjectiveListQuery, + ObjectiveListResponse, + ObjectiveQueryRequest, + ObjectiveQueryResponse, + ObjectiveShowRequest, + ObjectiveStateRequest, + OpenMergeRequestRequest, + PublicMergeRequest, + RegisterMergeRequestReviewCapabilityRequest, + RegisterReviewerChildSessionRequest, + RepairMergeRequestSelectorRequest, + ReviewEvent, + ReviewRevokedEvent, + RevokeMergeRequestReviewRequest, + SetTicketRoleAssignmentRequest, + SubmitMergeRequestReviewRequest, + TextResponse, + TicketCloseRecordRequest, + TicketDependencyCheckResponse, + TicketDetail, + TicketDoctorResponse, + TicketIntakeSummaryRequest, + TicketListHttpQuery, + TicketListResponse, + TicketMarkReadyRequest, + TicketOrchestrationPlanRecord, + TicketOrchestrationPlanRecordList, + TicketOrchestrationPlanSearchRequest, + TicketQueryRequest, + TicketQueryResponse, + TicketQueueResponse, + TicketRecord, + TicketRecordRef, + TicketRecordSummaryList, + TicketRelationRecord, + TicketRelationRecordList, + TicketRelationRecordView, + TicketRelationRemoveRequest, + TicketRelationSearchRequest, + TicketRoleAssignmentMutationResponse, + TicketRoleAssignmentsResponse, + TicketShowRequest, + TicketStateChangeRequest, + TicketSummarySearchQuery, + TicketThreadEventRequest, + RuntimeManagementApiError, + RuntimeWorkersQuery, + RuntimeWorkerSpawnRequest, + RuntimeWorkerSpawnResponse, + RuntimeConfigBundleSyncRequest, + RuntimeConfigBundleAvailabilityQuery, + RuntimeConfigBundleResult, + RuntimeWorkerShowResponse, + RuntimeWorkerInputRequest, + RuntimeWorkerInputResult, + RuntimeWorkerLifecycleRequest, + RuntimeWorkerLifecycleResult, + RuntimeWorkerCompletionsRequest, + RuntimeWorkerCompletionsResult, + RestoreTicketAssignmentQuery, + RuntimeCleanupPlanResponse, + ExecuteRuntimeCleanupRequest, + RuntimeCleanupExecutionResponse, + WorkerRetentionResponse, + WorkerFileUploadQuery, + WorkerFileUploadResponse, + AttachmentUploadGrantResponse, + AttachmentUploadCancelResponse, + WorkerFileDeleteResponse, + CreateRemoteRuntimeRequest, + WorkspaceRuntimeResource, + WorkspaceRuntimeDetail, + UpdateRemoteRuntimeRequest, + RemoveRuntimeRequest, + RuntimeRemovalOperationResponse, + RuntimeTrustKeyRevealResponse, + RevokeRuntimeTrustKeyRequest, + RuntimeConnectionTestResponse, + RuntimeListResponse, + WorkspaceRuntimeListResponse, + RuntimeWorkerListResponse, + WorkerRestoreResponse, + WorkerControlSubject, + WorkerControlRecord, + WorkerControlListResponse, + WorkspaceWorkerDiscoveryQuery, + WorkspaceWorkerSubject, + WorkspaceWorkerDiscoveryItem, + WorkspaceWorkerDiscoveryPage, + CompanionTranscriptQuery, + CompanionLifecycleState, + CompanionMessageDisposition, + CompanionTransportSummary, + CompanionStatusResponse, + CompanionMessageRequest, + CompanionCancelRequest, + CompanionMessageResponse, + CompanionTranscriptRole, + CompanionTranscriptItem, + CompanionTranscriptProjection, + WorkerRemoveRequest, + WorkerRemoveResponse, + WorkerRemoveApiError, + RuntimeResourceKind, + RuntimeResourceOperation, + RuntimeResourceRedactionPolicy, + RuntimeProfileSourceGraphSummary, + RuntimeResourceHandle, + RuntimeResourceFetchRequest, + RuntimeResourceFetchResponse, + RuntimeResourceFetchApiError, + WorkerListResponse, + WorkerSummary, + WorkerLaunchOptionsResponse, + CreateWorkspaceWorkerRequest, + BrowserCreateWorkerResponse, + BrowserWorkspaceOrchestratorResponse, ); #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -92,6 +389,743 @@ pub struct WorkspaceWorkerSessionResponse { #[api(reqwest, axum, openapi)] pub trait ServerApi { + #[get("/health", status = 200, error_status = 400)] + async fn health(&self) -> Result; + + #[get("/api/auth/config", status = 200, error_status = 400)] + async fn auth_config(&self) -> Result; + + #[post( + "/api/auth/bootstrap-user", + status = 200, + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_bootstrap_user( + &self, + #[body] request: AuthBootstrapUserRequest, + ) -> Result; + + #[post( + "/api/auth/passkeys/registration/options", + status = 200, + error_status = 400, + additional_error_statuses = [500, 502] + )] + async fn auth_passkey_registration_options( + &self, + #[extension] context: ServerRequestContext, + #[body] request: PasskeyRegistrationOptionsRequest, + ) -> Result; + + #[post( + "/api/auth/passkeys/registration/complete", + responses = [ + (status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)]) + ], + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_passkey_registration_complete( + &self, + #[body] request: PasskeyRegistrationCompleteRequest, + ) -> Result; + + #[post( + "/api/auth/passkeys/login/options", + status = 200, + error_status = 400, + additional_error_statuses = [500, 502] + )] + async fn auth_passkey_login_options( + &self, + #[extension] context: ServerRequestContext, + #[body] request: PasskeyLoginOptionsRequest, + ) -> Result; + + #[post( + "/api/auth/passkeys/login/complete", + responses = [ + (status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)]) + ], + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_passkey_login_complete( + &self, + #[body] request: PasskeyLoginCompleteRequest, + ) -> Result; + + #[post( + "/api/auth/device-login/start", + status = 200, + error_status = 400, + additional_error_statuses = [500, 502] + )] + async fn auth_device_login_start( + &self, + #[body] request: DeviceLoginStartRequest, + ) -> Result; + + #[post( + "/api/auth/device-login/approve", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500, 502], + bearer_auth = true, + browser_auth = true + )] + async fn auth_device_login_approve( + &self, + #[extension] context: ServerRequestContext, + #[body] request: DeviceLoginApproveRequest, + ) -> Result; + + #[post( + "/api/auth/device-login/poll", + status = 200, + error_status = 400, + additional_error_statuses = [500, 502] + )] + async fn auth_device_login_poll( + &self, + #[body] request: DeviceLoginPollRequest, + ) -> Result; + + #[get("/api/auth/whoami", status = 200, error_status = 400)] + async fn auth_whoami( + &self, + #[extension] context: ServerRequestContext, + ) -> Result; + + #[post( + "/api/auth/logout", + responses = [ + (status = 200, body = LogoutResponse, headers = [("set-cookie", SetCookieHeader)]) + ], + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_logout( + &self, + #[header("cookie")] cookie: Option, + ) -> Result; + + #[get( + "/api/workspaces", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_catalog_list( + &self, + #[extension] context: ServerRequestContext, + #[query] query: WorkspaceListQuery, + ) -> Result; + + #[post( + "/api/workspaces", + status = 201, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_catalog_create( + &self, + #[extension] context: ServerRequestContext, + #[body] request: WorkspaceCreateRequest, + ) -> Result; + + #[get( + "/api/workspaces/{workspace_id}/deletion", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_deletion_preflight( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/workspaces/{workspace_id}/deletion", + status = 202, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_deletion_start( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkspaceDeletionRequest, + ) -> Result; + + #[get( + "/api/workspace-deletions/{operation_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_deletion_get( + &self, + #[extension] context: ServerRequestContext, + #[path] operation_id: String, + ) -> Result; + #[get( + "/api/workspace", + status = 200, + error_status = 400, + bearer_auth = true, + browser_auth = true + )] + async fn workspace_current( + &self, + #[extension] context: ServerRequestContext, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/workspace", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_scoped( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtime-config", + responses = [ + (status = 200, body = WorkspaceRuntimeConfigResponse, headers = [("etag", String), ("cache-control", String)]), + (status = 304, headers = [("etag", String), ("cache-control", String)]) + ], + error_status = 400, + additional_error_statuses = [403, 404, 500] + )] + async fn workspace_runtime_config( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: WorkspaceRuntimeConfigQuery, + #[header("if-none-match")] if_none_match: Option, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_metadata_settings( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[put( + "/api/w/{workspace_id}/settings", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_metadata_settings_update( + &self, + #[path] workspace_id: String, + #[body] request: UpdateWorkspaceMetadataRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings/signing-identity", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_signing_identity( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/settings/signing-identity/provision", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_signing_identity_provision( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings/memory", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_memory_settings( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[put( + "/api/w/{workspace_id}/settings/memory", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_memory_settings_update( + &self, + #[path] workspace_id: String, + #[body] request: UpdateWorkspaceMemorySettingsRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings/repository-access", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_access_projection( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings/repository-access/credentials", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_credential_list( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/settings/repository-access/credentials", + status = 201, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_credential_create( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: CreateRepositorySshCredentialRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/settings/repository-access/credentials/generate", + status = 201, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_credential_generate( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: GenerateRepositorySshCredentialRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_credential_get( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] credential_id: String, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}", + status = 204, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_credential_delete( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] credential_id: String, + #[body] request: DeleteRepositorySshCredentialRequest, + ) -> Result<(), RepositoryApiError>; + + #[get( + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/public-key", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_credential_public_key( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] credential_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/rotate", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_credential_rotate( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] credential_id: String, + #[body] request: RotateRepositorySshCredentialRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings/repository-access/host-trusts", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_host_trust_list( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/settings/repository-access/host-trusts", + status = 201, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_host_trust_put( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: PutRepositorySshHostTrustRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings/repository-access/host-trusts/{host_trust_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_host_trust_get( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] host_trust_id: String, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/settings/repository-access/host-trusts/{host_trust_id}", + status = 204, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_host_trust_delete( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] host_trust_id: String, + #[body] request: DeleteRepositorySshHostTrustRequest, + ) -> Result<(), RepositoryApiError>; + + #[get( + "/api/w/{workspace_id}/config/source-tree", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_config_tree( + &self, + #[path] workspace_id: String, + ) -> Result; + #[get( + "/api/w/{workspace_id}/config/projections/prompts", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn workspace_prompt_projection( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/config/source-tree/commit", + status = 201, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_config_tree_commit( + &self, + #[path] workspace_id: String, + #[body] request: ConfigCommitRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/config/source-tree/revisions/{revision}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_config_revision( + &self, + #[path] workspace_id: String, + #[path] revision: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/config/source-tree/entries/{path}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_config_entry( + &self, + #[path] workspace_id: String, + #[path] path: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/settings/profiles", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn profile_settings( + &self, + #[path] workspace_id: String, + ) -> Result; + #[get( + "/api/w/{workspace_id}/flows", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn flow_list( + &self, + #[path] workspace_id: String, + ) -> Result; + #[put( + "/api/w/{workspace_id}/flows", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn flow_put( + &self, + #[path] workspace_id: String, + #[body] request: PutFlowRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/flows/resolve", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn flow_resolve( + &self, + #[path] workspace_id: String, + #[body] request: FlowSourceResolveRequest, + ) -> Result; + #[get( + "/api/w/{workspace_id}/flows/{flow_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn flow_get( + &self, + #[path] workspace_id: String, + #[path] flow_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/memory", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn memory_document( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/memory/staging", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn memory_staging_list( + &self, + #[path] workspace_id: String, + #[query] query: MemoryStagingQuery, + ) -> Result; + #[post( + "/api/w/{workspace_id}/memory/backend", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn memory_backend( + &self, + #[path] workspace_id: String, + #[body] request: MemoryBackendRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/memory/consolidation", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true, + )] + async fn memory_consolidation( + &self, + #[path] workspace_id: String, + #[body] request: MemoryConsolidateStagingRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/skills", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn skill_list( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/skills/lint", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn skill_lint( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/skills/{name}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn skill_get( + &self, + #[path] workspace_id: String, + #[path] name: String, + ) -> Result; + #[get( + "/api/w/{workspace_id}/skills/{name}/activate", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn skill_activate( + &self, + #[path] workspace_id: String, + #[path] name: String, + ) -> Result; + #[get( "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session", status = 200, @@ -105,6 +1139,935 @@ pub trait ServerApi { #[path] worker_id: String, ) -> Result; + #[get( + "/api/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_worker_list_alias(&self) -> Result; + + #[post( + "/api/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_worker_create_alias( + &self, + #[extension] context: ServerRequestContext, + #[body] request: CreateWorkspaceWorkerRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/orchestrator", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_orchestrator_status( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/orchestrator", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_orchestrator_start( + &self, + #[path] workspace_id: String, + ) -> Result; + #[get( + "/api/w/{workspace_id}/worker-control/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + openapi = false, + )] + async fn worker_control_list( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + #[post( + "/api/w/{workspace_id}/worker-control/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + openapi = false, + )] + async fn worker_control_spawn( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: CreateWorkspaceWorkerRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/input", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + openapi = false, + )] + async fn worker_control_input( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerInputRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/cancel", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + openapi = false, + )] + async fn worker_control_cancel( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerLifecycleRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/stop", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], + openapi = false, + )] + async fn worker_control_stop( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerLifecycleRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/restore", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], + openapi = false, + )] + async fn worker_control_restore( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + ) -> Result; + #[get( + "/api/w/{workspace_id}/worker-observation/sessions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + openapi = false, + )] + async fn worker_observation_sessions( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + #[post( + "/api/w/{workspace_id}/worker-observation/session", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 504], + openapi = false, + )] + async fn worker_observation_capture( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkerObservationSubjectRef, + ) -> Result; + #[get( + "/api/w/{workspace_id}/worker-discovery/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + openapi = false, + )] + async fn workspace_worker_discovery( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: WorkspaceWorkerDiscoveryQuery, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_worker_list( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_worker_create( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: CreateWorkspaceWorkerRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/workers/{worker_ref}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_worker_detail( + &self, + #[path] workspace_id: String, + #[path] worker_ref: String, + ) -> Result; + + #[get( + "/api/workers/launch-options", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_worker_launch_options_alias( + &self, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/workers/launch-options", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_worker_launch_options( + &self, + #[path] workspace_id: String, + ) -> Result; + #[post( + "/api/runtime/v1/workspaces/{workspace_id}/resources/fetch", + status = 200, + error_status = 400, + additional_error_statuses = [401, 404, 410, 502, 504], + openapi = false, + )] + async fn runtime_resource_fetch( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: RuntimeResourceFetchRequest, + ) -> Result; + + #[get( + "/api/companion/status", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn companion_status_alias(&self) -> Result; + + #[get( + "/api/w/{workspace_id}/companion/status", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn companion_status( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/companion/transcript", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn companion_transcript_alias( + &self, + #[query] query: CompanionTranscriptQuery, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/companion/transcript", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn companion_transcript( + &self, + #[path] workspace_id: String, + #[query] query: CompanionTranscriptQuery, + ) -> Result; + + #[post( + "/api/companion/messages", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn companion_message_alias( + &self, + #[body] request: CompanionMessageRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/companion/messages", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn companion_message( + &self, + #[path] workspace_id: String, + #[body] request: CompanionMessageRequest, + ) -> Result; + + #[post( + "/api/companion/cancel", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn companion_cancel_alias( + &self, + #[body] request: CompanionCancelRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/companion/cancel", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn companion_cancel( + &self, + #[path] workspace_id: String, + #[body] request: CompanionCancelRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/workers/remove", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 503], + openapi = false, + )] + async fn workspace_worker_remove( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkerRemoveRequest, + ) -> Result; + + #[get( + "/api/runtimes", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_list_alias(&self) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_list( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes", + status = 201, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_create( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: CreateRemoteRuntimeRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_detail( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_update( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[body] request: UpdateRemoteRuntimeRequest, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/runtimes/{runtime_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_remove( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[body] request: RemoveRuntimeRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_trust_key_reveal( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_trust_key_revoke( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[body] request: RevokeRuntimeTrustKeyRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_connection_test( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + ) -> Result; + + #[get( + "/api/runtimes/{runtime_id}/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_list_alias( + &self, + #[path] runtime_id: String, + #[query] query: RuntimeWorkersQuery, + ) -> Result; + + #[post( + "/api/runtimes/{runtime_id}/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_create_alias( + &self, + #[path] runtime_id: String, + #[body] request: RuntimeWorkerSpawnRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_list( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[query] query: RuntimeWorkersQuery, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_create( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[body] request: RuntimeWorkerSpawnRequest, + ) -> Result; + + #[post( + "/api/runtimes/{runtime_id}/config-bundles", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_config_bundle_sync_alias( + &self, + #[path] runtime_id: String, + #[body] request: RuntimeConfigBundleSyncRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_config_bundle_sync( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[body] request: RuntimeConfigBundleSyncRequest, + ) -> Result; + + #[get( + "/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_config_bundle_availability_alias( + &self, + #[path] runtime_id: String, + #[path] bundle_id: String, + #[query] query: RuntimeConfigBundleAvailabilityQuery, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_config_bundle_availability( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] bundle_id: String, + #[query] query: RuntimeConfigBundleAvailabilityQuery, + ) -> Result; + + #[get( + "/api/runtimes/{runtime_id}/workers/{worker_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_detail_alias( + &self, + #[path] runtime_id: String, + #[path] worker_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_detail( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + ) -> Result; + + #[post( + "/api/runtimes/{runtime_id}/workers/{worker_id}/restore", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_restore_alias( + &self, + #[path] runtime_id: String, + #[path] worker_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_restore( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[query] query: RestoreTicketAssignmentQuery, + ) -> Result; + + #[put( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/pin", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_pin( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/pin", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_unpin( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-plan", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_cleanup_plan( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-executions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_cleanup_execute( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[body] request: ExecuteRuntimeCleanupRequest, + ) -> Result; + + #[post( + "/api/runtimes/{runtime_id}/workers/{worker_id}/input", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_input_alias( + &self, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerInputRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/input", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_input( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerInputRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-upload-grants", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_attachment_upload_grant( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[query] query: WorkerFileUploadQuery, + ) -> Result; + + #[put( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 413, 500, 502, 503], + bearer_auth = true, + browser_auth = true, + normalize_body_errors = true + )] + async fn runtime_worker_attachment_upload( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[path] upload_id: String, + #[binary] body: BinaryBody, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_attachment_upload_cancel( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[path] upload_id: String, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachments/{artifact_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_attachment_delete( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[path] artifact_id: String, + ) -> Result; + + #[post( + "/api/runtimes/{runtime_id}/workers/{worker_id}/completions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_completions_alias( + &self, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerCompletionsRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/completions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_completions( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerCompletionsRequest, + ) -> Result; + + #[post( + "/api/runtimes/{runtime_id}/workers/{worker_id}/stop", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_stop_alias( + &self, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerLifecycleRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/stop", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_stop( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerLifecycleRequest, + ) -> Result; + + #[post( + "/api/runtimes/{runtime_id}/workers/{worker_id}/cancel", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_cancel_alias( + &self, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerLifecycleRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/cancel", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_worker_cancel( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[body] request: RuntimeWorkerLifecycleRequest, + ) -> Result; + #[get( "/api/w/{workspace_id}/repositories", status = 200, @@ -171,10 +2134,898 @@ pub trait ServerApi { #[path] workspace_id: String, #[body] request: CreateWorkspaceRepositoryRequest, ) -> Result; + + #[post( + "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_connection_probe( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] repository_key: String, + #[body] request: RepositorySshConnectionProbeRequest, + ) -> Result; + + #[put( + "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn repository_ssh_host_trust_confirm( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] repository_key: String, + #[body] request: ConfirmRepositorySshHostTrustRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/repositories/{repository_key}/log", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_log( + &self, + #[path] workspace_id: String, + #[path] repository_key: String, + #[query] query: RepositoryLogQuery, + ) -> Result; + + #[get( + "/api/repositories/{repository_key}/log", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_log_alias( + &self, + #[path] repository_key: String, + #[query] query: RepositoryLogQuery, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/hosts", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn host_list( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/hosts", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn host_list_alias(&self) -> Result; + + #[get( + "/api/w/{workspace_id}/hosts/{host_id}/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn host_worker_list( + &self, + #[path] workspace_id: String, + #[path] host_id: String, + ) -> Result; + + #[get( + "/api/hosts/{host_id}/workers", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn host_worker_list_alias( + &self, + #[path] host_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_workdir_list( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", + status = 201, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_workdir_create( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[body] request: WorkingDirectoryCreateRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_workdir_detail( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] working_directory_id: String, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn runtime_workdir_cleanup( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] working_directory_id: String, + #[body] request: WorkingDirectoryRemovalRequest, + ) -> Result; + #[post( + "/api/w/{workspace_id}/workers/self/workdir-attachments", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + openapi = false, + )] + async fn current_worker_workdir_attach( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: CurrentWorkerWorkdirAttachRequest, + ) -> Result; + #[delete( + "/api/w/{workspace_id}/workers/self/workdir-attachments/{alias}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + openapi = false, + )] + async fn current_worker_workdir_detach( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] alias: String, + ) -> Result; + #[post( + "/api/w/{workspace_id}/workers/self/workdir-session/operations", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + openapi = false, + )] + async fn current_worker_workdir_operation( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: CurrentWorkerWorkdirOperationRequest, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/external-workdir-grants", + status = 201, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn external_workdir_grant_create( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: ExternalWorkdirGrantCreateRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn external_workdir_grant_get( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] grant_id: String, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true + )] + async fn external_workdir_grant_revoke( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] grant_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/working-directories", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_workdir_list( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/working-directories", + status = 201, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_workdir_create( + &self, + #[path] workspace_id: String, + #[body] request: WorkingDirectoryCreateRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/working-directories/{working_directory_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_workdir_detail( + &self, + #[path] workspace_id: String, + #[path] working_directory_id: String, + ) -> Result; + + #[delete( + "/api/w/{workspace_id}/working-directories/{working_directory_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + bearer_auth = true, + browser_auth = true + )] + async fn workspace_workdir_cleanup( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] working_directory_id: String, + #[body] request: WorkingDirectoryRemovalRequest, + ) -> Result; + #[get("/api/tickets", status = 200, error_status = 400, additional_error_statuses = [500], bearer_auth = true, browser_auth = true)] + async fn ticket_list_alias( + &self, + #[query] query: TicketListHttpQuery, + ) -> Result; + + #[get("/api/w/{workspace_id}/tickets", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_list( + &self, + #[path] workspace_id: String, + #[query] query: TicketListHttpQuery, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_create_record( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: CreateTicketRecordRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/query", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_query( + &self, + #[path] workspace_id: String, + #[body] request: TicketQueryRequest, + ) -> Result; + #[get("/api/tickets/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_get_alias( + &self, + #[path] id: String, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/default-intake-ready-body", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_default_intake_ready_body( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: DefaultIntakeReadyBodyRequest, + ) -> Result; + #[get("/api/w/{workspace_id}/tickets/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_summary_search( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: TicketSummarySearchQuery, + ) -> Result; + #[get("/api/w/{workspace_id}/tickets/doctor", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_doctor( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/relations/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_relation_query( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: TicketRelationSearchRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/orchestration-plans/search", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_orchestration_plan_query( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: TicketOrchestrationPlanSearchRequest, + ) -> Result; + #[get("/api/w/{workspace_id}/tickets/{id}/record", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_record_get( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + ) -> Result; + #[patch("/api/w/{workspace_id}/tickets/{id}/item", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_record_item_edit( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: EditTicketRecordItemRequest, + ) -> Result; + #[get("/api/w/{workspace_id}/tickets/{id}/dependency-check", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_dependency_check( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/thread-events", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_thread_event_add( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketThreadEventRequest, + ) -> Result<(), RepositoryApiError>; + #[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_state_change_add( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketStateChangeRequest, + ) -> Result<(), RepositoryApiError>; + #[post("/api/w/{workspace_id}/tickets/{id}/intake-summaries", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_intake_summary_add( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketIntakeSummaryRequest, + ) -> Result<(), RepositoryApiError>; + #[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_state_field_set( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[path] field: String, + #[body] request: TicketStateChangeRequest, + ) -> Result<(), RepositoryApiError>; + #[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_workflow_state_set( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketStateChangeRequest, + ) -> Result<(), RepositoryApiError>; + #[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_mark_ready_record( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketMarkReadyRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/workflow/queue", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_queue_record( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + ) -> Result; + + #[get("/api/w/{workspace_id}/merge-requests", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn merge_request_list( + &self, + #[path] workspace_id: String, + #[query] query: MergeRequestListQuery, + ) -> Result; + + #[get("/api/w/{workspace_id}/merge-requests/{merge_request_id}", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true)] + async fn merge_request_show( + &self, + #[path] workspace_id: String, + #[path] merge_request_id: String, + #[query] query: MergeRequestThreadQuery, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/merge-request", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + async fn merge_request_open( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: OpenMergeRequestRequest, + ) -> Result; + #[get("/api/w/{workspace_id}/tickets/{id}/merge-request/readiness", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn merge_request_readiness( + &self, + #[path] workspace_id: String, + #[path] id: String, + ) -> Result; + #[get("/api/w/{workspace_id}/tickets/{id}/merge-request/thread", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn merge_request_thread( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[query] query: MergeRequestThreadQuery, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], browser_auth = true)] + async fn merge_request_selector_repair( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: RepairMergeRequestSelectorRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/internal/reviewer-child-sessions", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + async fn merge_request_reviewer_child_register( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: RegisterReviewerChildSessionRequest, + ) -> Result<(), RepositoryApiError>; + #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/review-capabilities", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + async fn merge_request_review_capability_register( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: RegisterMergeRequestReviewCapabilityRequest, + ) -> Result<(), RepositoryApiError>; + #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + async fn merge_request_review_submit( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: SubmitMergeRequestReviewRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/reviews/revoke", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + async fn merge_request_review_revoke( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: RevokeMergeRequestReviewRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/merge-request/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)] + async fn merge_request_complete( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: CompleteMergeRequestRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_close_record( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketCloseRecordRequest, + ) -> Result<(), RepositoryApiError>; + #[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_relation_view( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/relations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_relation_record( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: CreateTicketRelationRequest, + ) -> Result; + #[delete("/api/w/{workspace_id}/tickets/{id}/relations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_relation_remove( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketRelationRemoveRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/orchestration-plans", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_orchestration_plan_record( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: CreateTicketOrchestrationPlanRequest, + ) -> Result; + + #[get("/api/w/{workspace_id}/tickets/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_get( + &self, + #[path] workspace_id: String, + #[path] id: String, + ) -> Result; + + #[patch("/api/w/{workspace_id}/tickets/{id}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_edit( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: BrowserEditTicketRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_show( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketShowRequest, + ) -> Result; + + #[get("/api/w/{workspace_id}/tickets/{id}/assignments", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_list( + &self, + #[path] workspace_id: String, + #[path] id: String, + ) -> Result; + + #[put("/api/w/{workspace_id}/tickets/{id}/assignments/{role}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_set( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[path] role: String, + #[body] request: SetTicketRoleAssignmentRequest, + ) -> Result; + + #[delete("/api/w/{workspace_id}/tickets/{id}/assignments/{role}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_clear( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[path] role: String, + #[query] query: ClearTicketRoleAssignmentQuery, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/{id}/implementation-cancellations", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_implementation_cancel( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: CancelTicketImplementationRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_state_transition( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: BrowserTransitionTicketStateRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/{id}/ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_ready( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketMarkReadyRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/{id}/events", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_event_append( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: BrowserAppendTicketEventRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/{id}/queue", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_queue( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: BrowserQueueTicketRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_close( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: BrowserCloseTicketRequest, + ) -> Result; + #[get("/api/objectives", status = 200, error_status = 400, additional_error_statuses = [500], bearer_auth = true, browser_auth = true)] + async fn objective_list_alias( + &self, + #[query] query: ObjectiveListQuery, + ) -> Result; + + #[get("/api/w/{workspace_id}/objectives", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn objective_list( + &self, + #[path] workspace_id: String, + #[query] query: ObjectiveListQuery, + ) -> Result; + + #[post("/api/w/{workspace_id}/objectives", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn objective_create( + &self, + #[path] workspace_id: String, + #[body] request: ObjectiveCreateRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/objectives/query", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn objective_query( + &self, + #[path] workspace_id: String, + #[body] request: ObjectiveQueryRequest, + ) -> Result; + #[get("/api/objectives/{id}", status = 200, error_status = 404, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] + async fn objective_get_alias( + &self, + #[path] id: String, + ) -> Result; + + #[get("/api/w/{workspace_id}/objectives/{objective_id}", status = 200, error_status = 404, additional_error_statuses = [400, 401, 403, 500], bearer_auth = true, browser_auth = true)] + async fn objective_get( + &self, + #[path] workspace_id: String, + #[path] objective_id: String, + ) -> Result; + + #[patch("/api/w/{workspace_id}/objectives/{objective_id}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn objective_edit( + &self, + #[path] workspace_id: String, + #[path] objective_id: String, + #[body] request: ObjectiveEditRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/objectives/{objective_id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn objective_show( + &self, + #[path] workspace_id: String, + #[path] objective_id: String, + #[body] request: ObjectiveShowRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/objectives/{objective_id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn objective_state_set( + &self, + #[path] workspace_id: String, + #[path] objective_id: String, + #[body] request: ObjectiveStateRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/objectives/{objective_id}/ticket-links", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn objective_ticket_link( + &self, + #[path] workspace_id: String, + #[path] objective_id: String, + #[body] request: ObjectiveLinkTicketRequest, + ) -> Result; + + #[delete("/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn objective_ticket_unlink( + &self, + #[path] workspace_id: String, + #[path] objective_id: String, + #[path] ticket_id: String, + ) -> Result; + + #[websocket( + "/api/w/{workspace_id}/protocol/ws", + operation_id = "workspace_protocol_websocket", + method = GET, + client_to_server = protocol::subscription::SubscriptionFrame, + server_to_client = protocol::subscription::SubscriptionFrame, + path_parameters = [workspace_id: String] + )] + type WorkspaceProtocolWebSocket; + + #[websocket( + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", + operation_id = "external_workdir_provider_websocket", + method = GET, + client_to_server = workdir::external::ExternalWorkdirProviderFrame, + server_to_client = workdir::external::ExternalWorkdirServerFrame, + path_parameters = [workspace_id: String, grant_id: String] + )] + type ExternalWorkdirProviderWebSocket; + + #[websocket( + "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + operation_id = "worker_protocol_websocket_alias", + method = GET, + client_to_server = protocol::Method, + server_to_client = protocol::Event, + path_parameters = [runtime_id: String, worker_id: String] + )] + type WorkerProtocolWebSocketAlias; + + #[websocket( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + operation_id = "workspace_worker_protocol_websocket", + method = GET, + client_to_server = protocol::Method, + server_to_client = protocol::Event, + path_parameters = [workspace_id: String, runtime_id: String, worker_id: String] + )] + type WorkspaceWorkerProtocolWebSocket; } -/// Digest of the fully rendered canonical contract with its digest slot normalized. -pub fn canonical_openapi_source_digest() -> Result { +/// Stable fingerprint for one complete operation declaration. +/// +/// The fingerprint includes transport kind, directional WebSocket frame authorities, path +/// parameter names and types, and every unary body/response shape. Length-prefixing keeps the +/// representation unambiguous without introducing a second schema format. +pub fn operation_fingerprint(operation: &api_macros::OperationMetadata) -> String { + fn field(hasher: &mut Sha256, value: &str) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value.as_bytes()); + } + + let mut hasher = Sha256::new(); + field(&mut hasher, operation.operation_id); + field(&mut hasher, http_method_name(operation.method)); + field(&mut hasher, operation.path); + match operation.transport { + api_macros::TransportMetadata::Http => field(&mut hasher, "http"), + api_macros::TransportMetadata::WebSocket { + client_to_server_frame, + server_to_client_frame, + } => { + field(&mut hasher, "websocket"); + field(&mut hasher, client_to_server_frame); + field(&mut hasher, server_to_client_frame); + } + } + field(&mut hasher, "parameters"); + field(&mut hasher, &operation.parameters.len().to_string()); + for parameter in operation.parameters { + field(&mut hasher, parameter.rust_name); + field(&mut hasher, parameter.rust_type); + field(&mut hasher, parameter.wire_name); + field(&mut hasher, parameter_location_name(parameter.location)); + } + field( + &mut hasher, + wire_kind_name(operation.request_body.wire_kind), + ); + field(&mut hasher, "successes"); + field(&mut hasher, &operation.success_responses.len().to_string()); + for response in operation.success_responses { + field(&mut hasher, &response.status.to_string()); + field(&mut hasher, wire_kind_name(response.body.wire_kind)); + field(&mut hasher, &response.headers.len().to_string()); + for header in response.headers { + field(&mut hasher, header.wire_name); + field(&mut hasher, header.rust_type); + } + } + field(&mut hasher, "errors"); + field(&mut hasher, &operation.error_responses.len().to_string()); + for response in operation.error_responses { + field(&mut hasher, &response.status.to_string()); + field(&mut hasher, wire_kind_name(response.body.wire_kind)); + } + let digest = hasher.finalize(); + 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"); + } + encoded +} + +fn http_method_name(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Patch => "PATCH", + HttpMethod::Delete => "DELETE", + HttpMethod::Head => "HEAD", + HttpMethod::Options => "OPTIONS", + } +} + +fn parameter_location_name(location: api_macros::ParameterLocation) -> &'static str { + match location { + api_macros::ParameterLocation::Path => "path", + api_macros::ParameterLocation::Query => "query", + api_macros::ParameterLocation::Header => "header", + api_macros::ParameterLocation::Body => "body", + } +} + +fn wire_kind_name(kind: api_macros::WireKind) -> &'static str { + match kind { + api_macros::WireKind::Empty => "empty", + api_macros::WireKind::Json => "json", + api_macros::WireKind::Streaming => "streaming", + api_macros::WireKind::Multipart => "multipart", + api_macros::WireKind::Binary => "binary", + _ => "reserved", + } +} + +/// Digest of the complete canonical contract with the OpenAPI digest slot normalized. +pub fn canonical_contract_source_digest() -> Result { const NORMALIZED_DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo { @@ -183,7 +3034,15 @@ pub fn canonical_openapi_source_digest() -> Result Result Result { - let source_digest = canonical_openapi_source_digest()?; + let source_digest = canonical_contract_source_digest()?; server_api_openapi(api_macros::openapi::OpenApiInfo { title: "Yoi Server API", version: env!("CARGO_PKG_VERSION"), @@ -204,8 +3063,61 @@ pub fn canonical_openapi_document() }) } +/// Server-local request context populated by authentication middleware. +/// +/// Generated clients and OpenAPI omit extension parameters; operation implementations use this +/// value instead of re-parsing transport headers inside domain handlers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServerWorkerSource { + pub runtime_id: String, + pub worker_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServerRuntimeSource { + pub runtime_id: String, + pub worker_id: Option, +} + +#[derive(Clone)] +pub struct ServerRequestContext { + pub actor: Option, + pub worker_source: Option, + pub runtime_source: Option, + pub origin: Option, + /// Transport headers retained for Server-side authentication adapters. + /// + /// Generated clients and OpenAPI do not expose this extension. Values stay byte-exact so the + /// Workspace Server can reuse the existing Runtime source-proof and browser-session authority + /// while generated Axum routes replace only the handwritten route registration. + pub transport_headers: Vec<(String, Vec)>, +} + +impl std::fmt::Debug for ServerRequestContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ServerRequestContext") + .field("actor", &self.actor) + .field("origin", &self.origin) + .field("transport_header_count", &self.transport_headers.len()) + .finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct HealthResponse { + pub status: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceListQuery { + pub limit: Option, +} + /// Public browser-authentication configuration. -#[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 AuthPublicConfig { @@ -216,7 +3128,7 @@ pub struct AuthPublicConfig { } /// Authentication method that established the current request actor. -#[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 ActorAuthMethod { @@ -225,7 +3137,7 @@ pub enum ActorAuthMethod { } /// Public user identity returned by authentication operations. -#[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 AuthenticatedUser { @@ -236,7 +3148,7 @@ pub struct AuthenticatedUser { } /// Authenticated actor returned by `GET /api/auth/whoami`. -#[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 RequestActor { @@ -258,14 +3170,14 @@ impl RequestActor { } } -#[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 WhoamiResponse { pub actor: Option, } -#[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 AuthBootstrapUserRequest { @@ -275,14 +3187,14 @@ pub struct AuthBootstrapUserRequest { pub display_name: Option, } -#[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 AuthUserResponse { pub user: AuthenticatedUser, } -#[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 PasskeyRegistrationOptionsRequest { @@ -295,25 +3207,27 @@ pub struct PasskeyRegistrationOptionsRequest { pub browser_origin: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyRegistrationOptionsResponse { pub challenge_id: String, #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + #[schemars(with = "serde_json::Value")] pub public_key: CreationChallengeResponse, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyRegistrationCompleteRequest { pub challenge_id: String, #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + #[schemars(with = "serde_json::Value")] pub credential: RegisterPublicKeyCredential, } -#[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 PasskeyLoginOptionsRequest { @@ -325,25 +3239,236 @@ pub struct PasskeyLoginOptionsRequest { pub browser_origin: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyLoginOptionsResponse { pub challenge_id: String, #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + #[schemars(with = "serde_json::Value")] pub public_key: RequestChallengeResponse, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyLoginCompleteRequest { pub challenge_id: String, #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + #[schemars(with = "serde_json::Value")] pub credential: PublicKeyCredential, } -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Sensitive `Set-Cookie` response header value. +/// +/// Formatting is available only for transport encoding; `Debug` always redacts the value. +#[derive(Clone, Eq, JsonSchema, PartialEq)] +#[schemars(transparent)] +pub struct SetCookieHeader(String); + +impl SetCookieHeader { + pub fn new(value: String) -> Self { + Self(value) + } + + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for SetCookieHeader { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("SetCookieHeader()") + } +} + +impl std::fmt::Display for SetCookieHeader { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::str::FromStr for SetCookieHeader { + type Err = std::convert::Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self(value.to_owned())) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigQuery { + pub profile: String, +} + +/// Canonical Runtime config-bundle response projected by Workspace authority. +/// +/// This is intentionally a concrete transport DTO rather than an opaque JSON value so generated +/// clients and OpenAPI consumers receive the same complete wire contract as the Runtime. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigResponse { + pub metadata: WorkspaceRuntimeConfigMetadata, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub profiles: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub declarations: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_catalog: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_archive: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_archive_handle: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigMetadata { + pub id: String, + pub digest: String, + pub revision: String, + pub workspace_id: String, + pub created_at: String, + pub provenance: WorkspaceRuntimeConfigProvenance, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigProvenance { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigProfile { + pub selector: WorkspaceRuntimeProfileSelector, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum WorkspaceRuntimeProfileSelector { + Builtin(String), + Named(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigDeclaration { + pub kind: WorkspaceRuntimeConfigDeclarationKind, + pub name: String, + pub reference: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeConfigDeclarationKind { + SecretRef, + MountGrant, + NetworkPolicy, + ShellPolicy, + GitPolicy, + CapabilityGrant, + Unsupported, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimePromptCatalog { + pub templates: BTreeMap, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub config_revision: u64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source_digest: String, + pub schema_fingerprint: String, + pub toolchain_fingerprint: String, + pub catalog_digest: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeProfileSourceArchive { + pub reference: WorkspaceRuntimeProfileSourceArchiveRef, + pub content: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeProfileSourceArchiveRef { + pub id: String, + pub digest: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub size_bytes: u64, + pub source_graph: WorkspaceRuntimeProfileSourceGraph, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeProfileSourceGraph { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub source_count: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub total_source_bytes: u64, + pub entrypoints: BTreeMap, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub import_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeResourceHandle { + pub kind: WorkspaceRuntimeResourceKind, + 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: WorkspaceRuntimeResourceOperation, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))] + pub expires_at_unix_seconds: i64, + pub nonce: String, + pub revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub generation: Option, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub max_bytes: u64, + pub content_type: String, + pub redaction: WorkspaceRuntimeResourceRedactionPolicy, + pub audit_correlation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_graph: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeResourceKind { + ProfileSourceArchive, + RepositorySshAccess, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeResourceOperation { + FetchArchive, + FetchOnce, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeResourceRedactionPolicy { + RuntimeInternalOnly, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeviceLoginStartRequest { @@ -355,16 +3480,19 @@ pub struct DeviceLoginStartRequest { const DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS: u64 = 24 * 60 * 60; const DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS: u64 = 60; -#[derive(Clone, Serialize, PartialEq, Eq)] +#[derive(Clone, Serialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct DeviceLoginStartResponse { pub device_code: String, pub user_code: String, pub verification_uri: String, pub verification_uri_complete: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 1, max = 86400))] pub expires_in: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 1, max = 60))] pub interval: u64, } @@ -420,21 +3548,21 @@ impl std::fmt::Debug for DeviceLoginStartResponse { } } -#[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 DeviceLoginApproveRequest { pub user_code: String, } -#[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 DeviceLoginApprovalStatus { Approved, } -#[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 DeviceLoginApproveResponse { @@ -442,20 +3570,20 @@ pub struct DeviceLoginApproveResponse { pub user: AuthenticatedUser, } -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeviceLoginPollRequest { pub device_code: String, } -#[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))] pub enum DeviceAccessTokenType { Bearer, } -#[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 DeviceLoginPollStatus { @@ -466,7 +3594,7 @@ pub enum DeviceLoginPollStatus { Consumed, } -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeviceLoginPollResponse { @@ -493,14 +3621,14 @@ impl std::fmt::Debug for DeviceLoginPollResponse { } } -#[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 LogoutStatus { LoggedOut, } -#[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 LogoutResponse { @@ -680,7 +3808,7 @@ impl RepositoryObservedStatus { } /// Public Workspace catalog item returned by `GET /api/workspaces`. -#[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 WorkspaceSummary { @@ -696,12 +3824,12 @@ pub struct WorkspaceSummary { /// /// The transparent newtype keeps the established top-level JSON array while making the /// complete list response a named cross-crate and generated-TypeScript authority. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkspaceCatalogListResponse(pub Vec); /// Public Repository record embedded in Workspace creation responses. -#[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 WorkspaceRepositoryRecord { @@ -712,6 +3840,7 @@ pub struct WorkspaceRepositoryRecord { pub source: RepositorySource, pub default_ref: Option, #[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, @@ -721,7 +3850,7 @@ pub struct WorkspaceRepositoryRecord { } /// Initial Repository registration intent for Workspace creation. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct InitialRepositoryIntent { pub repository_key: String, @@ -731,7 +3860,7 @@ pub struct InitialRepositoryIntent { } /// Request for atomically creating a Workspace and its initial Repository. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkspaceCreateRequest { pub operation_key: String, @@ -740,20 +3869,27 @@ pub struct WorkspaceCreateRequest { } /// Response returned after atomically creating a Workspace and its first Repository. -#[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 WorkspaceCreateResponse { pub workspace: WorkspaceSummary, pub repository: WorkspaceRepositoryRecord, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub config_revision: u64, pub request_fingerprint: String, pub replayed: bool, } +impl api_macros::HttpSuccess for WorkspaceCreateResponse { + fn status_code(&self) -> u16 { + if self.replayed { 200 } else { 201 } + } +} + /// Browser authentication configuration exposed by the scoped Workspace summary. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub enum WorkspaceAuthConfig { Passkey { @@ -765,7 +3901,7 @@ pub enum WorkspaceAuthConfig { } /// Backend-authoritative permissions for the current Workspace actor. -#[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 WorkspacePermissionSummary { @@ -775,7 +3911,7 @@ pub struct WorkspacePermissionSummary { pub delete_workspace: bool, } -#[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 WorkspaceExtensionPointState { @@ -784,7 +3920,7 @@ pub struct WorkspaceExtensionPointState { 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 WorkspaceExtensionPoints { @@ -795,7 +3931,7 @@ pub struct WorkspaceExtensionPoints { } /// Scoped Workspace metadata and current-actor permission projection. -#[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 WorkspaceResponse { @@ -803,6 +3939,7 @@ pub struct WorkspaceResponse { pub display_name: String, pub record_authority: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))] pub schema_version: i64, pub auth: WorkspaceAuthConfig, pub permissions: WorkspacePermissionSummary, @@ -810,7 +3947,7 @@ pub struct WorkspaceResponse { } /// Workspace display metadata exposed from the Server DB settings authority. -#[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 WorkspaceMetadataSettingsResponse { @@ -823,7 +3960,7 @@ pub struct WorkspaceMetadataSettingsResponse { } /// Compare-and-swap update for Workspace identity display metadata. -#[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 UpdateWorkspaceMetadataRequest { @@ -831,7 +3968,7 @@ pub struct UpdateWorkspaceMetadataRequest { pub revision: 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 WorkspaceMetadataMutationResponse { @@ -840,7 +3977,7 @@ pub struct WorkspaceMetadataMutationResponse { } /// Lifecycle state for a Workspace-scoped Ed25519 signing identity. -#[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 WorkspaceSigningIdentityState { @@ -850,7 +3987,7 @@ pub enum WorkspaceSigningIdentityState { /// Public metadata for a Workspace signing identity. Private material and its /// storage reference are deliberately not part of this wire authority. -#[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 WorkspaceSigningIdentityPublic { @@ -864,6 +4001,7 @@ pub struct WorkspaceSigningIdentityPublic { #[cfg_attr(feature = "typescript", ts(optional))] pub public_key_fingerprint: Option, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub revision: u64, pub state: WorkspaceSigningIdentityState, pub created_at: String, @@ -873,7 +4011,7 @@ pub struct WorkspaceSigningIdentityPublic { } /// Copyable public trust bundle consumed by future Runtime enrollment work. -#[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 WorkspacePublicIdentityBundle { @@ -884,10 +4022,11 @@ pub struct WorkspacePublicIdentityBundle { pub public_key: String, pub public_key_fingerprint: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub revision: u64, } -#[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 WorkspaceSigningIdentityResponse { @@ -897,6 +4036,288 @@ pub struct WorkspaceSigningIdentityResponse { pub public_bundle: Option, } +/// Source content kinds accepted by the Workspace configuration tree. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum ConfigContentType { + Decodal, + Text, +} + +/// One source entry in the Workspace configuration tree. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigEntry { + pub path: String, + pub content_type: ConfigContentType, + pub content: String, + pub content_digest: String, +} + +/// Immutable Workspace configuration tree snapshot. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigTreeSnapshot { + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub digest: String, + pub entries: BTreeMap, +} + +/// Base-relative change applied by a Workspace configuration commit. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConfigTreeChange { + Create { + path: String, + content_type: ConfigContentType, + content: String, + }, + Update { + path: String, + expected_digest: String, + content: String, + }, + Rename { + from: String, + to: String, + expected_digest: String, + }, + Delete { + path: String, + expected_digest: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConfigProjectionValidator { + StaticTemplateCatalog { + namespace: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + key_aliases: BTreeMap, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigSchemaContribution { + pub provider_id: String, + pub namespace: String, + pub version: String, + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub projection_validator: Option, + pub source_digest: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigSchemaBundle { + pub contributions: Vec, + pub source: String, + pub fingerprint: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ToolchainContract { + pub contract_version: u32, + pub decodal_version: String, + pub schema_version: u32, + pub entrypoints: Vec, + pub import_policy_version: u32, + pub schema_bundle: WorkspaceConfigSchemaBundle, + pub fingerprint: String, +} + +/// Workspace configuration tree plus the exact evaluation contract that produced it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigTreeResponse { + pub snapshot: ConfigTreeSnapshot, + pub contract: ToolchainContract, + pub projection_digest: String, +} + +/// Compare-and-swap request for applying a complete set of source-tree changes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfigCommitRequest { + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub base_revision: u64, + pub base_digest: String, + pub changes: Vec, + pub entrypoints: Vec, +} + +/// Effective Prompt catalog projected from one immutable Workspace config revision. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspacePromptProjection { + pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub config_revision: u64, + pub source_digest: String, + pub projection_digest: String, + pub schema_fingerprint: String, + pub toolchain_fingerprint: String, + pub catalog: EffectivePromptCatalog, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct EffectivePromptCatalog { + pub templates: BTreeMap, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub config_revision: u64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source_digest: String, + pub schema_fingerprint: String, + pub toolchain_fingerprint: String, + pub catalog_digest: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum FlowSourceKind { + Builtin, + Workspace, +} + +/// Current source record for one Workspace-authored Flow. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct FlowSourceRecord { + pub workspace_id: String, + pub flow_id: String, + pub source_kind: FlowSourceKind, + pub name: String, + pub path: String, + pub content: String, + pub content_digest: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct FlowSourceListResponse(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PutFlowRequest { + pub path: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct FlowSourceResolveRequest { + pub selector: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct CompiledFlowTransition { + pub id: String, + pub target: String, + pub condition: String, + pub synthetic: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct CompiledFlowState { + pub id: String, + pub instructions: String, + pub terminal: bool, + pub transitions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct CompiledFlowDefinition { + pub schema_version: u32, + pub name: String, + pub initial: String, + pub states: BTreeMap, + pub content_digest: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ResolvedFlowSource { + pub selector: String, + pub workspace_id: String, + pub flow_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub content_digest: String, + pub definition: CompiledFlowDefinition, +} + +/// Query parameters for the bounded Memory staging list. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MemoryStagingQuery { + #[schemars(range(min = 0, max = 1000))] + pub limit: Option, +} + +/// Public wrapper retaining the established tagged Memory backend wire shape. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct MemoryBackendRequest(pub memory::backend::MemoryBackendOperation); + +/// Public wrapper retaining the established tagged Memory backend response shape. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct MemoryBackendResponse(pub memory::backend::MemoryBackendHttpResponse); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MemoryConsolidateStagingRequest { + #[serde(default)] + pub force: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MemoryConsolidationResponse { + pub status: String, + pub summary: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub candidate_count: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub total_bytes: u64, +} + pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256; @@ -1016,7 +4437,7 @@ where } /// Lifecycle state for one durable Workspace deletion operation. -#[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 WorkspaceDeletionState { @@ -1028,7 +4449,7 @@ pub enum WorkspaceDeletionState { } /// Stable category explaining why Workspace deletion cannot currently advance. -#[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 WorkspaceDeletionBlockerKind { @@ -1042,8 +4463,9 @@ pub enum WorkspaceDeletionBlockerKind { } /// One bounded, user-actionable blocker returned by preflight or execution. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkspaceDeletionBlocker { pub kind: WorkspaceDeletionBlockerKind, pub resource_kind: Option, @@ -1052,27 +4474,34 @@ pub struct WorkspaceDeletionBlocker { } /// Workspace-owned resources summarized before destructive confirmation. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkspaceDeletionResourceCounts { #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub workers: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub workdirs: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub repositories: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub runtime_bindings: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub secrets: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub artifacts: u64, } /// Owner-only impact preview for deleting one Workspace. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkspaceDeletionPreflightResponse { pub workspace_id: String, pub display_name: String, @@ -1084,8 +4513,9 @@ pub struct WorkspaceDeletionPreflightResponse { } /// Idempotent request to start or resume Workspace deletion. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkspaceDeletionRequest { pub operation_id: String, pub expected_revision: String, @@ -1093,8 +4523,9 @@ pub struct WorkspaceDeletionRequest { } /// Durable deletion operation projection used by request responses and polling. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkspaceDeletionOperationResponse { pub operation_id: String, pub workspace_id: String, @@ -1109,6 +4540,16 @@ pub struct WorkspaceDeletionOperationResponse { pub completed_at: Option, } +impl api_macros::HttpSuccess for WorkspaceDeletionOperationResponse { + fn status_code(&self) -> u16 { + if self.state == WorkspaceDeletionState::Succeeded { + 200 + } else { + 202 + } + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct WorkspaceDeletionBlockerWire { @@ -1233,7 +4674,7 @@ impl<'de> Deserialize<'de> for WorkspaceDeletionOperationResponse { } /// Read-only Profile catalog projected from one active Workspace config revision. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] @@ -1242,6 +4683,7 @@ pub struct ProfileSettingsResponse { pub registry_revision: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub config_revision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tree_digest: Option, @@ -1254,7 +4696,7 @@ pub struct ProfileSettingsResponse { 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))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] @@ -1272,7 +4714,7 @@ pub struct WorkspaceProfileSummary { 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 WorkspaceProfileSourceSummary { @@ -1285,11 +4727,12 @@ pub struct WorkspaceProfileSourceSummary { pub editable: bool, pub revision: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub size_bytes: u64, pub diagnostics: Vec, } -#[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 WorkspaceProfileSourceProvenance { @@ -1352,7 +4795,7 @@ pub struct RepositorySummary { pub diagnostics: Option>, } -#[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 GitCommitSummary { @@ -1385,14 +4828,14 @@ pub struct RepositoryDetailResponse { pub source: 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 RepositorySshConnectionProbeRequest { pub runtime_id: 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 RepositorySshHostKeyCandidate { @@ -1401,7 +4844,7 @@ pub struct RepositorySshHostKeyCandidate { pub fingerprint: String, } -#[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 RepositorySshConnectionTrustState { @@ -1410,7 +4853,7 @@ pub enum RepositorySshConnectionTrustState { Changed, } -#[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 RepositorySshConnectionProbeResponse { @@ -1422,11 +4865,12 @@ pub struct RepositorySshConnectionProbeResponse { pub trust_state: RepositorySshConnectionTrustState, pub host_trust_id: String, #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_host_trust_revision: Option, pub candidates: 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 ConfirmRepositorySshHostTrustRequest { @@ -1434,10 +4878,19 @@ pub struct ConfirmRepositorySshHostTrustRequest { pub runtime_id: String, pub host_key: String, #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_host_trust_revision: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RepositoryLogQuery { + #[serde(default)] + #[schemars(range(min = 1, max = 200))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositoryLogResponse { @@ -1446,6 +4899,7 @@ pub struct RepositoryLogResponse { #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional = nullable))] pub default_selector: Option, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, pub items: Vec, pub diagnostics: Vec, @@ -1476,7 +4930,7 @@ pub struct Diagnostic { /// /// The value identifies stable materialization provenance without exposing a /// provider path, Runtime handle, or session identity. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum WorkingDirectoryMaterializerKind { @@ -1485,7 +4939,7 @@ pub enum WorkingDirectoryMaterializerKind { ClientHostedExternal, } -#[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 WorkingDirectoryStatusKind { @@ -1514,7 +4968,7 @@ impl std::fmt::Display for WorkingDirectoryStatusKind { } } -#[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 WorkingDirectoryCleanupTarget { @@ -1523,14 +4977,14 @@ pub struct WorkingDirectoryCleanupTarget { pub repository_key: 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 WorkingDirectoryRemovalRequest { pub reason: String, } -#[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 WorkingDirectoryRemovalDisposition { @@ -1539,7 +4993,7 @@ pub enum WorkingDirectoryRemovalDisposition { AttentionRequired, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] @@ -1552,7 +5006,7 @@ pub struct WorkingDirectoryRemovalResponse { } /// Durable Workspace occupancy projection for one Workdir. -#[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 WorkingDirectoryOccupancy { @@ -1565,7 +5019,7 @@ pub struct WorkingDirectoryOccupancy { /// Runtime-internal Workdir cleanup authority. This transport intentionally /// retains the Backend-generated Repository id and is never a Workspace public /// projection. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] @@ -1578,7 +5032,7 @@ pub struct RuntimeWorkingDirectoryCleanupTarget { /// Runtime-internal Workdir inventory transport. Workspace REST and model-facing /// surfaces must project this through [`WorkingDirectorySummary`] so the UUID is /// replaced with `repository_key`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] @@ -1602,6 +5056,7 @@ pub struct RuntimeWorkingDirectorySummary { pub current_tree: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub observed_at_epoch_seconds: Option, pub materializer_kind: WorkingDirectoryMaterializerKind, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1614,7 +5069,7 @@ pub struct RuntimeWorkingDirectorySummary { } /// Public Workdir source identity. Provider routing details and host paths are intentionally absent. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum WorkingDirectorySource { @@ -1623,7 +5078,7 @@ pub enum WorkingDirectorySource { } /// Public, provider-neutral Workdir inventory projection. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] @@ -1647,6 +5102,7 @@ pub struct WorkingDirectorySummary { pub current_tree: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub observed_at_epoch_seconds: Option, pub materializer_kind: WorkingDirectoryMaterializerKind, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1775,17 +5231,18 @@ impl From for WorkingDirectorySumma } } -#[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 ExternalWorkdirGrantCreateRequest { pub provider_instance_id: String, pub display_name: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub ttl_seconds: u64, pub read_only: bool, } -#[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 ExternalWorkdirGrantResponse { @@ -1796,6 +5253,7 @@ pub struct ExternalWorkdirGrantResponse { pub display_name: String, pub permissions: String, pub expires_at: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub generation: u64, pub status: String, } @@ -1805,7 +5263,7 @@ pub struct ExternalWorkdirGrantResponse { /// `runtime_id = None` requests Workspace default Runtime resolution and /// `operation_id = Some(_)` fences exact replay. All four fields deliberately /// preserve the Server's existing optionality. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] @@ -1822,7 +5280,7 @@ pub struct WorkingDirectoryCreateRequest { pub operation_id: Option, } -#[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 WorkingDirectoryListResponse { @@ -1831,7 +5289,7 @@ pub struct WorkingDirectoryListResponse { 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 WorkingDirectoryDetailResponse { @@ -1842,7 +5300,72 @@ pub struct WorkingDirectoryDetailResponse { pub diagnostics: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct HostSummary { + pub runtime_id: String, + pub host_id: String, + pub label: String, + pub kind: String, + pub status: String, + pub observed_at: String, + pub last_seen_at: Option, + pub os: String, + pub arch: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct HostListResponse { + pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub limit: usize, + pub items: Vec, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct HostWorkerListResponse { + pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub limit: usize, + pub items: Vec, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirAttachRequest { + pub alias: String, + pub working_directory_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirAttachmentResponse { + pub workspace_id: String, + pub alias: String, + pub working_directory_id: String, + pub capabilities: workdir::WorkdirSessionCapabilities, + pub attached: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirOperationRequest { + pub target_workdir: String, + pub operation: workdir::http::WorkdirSessionOperation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct CurrentWorkerWorkdirOperationResponse(pub workdir::http::WorkdirSessionOperationResult); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkingDirectoryCreateResponse { @@ -1850,11 +5373,26 @@ pub struct WorkingDirectoryCreateResponse { pub runtime_id: String, pub item: WorkingDirectorySummary, pub diagnostics: Vec, + #[serde(skip, default = "default_workdir_create_status")] + #[cfg_attr(feature = "typescript", ts(skip))] + #[schemars(skip)] + pub http_status: u16, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +const fn default_workdir_create_status() -> u16 { + 201 +} + +impl api_macros::HttpSuccess for WorkingDirectoryCreateResponse { + fn status_code(&self) -> u16 { + self.http_status + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct ListResponse { pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, pub items: Vec, pub source: String, @@ -1862,19 +5400,28 @@ pub struct ListResponse { pub diagnostics: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct QueryPage { + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub returned: usize, pub has_more: bool, pub next_cursor: Option, pub sort: String, + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub source_limit: Option, pub source_truncated: bool, } -#[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 ObjectiveEventDetail { pub event_ref: String, pub kind: String, @@ -1882,7 +5429,9 @@ pub struct ObjectiveEventDetail { pub created_at: 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 ObjectiveLinkedTicketSummary { pub id: String, pub resource_key: String, @@ -1890,15 +5439,21 @@ pub struct ObjectiveLinkedTicketSummary { pub state: 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 ObjectiveResourceSummary { pub path: String, pub media_type: Option, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub bytes: usize, pub updated_at: 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 ObjectiveSummary { pub id: String, pub resource_key: String, @@ -1911,7 +5466,9 @@ pub struct ObjectiveSummary { pub record_source: 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 ObjectiveDetail { pub id: String, pub resource_key: String, @@ -1930,7 +5487,9 @@ pub struct ObjectiveDetail { pub record_source: 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 ObjectiveCreateRequest { pub title: String, #[serde(default)] @@ -1945,7 +5504,9 @@ fn default_objective_state() -> String { "active".to_string() } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct ObjectiveEditRequest { pub title: Option, pub old_string: Option, @@ -1954,17 +5515,1495 @@ pub struct ObjectiveEditRequest { pub replace_all: bool, } -#[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 ObjectiveStateRequest { pub state: 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 ObjectiveLinkTicketRequest { pub ticket_id: String, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +/// One malformed project record reported by a bounded list projection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct InvalidProjectRecord { + pub label: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketListItemSummary { + pub id: String, + pub resource_key: String, + pub title: String, + pub state: String, + pub priority: String, + pub updated_at: Option, + pub queued_by: Option, + pub queued_at: Option, + pub workspace_action_priority: String, + pub record_source: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketListHttpQuery { + pub states: Option, + #[schemars(range(min = 0, max = 1000))] + pub limit: Option, + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketListResponse { + pub workspace_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 1000))] + pub limit: usize, + pub items: Vec, + pub page: QueryPage, + pub invalid_records: Vec, + pub record_authority: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketEventDetail { + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub sequence: usize, + pub event_ref: String, + pub kind: String, + pub author: Option, + pub at: Option, + pub status: Option, + pub from: Option, + pub to: Option, + pub reason: Option, + pub state_field: Option, + pub heading: Option, + pub body: Option, + pub attributes: BTreeMap, + pub references: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketDetailRelation { + pub ticket_id: String, + pub kind: String, + pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub target_resource_key: Option, + pub note: Option, + pub author: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketDetailDerivedRelation { + pub source_ticket: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub source_resource_key: Option, + pub inverse_kind: String, + pub forward_kind: String, + pub note: Option, + pub author: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketDetailRelationBlocker { + pub blocking_ticket: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub blocking_resource_key: Option, + pub reason_kind: String, + pub relation_kind: String, + pub note: Option, + pub blocking_state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketDetailRelationNotice { + pub related_ticket: String, + pub kind: String, + pub message: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketDetailRelationView { + pub outgoing: Vec, + pub incoming: Vec, + pub blockers: Vec, + pub notices: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ObjectiveLinkSummary { + pub id: String, + pub resource_key: String, + pub title: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketEvidenceEvent { + pub event_ref: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub sequence: usize, + pub kind: String, + pub at: Option, + pub author: Option, + pub excerpt: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentSummary { + pub assignment_id: String, + pub runtime_id: String, + pub worker_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub worker_resource_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))] +pub enum TicketAssignmentPrincipal { + User { + account_id: String, + }, + Worker { + runtime_id: String, + worker_id: String, + }, + WorkspaceAgent { + agent_key: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum TicketAssignmentRole { + Orchestrator, + Coder, + Owner, + Contributor, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketRoleAssignmentRecord { + pub workspace_id: String, + pub ticket_id: String, + pub assignment_id: String, + pub role: TicketAssignmentRole, + pub principal: TicketAssignmentPrincipal, + pub assigned_by: String, + pub assigned_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketRoleAssignmentSummary { + pub assignment_id: String, + pub role: String, + pub principal: TicketAssignmentPrincipal, + pub assigned_by: String, + pub assigned_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketActionEligibility { + pub can_assign_orchestrator: bool, + pub can_unassign_orchestrator: bool, + pub can_queue: bool, + pub can_start_manual_coder: bool, + pub queue_tickets: Vec, + pub blockers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketMergeRequestSummary { + pub merge_request_id: String, + pub repository_key: String, + pub state: String, + pub review_status: String, + pub selector_from: Option, + pub selector_to: String, + pub updated_at: String, + pub current_subject_ref: Option, + pub review_subject_ref: Option, + pub review_requested_at: Option, + pub review_submitted_at: Option, + pub review_excerpt: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestRefDiagnostic { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestListItem { + pub summary: TicketMergeRequestSummary, + pub ticket_ids: Vec, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub thread_event_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ref_diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestListResponse { + pub items: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketEvidenceSummary { + pub has_merge_request: bool, + pub has_current_subject_ref: bool, + pub has_review_request: bool, + pub has_commit: bool, + pub review_status: Option, + pub approved_current_subject: bool, + pub review_after_rescope: bool, + pub unresolved_request_changes: bool, + pub complete_for_integration: bool, + pub missing: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketQueryRequest { + pub query: Option, + #[serde(default)] + pub states: Vec, + #[serde(default)] + pub event_kinds: Vec, + #[serde(default)] + pub evidence: Vec, + pub review_status: Option, + #[serde(default)] + pub attention: Vec, + pub related_ticket_id: Option, + pub relation_kind: Option, + pub linked_objective_id: Option, + pub updated_after: Option, + pub updated_before: Option, + pub sort: Option, + #[schemars(range(min = 0, max = 1000))] + pub limit: Option, + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketQueryItem { + pub id: String, + pub resource_key: String, + pub title: String, + pub state: String, + pub readiness: Option, + pub priority: String, + pub created_at: Option, + pub updated_at: Option, + pub item_revision: String, + pub workspace_action_priority: String, + pub matched_fields: Vec, + pub snippet: Option, + pub matching_event: Option, + pub linked_objective_ids: Vec, + pub linked_objective_keys: Vec, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub relation_count: usize, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub blocker_count: usize, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub unresolved_blocker_count: usize, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub unresolved_review_count: usize, + pub evidence: TicketEvidenceSummary, + pub merge_request: Option, + pub current_coder: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketQueryResponse { + pub items: Vec, + pub page: QueryPage, + pub record_authority: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketShowRequest { + #[schemars(range(min = 0, max = 1000))] + pub event_limit: Option, + pub event_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketDetail { + pub id: String, + pub resource_key: String, + pub title: String, + pub state: String, + pub readiness: Option, + pub priority: String, + pub created_at: Option, + pub updated_at: Option, + pub item_revision: String, + pub queued_by: Option, + pub queued_at: Option, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub targets: Vec, + pub risk_flags: Vec, + pub body: String, + pub body_truncated: bool, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub event_count: usize, + pub events: Vec, + pub event_page: QueryPage, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub artifact_count: usize, + pub artifacts: Vec, + pub relations: TicketDetailRelationView, + pub linked_objectives: Vec, + pub implementation_reports: Vec, + pub assignments: Vec, + pub current_coder: Option, + pub assignment_diagnostics: Vec, + pub action_eligibility: TicketActionEligibility, + pub merge_request: Option, + pub evidence: TicketEvidenceSummary, + pub resolution: Option, + pub record_source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DefaultIntakeReadyBodyRequest { + pub from: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct TextResponse(pub String); + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketSummarySearchQuery { + pub state: Option, + #[schemars(range(min = 0, max = 1000))] + pub limit: Option, +} + +macro_rules! transparent_ticket_dto { + ($name:ident, $inner:ty) => { + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] + #[serde(transparent)] + pub struct $name(pub $inner); + }; +} + +transparent_ticket_dto!(TicketRecord, ticket::Ticket); +transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef); +transparent_ticket_dto!(TicketRecordSummaryList, Vec); +transparent_ticket_dto!(TicketDependencyCheckResponse, ticket::TicketDependencyCheck); +transparent_ticket_dto!(TicketQueueResponse, ticket::TicketQueueOutcome); +transparent_ticket_dto!(TicketDoctorResponse, ticket::TicketDoctorReport); +transparent_ticket_dto!(TicketRelationRecord, ticket::TicketRelation); +transparent_ticket_dto!(TicketRelationRecordList, Vec); +transparent_ticket_dto!(TicketRelationRecordView, ticket::TicketRelationView); +transparent_ticket_dto!( + TicketOrchestrationPlanRecord, + ticket::OrchestrationPlanRecord +); +transparent_ticket_dto!( + TicketOrchestrationPlanRecordList, + Vec +); +transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket); +transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit); +transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent); +transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateChange); +transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary); +transparent_ticket_dto!(TicketCloseRecordRequest, ticket::MarkdownText); +transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation); +transparent_ticket_dto!( + CreateTicketOrchestrationPlanRequest, + ticket::NewOrchestrationPlanRecord +); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketMarkReadyRequest { + pub operation_key: String, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub intake_summary: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketRelationRemoveRequest { + pub kind: ticket::TicketRelationKind, + pub target: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketRelationSearchRequest { + pub ticket: Option, + pub kind: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketOrchestrationPlanSearchRequest { + pub ticket: Option, + pub kind: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SetTicketRoleAssignmentRequest { + pub operation_id: String, + pub principal: TicketAssignmentPrincipal, + pub expected_assignment_id: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ClearTicketRoleAssignmentQuery { + pub operation_id: Option, + pub assignment_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct CancelTicketImplementationRequest { + pub operation_id: String, + pub assignment_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketRoleAssignmentsResponse { + pub workspace_id: String, + pub ticket_id: String, + pub assignments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketRoleAssignmentMutationResponse { + pub workspace_id: String, + pub ticket_id: String, + pub assignment: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +#[cfg_attr(feature = "typescript", ts(tag = "action", rename_all = "snake_case"))] +pub enum BrowserTicketTargetEdit { + Set { + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + targets: Vec, + }, + Clear, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum BrowserTicketWorkflowState { + Planning, + Ready, + Queued, + Inprogress, + Done, + Closed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct BrowserEditTicketRequest { + pub title: Option, + pub body: Option, + pub old_string: Option, + pub new_string: Option, + #[serde(default)] + pub replace_all: bool, + pub target: Option, + pub author: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct BrowserTransitionTicketStateRequest { + pub state: BrowserTicketWorkflowState, + pub reason: Option, + pub body: Option, + pub author: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum BrowserTicketThreadRole { + Comment, + Plan, + Decision, + ImplementationReport, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct BrowserAppendTicketEventRequest { + pub role: BrowserTicketThreadRole, + pub body: String, + pub author: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct BrowserQueueTicketRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct BrowserCloseTicketRequest { + pub resolution: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ObjectiveListQuery { + #[schemars(range(min = 0, max = 1000))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ObjectiveListResponse { + pub workspace_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 1000))] + pub limit: usize, + pub items: Vec, + pub invalid_records: Vec, + pub record_authority: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ObjectiveQueryRequest { + pub query: Option, + #[serde(default)] + pub states: Vec, + pub linked_ticket_id: Option, + pub updated_after: Option, + pub updated_before: Option, + pub sort: Option, + #[schemars(range(min = 0, max = 1000))] + pub limit: Option, + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ObjectiveQueryItem { + pub id: String, + pub resource_key: String, + pub title: String, + pub state: String, + pub created_at: Option, + pub updated_at: Option, + pub matched_fields: Vec, + pub snippet: Option, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub linked_ticket_count: usize, + pub linked_tickets: Vec, + pub linked_ticket_keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ObjectiveQueryResponse { + pub items: Vec, + pub page: QueryPage, + pub record_authority: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ObjectiveShowRequest { + #[schemars(range(min = 0, max = 1000))] + pub event_limit: Option, + pub event_cursor: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum MergeRequestState { + Open, + Merged, + Closed, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum ReviewDecision { + Approve, + RequestChanges, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum FindingSeverity { + Blocker, + Major, + Minor, + Note, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ReviewFinding { + pub severity: FindingSeverity, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub line: Option, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestWorkerIdentity { + pub runtime_id: String, + pub worker_id: String, +} + +macro_rules! merge_request_event { + ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => { + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] + #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] + #[serde(deny_unknown_fields)] + pub struct $name { + pub event_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub sequence: u64, + $(pub $field: $ty,)* + pub created_at: String, + } + }; +} + +merge_request_event!(ReviewRequestedEvent { + subject_ref: String, + requested_by: MergeRequestWorkerIdentity, + reviewer: MergeRequestWorkerIdentity, +}); +merge_request_event!(ReviewEvent { + request_event_id: String, + subject_ref: String, + decision: ReviewDecision, + body: String, + findings: Vec, + reviewer: MergeRequestWorkerIdentity, +}); +merge_request_event!(ReviewRevokedEvent { + review_event_id: String, + subject_ref: String, + reason: String, + revoked_by: MergeRequestWorkerIdentity, +}); +merge_request_event!(ReviewCancelledEvent { + request_event_id: String, + subject_ref: String, + reason: String, +}); +merge_request_event!(MergeRequestCommentEvent { + body: String, + author: MergeRequestWorkerIdentity, +}); + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum MergeStrategy { + FastForward, + Merge, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum ConflictResolution { + None, + Clean, + ConflictsResolved, +} + +merge_request_event!(MergeEvent { + operation_id: String, + approval_event_id: String, + approved_source_ref: String, + target_ref_before: String, + target_ref_after: String, + strategy: MergeStrategy, + resolution: ConflictResolution, + merged_by: MergeRequestWorkerIdentity, +}); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MergeRequestThreadEvent { + ReviewRequested(ReviewRequestedEvent), + Review(ReviewEvent), + ReviewRevoked(ReviewRevokedEvent), + ReviewCancelled(ReviewCancelledEvent), + Comment(MergeRequestCommentEvent), + Merge(MergeEvent), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PublicMergeRequest { + pub workspace_id: String, + pub merge_request_id: String, + pub repository_key: String, + pub state: MergeRequestState, + pub selector_from: Option, + pub selector_to: String, + pub ticket_ids: Vec, + pub created_at: String, + pub updated_at: String, + pub thread: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MergeRequestListQuery { + pub state: Option, + pub repository_key: Option, + pub ticket_ref: Option, + pub selector_from: Option, + pub selector_to: Option, + pub cursor: Option, + #[schemars(range(min = 0, max = 1000))] + pub limit: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MergeRequestThreadQuery { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub after: Option, + #[schemars(range(min = 0, max = 1000))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestRefResponse { + pub status: String, + #[serde(rename = "ref")] + pub revision_ref: Option, + pub observed_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub diagnostic: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestLinkedTicketResponse { + pub ticket_id: String, + pub key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestDetailResponse { + #[serde(flatten)] + pub merge_request: PublicMergeRequest, + pub source: MergeRequestRefResponse, + pub target: MergeRequestRefResponse, + pub linked_tickets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MergeRequestReadinessResponse { + pub ready: bool, + pub blockers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub subject_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub review: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct MergeRequestThreadResponse(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OpenMergeRequestRequest { + pub repository_key: String, + pub selector_from: String, + pub selector_to: String, + #[serde(default)] + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RepairMergeRequestSelectorRequest { + pub selector_from: String, + pub reason: String, + pub explicit_confirmation: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RevokeMergeRequestReviewRequest { + pub review_event_id: String, + pub reason: String, + pub explicit_confirmation: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RegisterReviewerChildSessionRequest { + pub child_session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RegisterMergeRequestReviewCapabilityRequest { + pub child_session_id: String, + pub capability_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubmitMergeRequestReviewRequest { + pub capability_token: String, + pub decision: ReviewDecision, + #[serde(default)] + pub body: String, + #[serde(default)] + pub findings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CompleteMergeRequestRequest { + pub operation_id: String, + pub approval_event_id: String, + pub target_ref_before: String, + pub target_ref_after: String, + pub strategy: MergeStrategy, + pub resolution: ConflictResolution, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeManagementApiError { + pub error: String, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub current_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_fingerprint: Option, + #[serde(skip, default = "default_repository_error_status")] + status: u16, +} + +impl RuntimeManagementApiError { + pub fn trust_conflict(conflict: RuntimeTrustConflictResponse) -> Self { + Self { + error: match conflict.error { + RuntimeTrustConflictKind::StaleRevision => "stale_revision".to_string(), + RuntimeTrustConflictKind::FingerprintInUse => "fingerprint_in_use".to_string(), + }, + message: conflict.message, + diagnostics: Vec::new(), + current_revision: conflict.current_revision, + current_fingerprint: conflict.current_fingerprint, + status: 409, + } + } +} + +impl api_macros::HttpError for RuntimeManagementApiError { + fn status_code(&self) -> u16 { + self.status + } +} + +impl api_macros::HttpRequestError for RuntimeManagementApiError { + fn from_request_rejection(status: u16, message: String) -> Self { + Self::from(RepositoryApiError::from_request_rejection(status, message)) + } +} + +impl From for RuntimeManagementApiError { + fn from(error: RepositoryApiError) -> Self { + Self { + error: error.error, + message: error.message, + diagnostics: error.diagnostics, + current_revision: None, + current_fingerprint: None, + status: error.status, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeListResponse { + pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub limit: usize, + pub items: Vec, + pub source: String, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeListResponse { + pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub limit: usize, + pub items: Vec, + pub source: String, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerListResponse { + pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub limit: usize, + pub items: Vec, + pub source: String, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkersQuery { + pub status: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeWorkersStatusFilter { + Stopped, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde( + tag = "kind", + content = "value", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum RuntimeProfileSelector { + Builtin(String), + Named(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuntimeWorkerSpawnIntent { + WorkspaceCompanion, + WorkspaceOrchestrator, + WorkspaceCoding, + TicketRole { + ticket_id: String, + role: RuntimeTicketWorkerRole, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTicketWorkerRole { + Intake, + Orchestrator, + Coder, + Reviewer, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuntimeWorkerSpawnAcceptanceRequirement { + SocketReady, + RunAccepted { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + expected_segments: usize, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerTicketAssignmentRequest { + pub ticket_id: String, + pub operation_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerSpawnWorkingDirectoryRequest { + pub repository_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerSpawnWorkingDirectoryAttachmentRequest { + pub alias: String, + pub working_directory: RuntimeWorkerSpawnWorkingDirectoryRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerSpawnRequest { + pub intent: RuntimeWorkerSpawnIntent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_worker_name: Option, + pub acceptance: RuntimeWorkerSpawnAcceptanceRequirement, + pub profile: RuntimeProfileSelector, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ticket_assignment: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub initial_submit: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workdir_attachment_requests: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerSummary { + pub runtime_id: String, + pub worker_id: String, + pub host_id: String, + #[serde(default)] + pub display_name: String, + pub label: String, + pub profile: Option, + pub singleton_key: Option, + #[serde(default)] + pub tags: Vec, + pub workspace: WorkerWorkspaceSummary, + pub state: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_state: Option, + pub last_seen_at: Option, + #[serde(default)] + pub pinned: bool, + #[serde(default)] + pub retention_state: String, + pub implementation: WorkerImplementationSummary, + pub capabilities: WorkerCapabilitySummary, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workdir_attachments: Vec, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerShowResponse { + #[serde(flatten)] + pub worker: WorkerSummary, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerSpawnResponse { + pub state: WorkerOperationState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker: Option, + #[serde(default)] + pub acceptance_evidence: Vec, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerSpawnAcceptanceEvidence { + pub kind: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfigBundleSyncRequest { + pub bundle: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfigBundleAvailabilityQuery { + pub digest: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfigBundleResult { + pub state: WorkerOperationState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub availability: Option, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerInputRequest { + #[serde(default)] + pub kind: Option, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub segments: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerInputResult { + pub state: WorkerOperationState, + pub runtime_id: String, + pub worker_id: String, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerLifecycleRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ticket_assignment: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerLifecycleResult { + pub state: WorkerOperationState, + pub runtime_id: String, + pub worker_id: String, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerCompletionsRequest { + pub kind: serde_json::Value, + #[serde(default)] + pub prefix: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkerCompletionsResult { + pub runtime_id: String, + pub worker_id: String, + pub kind: serde_json::Value, + pub prefix: String, + pub entries: Vec, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RestoreTicketAssignmentQuery { + pub ticket_id: Option, + pub assignment_operation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CleanupTargetKind { + WorkerDelete, + WorkdirCleanCleanup, + WorkdirDirtyDiscard, + WorkdirRecordDelete, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CleanupWorkdirFileStatus { + Pending, + Present, + Active, + CleanupPending, + NotFound, + Corrupted, + Failed, + Unknown, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CleanupWorkdirCleanliness { + Clean, + Dirty, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CleanupWorkerCandidate { + pub target_id: String, + pub action: CleanupTargetKind, + pub worker_id: String, + pub runtime_worker_id: String, + pub runtime_id: String, + pub reason: String, + pub blocking_reason: Option, + pub pinned: bool, + pub retention_state: String, + pub linked_workdir_ids: Vec, + pub running_linked: bool, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub estimated_reclaim_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CleanupWorkdirCandidate { + pub target_id: String, + pub action: CleanupTargetKind, + pub workdir_id: String, + pub runtime_id: String, + pub repository_key: String, + pub reason: String, + pub blocking_reason: Option, + pub linked_worker_ids: Vec, + pub linked_running_worker_ids: Vec, + pub running_linked: bool, + pub pinned_linked: bool, + pub file_status: CleanupWorkdirFileStatus, + pub cleanliness: CleanupWorkdirCleanliness, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub estimated_reclaim_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeCleanupPlanResponse { + pub workspace_id: String, + pub runtime_id: String, + pub generated_at: String, + pub revision: String, + pub digest: String, + pub workers: Vec, + pub workdirs: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ExecuteRuntimeCleanupRequest { + pub expected_plan_revision: String, + pub expected_plan_digest: String, + #[serde(default)] + pub worker_target_ids: Vec, + #[serde(default)] + pub workdir_target_ids: Vec, + #[serde(default)] + pub confirm_dirty_discard_target_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeCleanupExecutionResult { + pub target_id: String, + pub action: CleanupTargetKind, + pub status: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeCleanupExecutionResponse { + pub workspace_id: String, + pub runtime_id: String, + pub executed_at: String, + pub results: Vec, + pub plan_after: RuntimeCleanupPlanResponse, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerRetentionResponse { + pub workspace_id: String, + pub runtime_id: String, + pub worker_id: String, + pub pinned: bool, + pub retention_state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerFileUploadQuery { + pub file_name: String, + pub media_type: String, + #[serde(default)] + pub upload_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerFileUploadResponse { + pub file: protocol::UploadedFileRef, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerFileDeleteResponse { + pub deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AttachmentUploadGrantResponse { + pub upload_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub expires_at_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AttachmentUploadCancelResponse { + pub cancelled: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeSourceKind { @@ -1972,7 +7011,7 @@ pub enum RuntimeSourceKind { RemoteHttp, } -#[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 RuntimeSourceStatus { @@ -1980,7 +7019,7 @@ pub enum RuntimeSourceStatus { Reserved, } -#[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 RuntimeIdentityAuthority { @@ -1988,7 +7027,7 @@ pub enum RuntimeIdentityAuthority { ServerRuntimeConfiguration, } -#[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 RuntimeSourceSummary { @@ -1998,7 +7037,7 @@ pub struct RuntimeSourceSummary { pub note: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RuntimeSummary { pub runtime_id: String, @@ -2015,7 +7054,7 @@ pub struct RuntimeSummary { pub diagnostics: Vec, } -#[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 WorkspaceRuntimeBindingState { @@ -2024,7 +7063,7 @@ pub enum WorkspaceRuntimeBindingState { Revoked, } -#[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 RuntimeConnectionDisplayState { @@ -2034,7 +7073,7 @@ pub enum RuntimeConnectionDisplayState { Revoked, } -#[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 RuntimeVerificationOutcome { @@ -2044,7 +7083,7 @@ pub enum RuntimeVerificationOutcome { ConnectivityFailed, } -#[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 RuntimeVerificationEvidenceSummary { @@ -2052,35 +7091,41 @@ pub struct RuntimeVerificationEvidenceSummary { pub last_checked_at: String, pub last_outcome: RuntimeVerificationOutcome, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub binding_revision: u64, pub workspace_key_id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub workspace_identity_revision: u64, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub workspace_trust_generation: u64, pub runtime_public_key_fingerprint: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub runtime_identity_revision: u64, } -#[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 WorkspaceRuntimeBindingSummary { pub state: WorkspaceRuntimeBindingState, pub connection_state: RuntimeConnectionDisplayState, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub revision: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_key_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub workspace_key_generation: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub verification: Option, } -#[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 RuntimeManagementSummary { @@ -2093,15 +7138,29 @@ pub struct RuntimeManagementSummary { pub binding: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkspaceRuntimeResource { #[serde(flatten)] pub runtime: RuntimeSummary, pub management: RuntimeManagementSummary, + #[serde(skip, default = "default_runtime_mutation_status")] + #[cfg_attr(feature = "typescript", ts(skip))] + #[schemars(skip)] + pub http_status: u16, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +const fn default_runtime_mutation_status() -> u16 { + 200 +} + +impl api_macros::HttpSuccess for WorkspaceRuntimeResource { + fn status_code(&self) -> u16 { + self.http_status + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeTrustKeyStatus { @@ -2110,7 +7169,7 @@ pub enum RuntimeTrustKeyStatus { Revoked, } -#[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 RuntimeTrustKeyState { @@ -2119,6 +7178,7 @@ pub struct RuntimeTrustKeyState { pub fingerprint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub revision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub created_at: Option, @@ -2128,7 +7188,7 @@ pub struct RuntimeTrustKeyState { pub revoked_at: Option, } -#[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 RuntimeTrustAuditAction { @@ -2138,7 +7198,7 @@ pub enum RuntimeTrustAuditAction { Revoked, } -#[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 RuntimeTrustAuditEntry { @@ -2149,11 +7209,12 @@ pub struct RuntimeTrustAuditEntry { #[serde(default, skip_serializing_if = "Option::is_none")] pub new_fingerprint: Option, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub revision: u64, pub at: 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 WorkspaceRuntimeDetail { @@ -2166,31 +7227,33 @@ pub struct WorkspaceRuntimeDetail { pub recent_audit: 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 RuntimeTrustKeyRevealResponse { pub public_key: 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 RevokeRuntimeTrustKeyRequest { #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_revision: u64, } -#[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 RemoveRuntimeRequest { pub operation_id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_binding_revision: u64, } -#[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 RuntimeRemovalOperationState { @@ -2200,7 +7263,7 @@ pub enum RuntimeRemovalOperationState { Failed, } -#[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 RuntimeRemovalOperationResponse { @@ -2218,7 +7281,7 @@ pub struct RuntimeRemovalOperationResponse { pub completed_at: Option, } -#[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 RuntimeTrustConflictKind { @@ -2226,7 +7289,7 @@ pub enum RuntimeTrustConflictKind { FingerprintInUse, } -#[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 RuntimeTrustConflictResponse { @@ -2234,12 +7297,13 @@ pub struct RuntimeTrustConflictResponse { pub message: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub current_revision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_fingerprint: Option, } -#[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 RuntimePublicIdentityBundle { @@ -2247,7 +7311,7 @@ pub struct RuntimePublicIdentityBundle { pub public_key: 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 CreateRemoteRuntimeRequest { @@ -2257,10 +7321,11 @@ pub struct CreateRemoteRuntimeRequest { pub endpoint: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_revision: Option, } -#[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 UpdateRemoteRuntimeRequest { @@ -2269,7 +7334,7 @@ pub struct UpdateRemoteRuntimeRequest { pub endpoint: 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(rename_all = "snake_case")] pub enum RuntimeConnectionTestStatus { @@ -2277,7 +7342,7 @@ pub enum RuntimeConnectionTestStatus { Failed, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeConnectionTestFailureKind { @@ -2292,27 +7357,353 @@ pub enum RuntimeConnectionTestFailureKind { Configuration, } -#[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 RuntimeConnectionTestResponse { pub workspace_id: String, pub runtime_id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub binding_revision: u64, pub connection_state: RuntimeConnectionDisplayState, pub verification: Option, pub checked_at: String, pub status: RuntimeConnectionTestStatus, pub failure_kind: Option, + #[schemars(range(min = 0, max = 4_294_967_295_u32))] pub expected_protocol_version: u32, + #[schemars(range(min = 0, max = 4_294_967_295_u32))] pub actual_protocol_version: Option, #[serde(default)] pub diagnostics: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerControlSubject { + pub runtime_id: String, + pub worker_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerControlRecord { + pub subject: WorkerControlSubject, + pub relation: String, + pub origin: String, + pub permissions: Vec, + pub summary: WorkerLaunchWorkerSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerControlListResponse { + pub workspace_id: String, + pub items: Vec, + pub truncated: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceWorkerDiscoveryQuery { + pub cursor: Option, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub limit: Option, + pub query: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum WorkerObservationSubjectRef { + RuntimeWorker { + runtime_id: String, + worker_id: String, + }, + SubWorker { + name: String, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkerObservationSubject { + pub subject: WorkerObservationSubjectRef, + pub display_name: String, + pub relation: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkerObservationSessionsResponse { + pub sessions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorkerObservationCaptureResponse { + pub segment_id: String, + pub session: protocol::SessionSnapshot, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CompanionTranscriptQuery { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub start: Option, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerRemoveRequest { + pub target_runtime_id: String, + pub target_worker_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerRemoveResponse { + pub removed: bool, + pub runtime_id: String, + pub worker_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerRemoveApiError { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diagnostics: Option>, + #[serde(skip, default = "default_repository_error_status")] + #[schemars(skip)] + status: u16, +} + +impl WorkerRemoveApiError { + pub fn new( + status: u16, + error: Option, + code: Option, + message: Option, + diagnostics: Option>, + ) -> Self { + Self { + error, + code, + message, + diagnostics, + status, + } + } +} + +impl std::fmt::Display for WorkerRemoveApiError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str( + self.message + .as_deref() + .or(self.error.as_deref()) + .or(self.code.as_deref()) + .unwrap_or("Worker removal failed"), + ) + } +} + +impl std::error::Error for WorkerRemoveApiError {} +impl api_macros::HttpError for WorkerRemoveApiError { + fn status_code(&self) -> u16 { + self.status + } +} +impl api_macros::HttpRequestError for WorkerRemoveApiError { + fn from_request_rejection(status: u16, message: String) -> Self { + Self::new( + status, + Some("invalid_worker_remove".to_string()), + None, + Some(message), + None, + ) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeResourceKind { + ProfileSourceArchive, + RepositorySshAccess, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeResourceOperation { + FetchArchive, + FetchOnce, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeResourceRedactionPolicy { + RuntimeInternalOnly, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeProfileSourceGraphSummary { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub source_count: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub total_source_bytes: u64, + pub entrypoints: BTreeMap, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub import_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeResourceHandle { + pub kind: RuntimeResourceKind, + 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: RuntimeResourceOperation, + #[schemars(range(min = -9_007_199_254_740_991_i64, max = 9_007_199_254_740_991_i64))] + pub expires_at_unix_seconds: i64, + pub nonce: String, + pub revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub generation: Option, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub max_bytes: u64, + pub content_type: String, + pub redaction: RuntimeResourceRedactionPolicy, + pub audit_correlation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_graph: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeResourceFetchRequest { + pub handle: RuntimeResourceHandle, + pub runtime_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub audit_correlation_id: String, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RuntimeResourceFetchResponse { + pub kind: RuntimeResourceKind, + pub resource_id: String, + pub digest: String, + pub content_type: String, + pub bytes: Vec, + pub audit_correlation_id: String, +} + +impl std::fmt::Debug for RuntimeResourceFetchResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RuntimeResourceFetchResponse") + .field("kind", &self.kind) + .field("resource_id", &self.resource_id) + .field("digest", &self.digest) + .field("content_type", &self.content_type) + .field( + "bytes", + &format_args!("[REDACTED; {} bytes]", self.bytes.len()), + ) + .field("audit_correlation_id", &self.audit_correlation_id) + .finish() + } +} +impl Drop for RuntimeResourceFetchResponse { + fn drop(&mut self) { + self.bytes.fill(0); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum RuntimeResourceFetchApiError { + Expired, + Unauthorized { + message: String, + }, + UnsupportedKind, + MissingResource, + DigestMismatch { + expected: String, + actual: String, + }, + Oversized { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + max_bytes: u64, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + actual_bytes: u64, + }, + ContentTypeMismatch { + expected: String, + actual: String, + }, + Timeout, + Transport { + message: String, + }, + InvalidResponse { + message: String, + }, +} + +impl std::fmt::Display for RuntimeResourceFetchApiError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "Runtime resource fetch failed: {self:?}") + } +} +impl std::error::Error for RuntimeResourceFetchApiError {} +impl api_macros::HttpError for RuntimeResourceFetchApiError { + fn status_code(&self) -> u16 { + match self { + Self::Expired => 410, + Self::Unauthorized { .. } => 401, + Self::MissingResource => 404, + Self::Timeout => 504, + Self::Transport { .. } => 502, + Self::UnsupportedKind + | Self::DigestMismatch { .. } + | Self::Oversized { .. } + | Self::ContentTypeMismatch { .. } + | Self::InvalidResponse { .. } => 400, + } + } +} +impl api_macros::HttpRequestError for RuntimeResourceFetchApiError { + fn from_request_rejection(_status: u16, message: String) -> Self { + Self::InvalidResponse { message } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkerWorkspaceSummary { pub visibility: String, pub identity: String, @@ -2320,23 +7711,23 @@ pub struct WorkerWorkspaceSummary { pub workspace_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkerImplementationSummary { pub kind: String, pub display_hint: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkerCapabilitySummary { pub can_stop: bool, pub can_spawn_followup: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[serde(tag = "kind", rename_all = "snake_case")] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum WorkspaceWorkerSubject { RuntimeWorker { runtime_id: String, @@ -2347,8 +7738,9 @@ pub enum WorkspaceWorkerSubject { /// Bounded, model-safe projection used by privileged Workspace Worker discovery. /// Runtime placement appears only in the typed subject required by Worker /// control operations; provider and launch internals are intentionally omitted. -#[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 WorkspaceWorkerDiscoveryItem { pub subject: WorkspaceWorkerSubject, pub resource_key: String, @@ -2359,7 +7751,7 @@ pub struct WorkspaceWorkerDiscoveryItem { } /// Public lifecycle projection for the Workspace Companion endpoint. -#[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 CompanionLifecycleState { @@ -2369,7 +7761,7 @@ pub enum CompanionLifecycleState { } /// Public outcome of a Companion message submission. -#[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 CompanionMessageDisposition { @@ -2378,7 +7770,7 @@ pub enum CompanionMessageDisposition { } /// Public, bounded transport metadata for Companion status. -#[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 CompanionTransportSummary { @@ -2387,7 +7779,7 @@ pub struct CompanionTransportSummary { } /// Public Workspace Companion status response. -#[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 CompanionStatusResponse { @@ -2399,7 +7791,7 @@ pub struct CompanionStatusResponse { } /// Public Workspace Companion message request. -#[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 CompanionMessageRequest { @@ -2407,7 +7799,7 @@ pub struct CompanionMessageRequest { } /// Public Workspace Companion cancellation request. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct CompanionCancelRequest { @@ -2416,7 +7808,7 @@ pub struct CompanionCancelRequest { } /// Public Workspace Companion message response. -#[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 CompanionMessageResponse { @@ -2425,7 +7817,7 @@ pub struct CompanionMessageResponse { } /// User-visible role accepted in the public Companion transcript. -#[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 CompanionTranscriptRole { @@ -2434,10 +7826,11 @@ pub enum CompanionTranscriptRole { } /// One allowlisted, user-visible Companion transcript item. -#[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 CompanionTranscriptItem { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub sequence: usize, pub role: CompanionTranscriptRole, pub content: String, @@ -2445,14 +7838,18 @@ pub struct CompanionTranscriptItem { } /// Bounded public Companion transcript projection. -#[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 CompanionTranscriptProjection { pub state: CompanionLifecycleState, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub start: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub total: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub next: Option, pub items: Vec, } @@ -2489,7 +7886,7 @@ pub fn companion_api_typescript() -> String { ) } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct WorkspaceWorkerDiscoveryPage { pub workers: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2498,7 +7895,7 @@ pub struct WorkspaceWorkerDiscoveryPage { /// One Workspace Worker Workdir attachment. `alias` is the stable Worker-local /// routing key; the nested Workdir id and display name are inventory metadata. -#[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 WorkerWorkdirAttachmentSummary { @@ -2506,7 +7903,7 @@ pub struct WorkerWorkdirAttachmentSummary { pub working_directory: WorkingDirectorySummary, } -#[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 RuntimeWorkerWorkdirAttachmentSummary { @@ -2519,7 +7916,7 @@ pub struct RuntimeWorkerWorkdirAttachmentSummary { /// `resource_key` is required here even though Runtime-internal Worker summaries /// do not carry one. The Workspace Server must resolve it from Workspace /// authority before constructing this response. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkerSummary { pub runtime_id: String, @@ -2553,11 +7950,25 @@ pub struct WorkerSummary { pub diagnostics: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkerListResponse { + pub workspace_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub limit: usize, + pub items: Vec, + pub source: String, + #[serde(default)] + pub diagnostics: Vec, +} + /// Runtime-owned Worker summary embedded in Worker launch responses. /// /// This preserves the existing launch wire shape. Workspace-owned Worker list /// and detail responses use [`WorkerSummary`] instead. -#[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 WorkerLaunchWorkerSummary { @@ -2582,7 +7993,7 @@ pub struct WorkerLaunchWorkerSummary { 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 WorkerLaunchOptionsResponse { @@ -2595,7 +8006,7 @@ pub struct WorkerLaunchOptionsResponse { 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 WorkerLaunchRuntimeOption { @@ -2608,7 +8019,7 @@ pub struct WorkerLaunchRuntimeOption { 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 WorkerLaunchProfileCandidate { @@ -2617,7 +8028,7 @@ pub struct WorkerLaunchProfileCandidate { pub description: 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 WorkingDirectoryRepositoryOption { @@ -2627,7 +8038,7 @@ pub struct WorkingDirectoryRepositoryOption { pub default_selector: Option, } -#[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 BrowserWorkerWorkingDirectorySelection { @@ -2638,7 +8049,7 @@ pub struct BrowserWorkerWorkingDirectorySelection { pub relative_cwd: Option, } -#[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 CreateWorkspaceWorkerTicketAssignmentRequest { @@ -2646,7 +8057,7 @@ pub struct CreateWorkspaceWorkerTicketAssignmentRequest { pub operation_id: 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 CreateWorkspaceWorkerRequest { @@ -2665,7 +8076,7 @@ pub struct CreateWorkspaceWorkerRequest { pub control_operation_id: Option, } -#[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 BrowserCreateWorkerResponse { @@ -2677,7 +8088,7 @@ pub struct BrowserCreateWorkerResponse { 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 BrowserWorkspaceOrchestratorResponse { @@ -2690,7 +8101,7 @@ pub struct BrowserWorkspaceOrchestratorResponse { pub diagnostics: Vec, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum WorkerOperationState { Accepted, @@ -2703,7 +8114,7 @@ pub enum WorkerOperationState { /// `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)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum WorkerRestoreState { @@ -2713,7 +8124,7 @@ pub enum WorkerRestoreState { ReconciliationRequired, } -#[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 WorkerRestoreResult { @@ -2725,7 +8136,7 @@ pub struct WorkerRestoreResult { 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 WorkerRestoreResponse { @@ -2735,20 +8146,27 @@ pub struct WorkerRestoreResponse { pub result: WorkerRestoreResult, } +pub const MEMORY_API_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024; +pub const MEMORY_API_MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024; +pub const MEMORY_API_MAX_COLLECTION_ITEMS: usize = 500; +pub const MEMORY_API_MAX_STRING_BYTES: usize = 1024 * 1024; +pub const MEMORY_API_MAX_IDENTIFIER_BYTES: usize = 512; + /// Public Workspace Memory document projection. -#[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 MemoryDocumentResponse { pub body_md: String, pub created_at: String, pub updated_at: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub bytes: usize, pub record_source: String, } /// Candidate kinds exposed by the Memory staging resource. -#[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 MemoryCandidateKind { @@ -2761,7 +8179,7 @@ pub enum MemoryCandidateKind { } /// Typed, bounded provenance classification for public Memory evidence anchors. -#[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 MemoryEvidenceOriginKind { @@ -2779,7 +8197,7 @@ pub enum MemoryEvidenceOriginKind { /// /// This is provenance only. It carries no message body, prompt, reasoning, /// secret, tool output, or authorization authority. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] #[serde(deny_unknown_fields)] @@ -2799,27 +8217,30 @@ pub struct MemoryEvidenceOrigin { pub flow_definition_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub flow_definition_revision: Option, } /// Record-level source range for one Memory staging candidate. -#[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 MemorySourceRef { pub segment_id: String, #[cfg_attr(feature = "typescript", ts(type = "[number, number]"))] + #[schemars(with = "JsonSafeU64Pair")] pub range: [u64; 2], } /// Bounded evidence snippet included in one Memory staging record. -#[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 MemoryStagingEvidence { pub id: String, pub kind: String, #[cfg_attr(feature = "typescript", ts(type = "[number, number] | null"))] + #[schemars(with = "Option")] pub entry_range: Option<[u64; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr( @@ -2832,13 +8253,14 @@ pub struct MemoryStagingEvidence { } /// Bounded source anchor included in one Memory staging record. -#[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 MemorySourceEvidenceRef { pub session_id: Option, pub segment_id: Option, #[cfg_attr(feature = "typescript", ts(type = "[number, number] | null"))] + #[schemars(with = "Option")] pub entry_range: Option<[u64; 2]>, pub evidence_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2853,7 +8275,7 @@ pub struct MemorySourceEvidenceRef { } /// Public projection of one valid Memory staging record. -#[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 MemoryStagingRecord { @@ -2870,24 +8292,29 @@ pub struct MemoryStagingRecord { } /// Public list entry for one valid Memory staging record. -#[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 MemoryStagingEntry { pub id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub byte_len: u64, pub record: MemoryStagingRecord, } /// Public response returned by the Workspace Memory staging list resource. -#[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 MemoryStagingListResponse { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub returned_count: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub total_valid_count: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub invalid_count: usize, pub truncated: bool, pub order: String, @@ -2916,8 +8343,11 @@ pub fn memory_api_typescript() -> String { MemoryStagingListResponse::decl(&config), ]; + let limits = format!( + "export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;" + ); format!( - "// 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", + "// 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{limits}\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -2927,18 +8357,20 @@ pub fn memory_api_typescript() -> String { } /// Workspace-owned Memory settings returned by the shared Server API. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkspaceMemorySettings { pub workspace_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub settings_revision: u64, pub language: String, } /// Compare-and-swap update for Workspace-owned Memory settings. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpdateWorkspaceMemorySettingsRequest { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_revision: u64, pub language: String, } @@ -2946,7 +8378,7 @@ pub struct UpdateWorkspaceMemorySettingsRequest { /// Public metadata for one Workspace-scoped Repository SSH credential. /// /// Secret references and secret material are deliberately not part of this DTO. -#[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 RepositorySshCredential { @@ -2956,6 +8388,7 @@ pub struct RepositorySshCredential { pub public_key_algorithm: String, pub public_key_fingerprint: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub current_revision: u64, pub status: String, pub created_at: String, @@ -2964,7 +8397,11 @@ pub struct RepositorySshCredential { pub referenced_repositories: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct RepositorySshCredentialListResponse(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct CreateRepositorySshCredentialRequest { @@ -2976,7 +8413,7 @@ pub struct CreateRepositorySshCredentialRequest { pub passphrase: Option, } -#[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 GenerateRepositorySshCredentialRequest { @@ -2985,41 +8422,44 @@ pub struct GenerateRepositorySshCredentialRequest { pub name: 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 RepositorySshPublicKey { pub credential_id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub current_revision: u64, pub public_key_algorithm: String, pub public_key_fingerprint: String, pub public_key: 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 RotateRepositorySshCredentialRequest { pub operation_id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_revision: u64, pub private_key: String, #[serde(default)] pub passphrase: Option, } -#[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 DeleteRepositorySshCredentialRequest { pub operation_id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_revision: u64, } /// Public metadata for an explicitly pinned SSH host key. -#[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 RepositorySshHostTrust { @@ -3031,6 +8471,7 @@ pub struct RepositorySshHostTrust { pub host_key: String, pub fingerprint: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub current_revision: u64, pub created_at: String, pub updated_at: String, @@ -3038,7 +8479,11 @@ pub struct RepositorySshHostTrust { pub referenced_repositories: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct RepositorySshHostTrustListResponse(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PutRepositorySshHostTrustRequest { @@ -3049,19 +8494,37 @@ pub struct PutRepositorySshHostTrustRequest { pub host_key: String, #[serde(default)] #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_revision: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RepositorySshHostTrustMutationResponse { + #[serde(flatten)] + pub host_trust: RepositorySshHostTrust, + #[serde(skip)] + #[schemars(skip)] + pub created: bool, +} + +impl api_macros::HttpSuccess for RepositorySshHostTrustMutationResponse { + fn status_code(&self) -> u16 { + if self.created { 201 } else { 200 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct DeleteRepositorySshHostTrustRequest { pub operation_id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub expected_revision: u64, } -#[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))] #[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))] #[serde(rename_all = "snake_case")] @@ -3070,7 +8533,7 @@ pub enum RepositoryAccessMode { ReadWrite, } -#[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 RepositorySshAccessBinding { @@ -3081,12 +8544,13 @@ pub struct RepositorySshAccessBinding { } /// Secret-free active Repository access projection consumed by later Runtime work. -#[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 RepositoryAccessProjection { pub workspace_id: String, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub config_revision: u64, pub projection_digest: String, pub bindings: Vec, @@ -3106,7 +8570,7 @@ pub const SKILL_API_MAX_PATH_BYTES: usize = 1_024; pub const SKILL_API_MAX_DIGEST_BYTES: usize = 128; pub const SKILL_API_MAX_RESPONSE_BYTES: usize = 2_097_152; -#[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))] #[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))] #[serde(rename_all = "snake_case")] @@ -3115,7 +8579,7 @@ pub enum SkillDiagnosticSeverity { Warning, } -#[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 SkillDiagnostic { @@ -3155,7 +8619,7 @@ impl SkillDiagnostic { } } -#[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))] #[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))] #[serde(rename_all = "snake_case")] @@ -3164,7 +8628,7 @@ pub enum SkillSourceKind { Workspace, } -#[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 SkillProvenance { @@ -3184,7 +8648,7 @@ pub struct SkillProvenance { pub tree_digest: Option, } -#[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))] #[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))] #[serde(rename_all = "snake_case")] @@ -3193,7 +8657,7 @@ pub enum SkillActivationStatus { Inactive, } -#[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))] #[cfg_attr(feature = "typescript", ts(rename_all = "snake_case"))] #[serde(rename_all = "snake_case")] @@ -3202,16 +8666,17 @@ pub enum SkillProjectionStatus { Invalid, } -#[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 SkillProjectionIdentity { #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub config_revision: u64, pub tree_digest: 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 SkillResourceRef { @@ -3223,7 +8688,7 @@ pub struct SkillResourceRef { pub diagnostic: Option, } -#[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 SkillCatalogEntry { @@ -3236,7 +8701,7 @@ pub struct SkillCatalogEntry { 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 SkillCatalogResponse { @@ -3246,7 +8711,7 @@ pub struct SkillCatalogResponse { 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 SkillDetailResponse { @@ -3265,6 +8730,17 @@ pub struct SkillDetailResponse { pub resources: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SkillActivationResponse { + pub name: String, + pub provenance: SkillProvenance, + #[serde(default)] + pub diagnostics: Vec, + pub body: String, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillApiValidationError { CollectionTooLarge, @@ -3536,6 +9012,16 @@ pub fn legacy_catalog_typescript() -> String { WorkspaceSigningIdentityPublic::decl(&config), WorkspacePublicIdentityBundle::decl(&config), WorkspaceSigningIdentityResponse::decl(&config), + ConfigContentType::decl(&config), + ConfigEntry::decl(&config), + ConfigTreeSnapshot::decl(&config), + ConfigTreeChange::decl(&config), + ConfigProjectionValidator::decl(&config), + ConfigSchemaContribution::decl(&config), + WorkspaceConfigSchemaBundle::decl(&config), + ToolchainContract::decl(&config), + WorkspaceConfigTreeResponse::decl(&config), + ConfigCommitRequest::decl(&config), ProfileSettingsResponse::decl(&config), WorkspaceProfileSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config), @@ -3547,36 +9033,6 @@ pub fn legacy_catalog_typescript() -> String { RepositorySshConnectionProbeResponse::decl(&config), ConfirmRepositorySshHostTrustRequest::decl(&config), RepositoryLogResponse::decl(&config), - RuntimeSourceKind::decl(&config), - RuntimeSourceStatus::decl(&config), - RuntimeIdentityAuthority::decl(&config), - RuntimeSourceSummary::decl(&config), - RuntimeSummary::decl(&config), - WorkspaceRuntimeBindingState::decl(&config), - RuntimeConnectionDisplayState::decl(&config), - RuntimeVerificationOutcome::decl(&config), - RuntimeVerificationEvidenceSummary::decl(&config), - WorkspaceRuntimeBindingSummary::decl(&config), - RuntimeManagementSummary::decl(&config), - WorkspaceRuntimeResource::decl(&config), - RuntimeTrustKeyStatus::decl(&config), - RuntimeTrustKeyState::decl(&config), - RuntimeTrustAuditAction::decl(&config), - RuntimeTrustAuditEntry::decl(&config), - WorkspaceRuntimeDetail::decl(&config), - RuntimeTrustKeyRevealResponse::decl(&config), - RevokeRuntimeTrustKeyRequest::decl(&config), - RemoveRuntimeRequest::decl(&config), - RuntimeRemovalOperationState::decl(&config), - RuntimeRemovalOperationResponse::decl(&config), - RuntimeTrustConflictKind::decl(&config), - RuntimeTrustConflictResponse::decl(&config), - RuntimePublicIdentityBundle::decl(&config), - CreateRemoteRuntimeRequest::decl(&config), - UpdateRemoteRuntimeRequest::decl(&config), - RuntimeConnectionTestStatus::decl(&config), - RuntimeConnectionTestFailureKind::decl(&config), - RuntimeConnectionTestResponse::decl(&config), ] .map(|declaration| format!("export {declaration}")); @@ -3632,6 +9088,7 @@ pub fn skill_api_typescript() -> String { SkillCatalogEntry::decl(&config), SkillCatalogResponse::decl(&config), SkillDetailResponse::decl(&config), + SkillActivationResponse::decl(&config), ]; let limits = format!( "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;" @@ -3687,35 +9144,6 @@ pub fn auth_api_typescript() -> String { ) } -#[cfg(feature = "typescript")] -pub fn workdir_api_typescript() -> String { - use ts_rs::TS; - - let config = ts_rs::Config::default(); - let declarations = [ - DiagnosticSeverity::decl(&config), - Diagnostic::decl(&config), - WorkingDirectoryMaterializerKind::decl(&config), - WorkingDirectoryStatusKind::decl(&config), - WorkingDirectoryCleanupTarget::decl(&config), - WorkingDirectoryOccupancy::decl(&config), - WorkingDirectorySource::decl(&config), - WorkingDirectorySummary::decl(&config), - WorkingDirectoryCreateRequest::decl(&config), - WorkingDirectoryListResponse::decl(&config), - WorkingDirectoryDetailResponse::decl(&config), - WorkingDirectoryCreateResponse::decl(&config), - ]; - format!( - "// 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}")) - .collect::>() - .join("\n\n") - ) -} - #[cfg(feature = "typescript")] pub fn worker_launch_api_typescript() -> String { use ts_rs::TS; @@ -3757,33 +9185,24 @@ pub fn worker_launch_api_typescript() -> String { ) } -#[cfg(all(test, feature = "typescript"))] +#[cfg(test)] mod worker_launch_typescript_tests { #[test] fn generated_worker_launch_api_contract_is_current() { - let expected = super::worker_launch_api_typescript(); - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../web/workspace/src/lib/generated/worker-launch-api.ts"); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let input = + std::fs::read_to_string(root.join(crate::repository_openapi_typescript::INPUT_PATH)) + .expect("read Server API OpenAPI artifact"); + let expected = crate::repository_openapi_typescript::generate_worker_typescript(&input) + .expect("generate Worker API TypeScript"); + let path = root.join(crate::repository_openapi_typescript::WORKER_OUTPUT_PATH); let actual = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); assert_eq!( - normalize(&actual), - normalize(&expected), - "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", + actual, expected, + "regenerate Worker API TypeScript types with `cargo run -q -p server-api --example generate_worker_launch_api_types`", ); } - - fn normalize(value: &str) -> String { - value - .chars() - .filter_map(|character| match character { - character if character.is_whitespace() => None, - ',' => Some(';'), - character => Some(character), - }) - .collect::() - .replace("=|", "=") - } } #[cfg(all(test, feature = "typescript"))] @@ -3846,42 +9265,275 @@ mod skill_typescript_tests { } } -#[cfg(all(test, feature = "typescript"))] -mod workdir_typescript_tests { - #[test] - fn generated_workdir_api_contract_is_current() { - let expected = super::workdir_api_typescript(); - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../web/workspace/src/lib/generated/workdir-api.ts"); - let actual = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); - assert_eq!( - normalize(&actual), - normalize(&expected), - "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", - ); - } - - fn normalize(value: &str) -> String { - value - .chars() - .filter_map(|character| match character { - character if character.is_whitespace() => None, - ',' => Some(';'), - character => Some(character), - }) - .collect::() - .replace("=|", "=") - } -} - #[cfg(test)] mod tests { use super::*; + + #[test] + fn browser_ticket_targets_are_one_collection_authority() { + let edit = BrowserTicketTargetEdit::Set { + targets: vec![ + ticket::TicketTarget { + repository_key: "main".to_string(), + ref_selector: Some("develop".to_string()), + access: ticket::TicketTargetAccess::ReadWrite, + }, + ticket::TicketTarget { + repository_key: "docs".to_string(), + ref_selector: None, + access: ticket::TicketTargetAccess::ReadOnly, + }, + ], + }; + assert_eq!( + serde_json::to_value(edit).unwrap(), + serde_json::json!({ + "action": "set", + "targets": [ + { + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }, + { + "repository_key": "docs", + "access": "read_only" + } + ] + }) + ); + + assert!( + serde_json::from_value::(serde_json::json!({ + "action": "set", + "repository_key": "main", + "ref_selector": "develop" + })) + .is_err(), + "the removed singular target fields must not remain an accepted authority" + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "target": { + "action": "set", + "targets": [{ + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }] + } + })) + .is_ok() + ); + } + + #[test] + fn websocket_operations_preserve_routes_and_directional_frame_authorities() { + use api_macros::{TransportMetadata, WebSocketOperation}; + + fn workspace_protocol() + where + O: WebSocketOperation< + PathParameters = (String,), + ClientToServerFrame = protocol::subscription::SubscriptionFrame, + ServerToClientFrame = protocol::subscription::SubscriptionFrame, + >, + { + } + fn external_provider() + where + O: WebSocketOperation< + PathParameters = (String, String), + ClientToServerFrame = workdir::external::ExternalWorkdirProviderFrame, + ServerToClientFrame = workdir::external::ExternalWorkdirServerFrame, + >, + { + } + fn direct_worker() + where + O: WebSocketOperation< + PathParameters = P, + ClientToServerFrame = protocol::Method, + ServerToClientFrame = protocol::Event, + >, + { + } + + workspace_protocol::(); + external_provider::(); + direct_worker::(); + direct_worker::< + server_api_operations::WorkspaceWorkerProtocolWebSocket, + (String, String, String), + >(); + + for (operation_id, path, client, server) in [ + ( + "workspace_protocol_websocket", + "/api/w/{workspace_id}/protocol/ws", + "protocol :: subscription :: SubscriptionFrame", + "protocol :: subscription :: SubscriptionFrame", + ), + ( + "external_workdir_provider_websocket", + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", + "workdir :: external :: ExternalWorkdirProviderFrame", + "workdir :: external :: ExternalWorkdirServerFrame", + ), + ( + "worker_protocol_websocket_alias", + "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + "protocol :: Method", + "protocol :: Event", + ), + ( + "workspace_worker_protocol_websocket", + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + "protocol :: Method", + "protocol :: Event", + ), + ] { + let operation = ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == operation_id) + .unwrap_or_else(|| panic!("missing WebSocket operation {operation_id}")); + assert_eq!(operation.method, HttpMethod::Get); + assert_eq!(operation.path, path); + assert!(operation.success_responses.is_empty()); + assert!(operation.error_responses.is_empty()); + assert_eq!( + operation.request_body.wire_kind, + api_macros::WireKind::Empty + ); + assert_eq!( + operation.transport, + TransportMetadata::WebSocket { + client_to_server_frame: client, + server_to_client_frame: server, + } + ); + assert!(operation_fingerprint(operation).starts_with("sha256:")); + } + + let external = *ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == "external_workdir_provider_websocket") + .expect("external provider WebSocket operation"); + let swapped = api_macros::OperationMetadata { + transport: TransportMetadata::WebSocket { + client_to_server_frame: "workdir :: external :: ExternalWorkdirServerFrame", + server_to_client_frame: "workdir :: external :: ExternalWorkdirProviderFrame", + }, + ..external + }; + assert_ne!( + operation_fingerprint(&external), + operation_fingerprint(&swapped), + "directional frame authority must participate in the operation fingerprint" + ); + + let openapi = canonical_openapi_document() + .expect("canonical OpenAPI contract must build") + .as_value() + .clone(); + for path in [ + "/api/w/{workspace_id}/protocol/ws", + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", + "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + ] { + assert!( + openapi["paths"][path].is_null(), + "WebSocket route {path} must not be projected into OpenAPI" + ); + } + } + + #[test] + fn runtime_config_response_round_trips_the_complete_config_bundle_shape() { + let fixture = serde_json::json!({ + "metadata": { + "id": "bundle-a", + "digest": "0123456789abcdef", + "revision": "revision-a", + "workspace_id": "workspace-a", + "created_at": "2026-01-01T00:00:00Z", + "provenance": { + "source": "workspace_config", + "detail": "projected" + } + }, + "profiles": [{ + "selector": { "kind": "builtin", "value": "builtin:companion" }, + "label": "Companion" + }], + "declarations": [{ + "kind": "secret_ref", + "name": "github-token", + "reference": "secret:github-token" + }], + "prompt_catalog": { + "templates": { "default": "Prompt" }, + "config_revision": 7, + "source_digest": "source-digest", + "schema_fingerprint": "schema-a", + "toolchain_fingerprint": "toolchain-a", + "catalog_digest": "catalog-a" + }, + "profile_source_archive": { + "reference": { + "id": "archive-a", + "digest": "archive-digest", + "size_bytes": 3, + "source_graph": { + "source_count": 1, + "total_source_bytes": 3, + "entrypoints": { "builtin:companion": "companion.dcdl" }, + "import_count": 0 + } + }, + "content": [1, 2, 3] + }, + "profile_source_archive_handle": { + "kind": "profile_source_archive", + "workspace_id": "workspace-a", + "scope_id": "scope-a", + "runtime_id": "runtime-a", + "worker_id": "worker-a", + "resource_id": "resource-a", + "digest": "resource-digest", + "operation": "fetch_archive", + "expires_at_unix_seconds": 1_800_000_000, + "nonce": "nonce-a", + "revision": "revision-a", + "generation": 2, + "max_bytes": 1_048_576, + "content_type": "application/vnd.yoi.profile-source-archive+tar", + "redaction": "runtime_internal_only", + "audit_correlation_id": "audit-a", + "profile_source_graph": { + "source_count": 1, + "total_source_bytes": 3, + "entrypoints": { "builtin:companion": "companion.dcdl" }, + "import_count": 0 + } + } + }); + let response: WorkspaceRuntimeConfigResponse = + serde_json::from_value(fixture.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), fixture); + + let mut unknown_nested_field = fixture; + unknown_nested_field["metadata"]["runtime_local_path"] = + serde_json::Value::String("/private/path".to_string()); + assert!( + serde_json::from_value::(unknown_nested_field).is_err() + ); + } + #[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") @@ -3904,6 +9556,316 @@ mod tests { assert_eq!(value["subject"]["kind"], "runtime_worker"); } + #[test] + fn auth_and_workspace_catalog_operations_are_in_server_api_metadata() { + let operations = ServerApiMetadata::OPERATIONS; + for (operation_id, method, path) in [ + ("health", HttpMethod::Get, "/health"), + ("auth_config", HttpMethod::Get, "/api/auth/config"), + ( + "auth_passkey_registration_options", + HttpMethod::Post, + "/api/auth/passkeys/registration/options", + ), + ( + "auth_passkey_login_options", + HttpMethod::Post, + "/api/auth/passkeys/login/options", + ), + ( + "auth_device_login_start", + HttpMethod::Post, + "/api/auth/device-login/start", + ), + ( + "auth_device_login_approve", + HttpMethod::Post, + "/api/auth/device-login/approve", + ), + ( + "auth_device_login_poll", + HttpMethod::Post, + "/api/auth/device-login/poll", + ), + ("auth_whoami", HttpMethod::Get, "/api/auth/whoami"), + ("workspace_catalog_list", HttpMethod::Get, "/api/workspaces"), + ( + "workspace_catalog_create", + HttpMethod::Post, + "/api/workspaces", + ), + ( + "workspace_deletion_preflight", + HttpMethod::Get, + "/api/workspaces/{workspace_id}/deletion", + ), + ( + "workspace_deletion_start", + HttpMethod::Post, + "/api/workspaces/{workspace_id}/deletion", + ), + ( + "workspace_deletion_get", + HttpMethod::Get, + "/api/workspace-deletions/{operation_id}", + ), + ] { + let operation = operations + .iter() + .find(|operation| operation.operation_id == operation_id) + .unwrap_or_else(|| panic!("missing ServerApi operation {operation_id}")); + assert_eq!(operation.method, method, "{operation_id}"); + assert_eq!(operation.path, path, "{operation_id}"); + } + } + + #[test] + fn ticket_objective_and_merge_request_operations_are_in_server_api_metadata() { + let operations = ServerApiMetadata::OPERATIONS; + assert_eq!( + operations + .iter() + .filter(|operation| operation.operation_id.starts_with("ticket_")) + .count(), + 37 + ); + assert_eq!( + operations + .iter() + .filter(|operation| operation.operation_id.starts_with("objective_")) + .count(), + 11 + ); + assert_eq!( + operations + .iter() + .filter(|operation| operation.operation_id.starts_with("merge_request_")) + .count(), + 11 + ); + for (operation_id, method, path) in [ + ( + "ticket_relation_query", + HttpMethod::Post, + "/api/w/{workspace_id}/tickets/relations/search", + ), + ( + "ticket_orchestration_plan_query", + HttpMethod::Post, + "/api/w/{workspace_id}/tickets/orchestration-plans/search", + ), + ( + "merge_request_complete", + HttpMethod::Post, + "/api/w/{workspace_id}/tickets/{id}/merge-request/complete", + ), + ( + "objective_ticket_unlink", + HttpMethod::Delete, + "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}", + ), + ] { + let operation = operations + .iter() + .find(|operation| operation.operation_id == operation_id) + .unwrap_or_else(|| panic!("missing ServerApi operation {operation_id}")); + assert_eq!(operation.method, method, "{operation_id}"); + assert_eq!(operation.path, path, "{operation_id}"); + } + } + + #[test] + fn workspace_configuration_domain_operations_are_in_server_api_metadata() { + let operations = ServerApiMetadata::OPERATIONS; + for (operation_id, method, path) in [ + ("workspace_current", HttpMethod::Get, "/api/workspace"), + ( + "workspace_scoped", + HttpMethod::Get, + "/api/w/{workspace_id}/workspace", + ), + ( + "workspace_metadata_settings", + HttpMethod::Get, + "/api/w/{workspace_id}/settings", + ), + ( + "workspace_metadata_settings_update", + HttpMethod::Put, + "/api/w/{workspace_id}/settings", + ), + ( + "workspace_signing_identity", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/signing-identity", + ), + ( + "workspace_signing_identity_provision", + HttpMethod::Post, + "/api/w/{workspace_id}/settings/signing-identity/provision", + ), + ( + "workspace_memory_settings", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/memory", + ), + ( + "workspace_memory_settings_update", + HttpMethod::Put, + "/api/w/{workspace_id}/settings/memory", + ), + ( + "repository_access_projection", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/repository-access", + ), + ( + "repository_ssh_credential_list", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/repository-access/credentials", + ), + ( + "repository_ssh_credential_create", + HttpMethod::Post, + "/api/w/{workspace_id}/settings/repository-access/credentials", + ), + ( + "repository_ssh_credential_generate", + HttpMethod::Post, + "/api/w/{workspace_id}/settings/repository-access/credentials/generate", + ), + ( + "repository_ssh_credential_get", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}", + ), + ( + "repository_ssh_credential_delete", + HttpMethod::Delete, + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}", + ), + ( + "repository_ssh_credential_public_key", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/public-key", + ), + ( + "repository_ssh_credential_rotate", + HttpMethod::Post, + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/rotate", + ), + ( + "repository_ssh_host_trust_list", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/repository-access/host-trusts", + ), + ( + "repository_ssh_host_trust_put", + HttpMethod::Post, + "/api/w/{workspace_id}/settings/repository-access/host-trusts", + ), + ( + "repository_ssh_host_trust_get", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/repository-access/host-trusts/{host_trust_id}", + ), + ( + "repository_ssh_host_trust_delete", + HttpMethod::Delete, + "/api/w/{workspace_id}/settings/repository-access/host-trusts/{host_trust_id}", + ), + ( + "workspace_config_tree", + HttpMethod::Get, + "/api/w/{workspace_id}/config/source-tree", + ), + ( + "workspace_prompt_projection", + HttpMethod::Get, + "/api/w/{workspace_id}/config/projections/prompts", + ), + ( + "workspace_config_tree_commit", + HttpMethod::Post, + "/api/w/{workspace_id}/config/source-tree/commit", + ), + ( + "workspace_config_revision", + HttpMethod::Get, + "/api/w/{workspace_id}/config/source-tree/revisions/{revision}", + ), + ( + "workspace_config_entry", + HttpMethod::Get, + "/api/w/{workspace_id}/config/source-tree/entries/{path}", + ), + ( + "profile_settings", + HttpMethod::Get, + "/api/w/{workspace_id}/settings/profiles", + ), + ("flow_list", HttpMethod::Get, "/api/w/{workspace_id}/flows"), + ("flow_put", HttpMethod::Put, "/api/w/{workspace_id}/flows"), + ( + "flow_resolve", + HttpMethod::Post, + "/api/w/{workspace_id}/flows/resolve", + ), + ( + "flow_get", + HttpMethod::Get, + "/api/w/{workspace_id}/flows/{flow_id}", + ), + ( + "memory_document", + HttpMethod::Get, + "/api/w/{workspace_id}/memory", + ), + ( + "memory_staging_list", + HttpMethod::Get, + "/api/w/{workspace_id}/memory/staging", + ), + ( + "memory_backend", + HttpMethod::Post, + "/api/w/{workspace_id}/memory/backend", + ), + ( + "memory_consolidation", + HttpMethod::Post, + "/api/w/{workspace_id}/memory/consolidation", + ), + ( + "skill_list", + HttpMethod::Get, + "/api/w/{workspace_id}/skills", + ), + ( + "skill_lint", + HttpMethod::Get, + "/api/w/{workspace_id}/skills/lint", + ), + ( + "skill_get", + HttpMethod::Get, + "/api/w/{workspace_id}/skills/{name}", + ), + ( + "skill_activate", + HttpMethod::Get, + "/api/w/{workspace_id}/skills/{name}/activate", + ), + ] { + let operation = operations + .iter() + .find(|operation| operation.operation_id == operation_id) + .unwrap_or_else(|| panic!("missing ServerApi operation {operation_id}")); + assert_eq!(operation.method, method, "{operation_id}"); + assert_eq!(operation.path, path, "{operation_id}"); + } + } + #[test] fn worker_restore_state_round_trips_and_rejects_unknown_variants() { for (state, wire) in [ @@ -4596,18 +10558,48 @@ mod tests { assert!(!output.contains("export type RepositoryObservedStatus =")); assert!(output.contains("export type WorkspaceMetadataSettingsResponse =")); assert!(output.contains("export type WorkspaceMetadataMutationResponse =")); + assert!(output.contains("export type WorkspaceConfigTreeResponse =")); + assert!(output.contains("export type ConfigCommitRequest =")); assert!(output.contains("export type ProfileSettingsResponse =")); assert!(output.contains("config_revision?: number | null")); assert!(output.contains("provenance: WorkspaceProfileSourceProvenance")); assert!(output.contains( "export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\"" )); - assert!(output.contains("export type RuntimeConnectionTestResponse =")); - assert!(output.contains("status: RuntimeConnectionTestStatus")); - assert!(output.contains("failure_kind: RuntimeConnectionTestFailureKind | null")); + assert!(!output.contains("export type RuntimeConnectionTestResponse =")); + assert!(!output.contains("export type RuntimePublicIdentityBundle =")); + assert!(!output.contains("export type WorkspaceRuntimeResource =")); assert!(!output.contains("repository_key: string, display_name")); } + #[cfg(feature = "typescript")] + #[test] + fn generated_legacy_server_api_contract_is_current() { + fn normalize(value: &str) -> String { + value + .chars() + .filter_map(|character| match character { + character if character.is_whitespace() => None, + ',' => Some(';'), + character => Some(character), + }) + .collect::() + .replace("=|", "=") + .replace(";}", "}") + } + + let expected = legacy_catalog_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/legacy-server-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize(&actual), + normalize(&expected), + "regenerate legacy Server API TypeScript types and format the generated file", + ); + } + #[test] fn worker_resource_key_is_required() { let payload = serde_json::json!({ @@ -5033,8 +11025,8 @@ mod tests { let actual = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); assert_eq!( - normalize_typescript(&actual), - normalize_typescript(&expected), + normalized_typescript(actual), + normalized_typescript(expected), "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", ); } @@ -5107,35 +11099,23 @@ mod tests { assert!(serde_json::from_value::(private_item).is_err()); } - #[cfg(feature = "typescript")] #[test] fn generated_companion_api_contract_is_current() { - let expected = companion_api_typescript(); - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../web/workspace/src/lib/generated/companion-api.ts"); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let input = + std::fs::read_to_string(root.join(crate::repository_openapi_typescript::INPUT_PATH)) + .expect("read Server API OpenAPI artifact"); + let expected = crate::repository_openapi_typescript::generate_companion_typescript(&input) + .expect("generate Companion API TypeScript"); + let path = root.join(crate::repository_openapi_typescript::COMPANION_OUTPUT_PATH); let actual = std::fs::read_to_string(&path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); assert_eq!( - normalize_typescript(&actual), - normalize_typescript(&expected), - "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", + actual, expected, + "regenerate Companion API TypeScript types with `cargo run -q -p server-api --example generate_companion_api_types`", ); } - #[cfg(feature = "typescript")] - fn normalize_typescript(value: &str) -> String { - value - .chars() - .filter_map(|character| match character { - character if character.is_whitespace() => None, - ',' => Some(';'), - character => Some(character), - }) - .collect::() - .replace(";}", "}") - .replace("=|", "=") - } - #[test] fn workdir_create_request_preserves_optional_operation_fields() { let payload = serde_json::json!({"repository_key": "main"}); @@ -5248,6 +11228,276 @@ mod openapi_artifact_tests { ); } + #[test] + fn attachment_upload_openapi_uses_required_binary_body_and_json_response() { + let value = canonical_openapi_document() + .expect("canonical OpenAPI contract must build") + .as_value() + .clone(); + let operation = &value["paths"]["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}"] + ["put"]; + assert_eq!(operation["operationId"], "runtime_worker_attachment_upload"); + assert_eq!( + operation["requestBody"], + serde_json::json!({ + "required": true, + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + } + }) + ); + assert_eq!( + operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/WorkerFileUploadResponse" + ); + assert!( + value["components"]["schemas"] + .as_object() + .expect("schema components") + .get("BinaryBody") + .is_none() + ); + } + + #[test] + fn server_api_openapi_security_matches_workspace_server_auth_semantics() { + const PUBLIC_UNAUTHENTICATED: &[&str] = &[ + "health", + "auth_config", + "auth_bootstrap_user", + "auth_passkey_registration_options", + "auth_passkey_registration_complete", + "auth_passkey_login_options", + "auth_passkey_login_complete", + "auth_device_login_start", + "auth_device_login_poll", + "auth_logout", + "auth_whoami", + ]; + // These operations are authenticated by Runtime request-source proof, Worker source + // identity, or a one-use Reviewer capability. They remain generated Rust client/Axum + // operations, but must not appear as unauthenticated operations in the public OpenAPI. + const SIGNED_INTERNAL: &[&str] = &[ + "current_worker_workdir_attach", + "current_worker_workdir_detach", + "current_worker_workdir_operation", + "merge_request_complete", + "merge_request_open", + "merge_request_review_capability_register", + "merge_request_review_revoke", + "merge_request_review_submit", + "merge_request_reviewer_child_register", + "runtime_resource_fetch", + "worker_control_cancel", + "worker_control_input", + "worker_control_list", + "worker_control_restore", + "worker_control_spawn", + "worker_control_stop", + "worker_observation_capture", + "worker_observation_sessions", + "workspace_worker_discovery", + "workspace_worker_remove", + ]; + // Session observation retains its existing explicit non-OpenAPI boundary independently of + // the Runtime/Worker-source signed operation inventory above. + const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"]; + const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"]; + const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"]; + + 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"); + let paths = value["paths"] + .as_object() + .expect("OpenAPI paths must exist"); + let mut documented = std::collections::BTreeMap::new(); + for path in paths.values() { + for operation in path + .as_object() + .expect("OpenAPI path item must be an object") + .values() + { + let operation_id = operation["operationId"] + .as_str() + .expect("OpenAPI operationId must be a string"); + assert!( + documented.insert(operation_id, operation).is_none(), + "duplicate OpenAPI operationId {operation_id}" + ); + } + } + + let operations = ServerApiMetadata::OPERATIONS + .iter() + .filter(|operation| matches!(operation.transport, api_macros::TransportMetadata::Http)) + .collect::>(); + assert_eq!( + documented.len() + SIGNED_INTERNAL.len() + OTHER_OPENAPI_EXCLUDED.len(), + operations.len(), + "every ServerApi operation must have one bounded OpenAPI auth disposition" + ); + for operation in operations { + let operation_id = operation.operation_id; + if SIGNED_INTERNAL.contains(&operation_id) + || OTHER_OPENAPI_EXCLUDED.contains(&operation_id) + { + assert!( + !documented.contains_key(operation_id), + "internal/excluded operation {operation_id} must not appear public in OpenAPI" + ); + continue; + } + + let documented_operation = documented + .get(operation_id) + .unwrap_or_else(|| panic!("missing public OpenAPI operation {operation_id}")); + let expected = if PUBLIC_UNAUTHENTICATED.contains(&operation_id) + || DOCUMENTED_SIGNED_INTERNAL.contains(&operation_id) + { + &[][..] + } else if BROWSER_ONLY.contains(&operation_id) { + &["browserSession"][..] + } else { + &["bearerAuth", "browserSession"][..] + }; + let security = documented_operation.get("security"); + if expected.is_empty() { + assert!( + security.is_none(), + "public unauthenticated operation {operation_id} must not require security" + ); + continue; + } + let security = security + .and_then(serde_json::Value::as_array) + .unwrap_or_else(|| panic!("authenticated operation {operation_id} lacks security")); + let mut actual = security + .iter() + .map(|requirement| { + let requirement = requirement.as_object().unwrap_or_else(|| { + panic!("security requirement for {operation_id} must be an object") + }); + assert_eq!( + requirement.len(), + 1, + "security alternatives for {operation_id} must remain independent OR choices" + ); + requirement.keys().next().expect("one security scheme").as_str() + }) + .collect::>(); + actual.sort_unstable(); + assert_eq!(actual, expected, "security mismatch for {operation_id}"); + } + } + + #[test] + fn auth_workspace_catalog_and_typed_header_contract_is_strict() { + 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"); + + for (path, method) in [ + ("/health", "get"), + ("/api/auth/config", "get"), + ("/api/auth/bootstrap-user", "post"), + ("/api/auth/passkeys/registration/options", "post"), + ("/api/auth/passkeys/registration/complete", "post"), + ("/api/auth/passkeys/login/options", "post"), + ("/api/auth/passkeys/login/complete", "post"), + ("/api/auth/logout", "post"), + ("/api/auth/device-login/start", "post"), + ("/api/auth/device-login/approve", "post"), + ("/api/auth/device-login/poll", "post"), + ("/api/auth/whoami", "get"), + ("/api/workspaces", "get"), + ("/api/workspaces", "post"), + ("/api/workspaces/{workspace_id}/deletion", "get"), + ("/api/workspaces/{workspace_id}/deletion", "post"), + ("/api/workspace-deletions/{operation_id}", "get"), + ] { + assert!( + value["paths"][path][method].is_object(), + "missing {method} {path}" + ); + } + let workspace_catalog = &value["paths"]["/api/workspaces"]; + for method in ["get", "post"] { + assert_eq!( + workspace_catalog[method]["security"][0]["bearerAuth"], + serde_json::json!([]) + ); + assert_eq!( + workspace_catalog[method]["security"][1]["browserSession"], + serde_json::json!([]) + ); + } + for status in ["200", "201"] { + assert!(workspace_catalog["post"]["responses"][status].is_object()); + } + let deletion_start = &value["paths"]["/api/workspaces/{workspace_id}/deletion"]["post"]; + for status in ["200", "202", "409"] { + assert!(deletion_start["responses"][status].is_object()); + } + for path in [ + "/api/auth/passkeys/registration/complete", + "/api/auth/passkeys/login/complete", + "/api/auth/logout", + ] { + let response = &value["paths"][path]["post"]["responses"]["200"]; + assert_eq!( + response["headers"]["set-cookie"]["schema"]["$ref"], + "#/components/schemas/SetCookieHeader", + "cookie response route {path} must declare Set-Cookie" + ); + assert_eq!( + response["content"]["application/json"]["schema"]["$ref"], + if path == "/api/auth/logout" { + "#/components/schemas/LogoutResponse" + } else { + "#/components/schemas/AuthUserResponse" + } + ); + } + let runtime_config = &value["paths"]["/api/w/{workspace_id}/runtime-config"]["get"]; + assert_eq!( + runtime_config["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/WorkspaceRuntimeConfigResponse" + ); + let runtime_config_schema = + &value["components"]["schemas"]["WorkspaceRuntimeConfigResponse"]; + assert_eq!(runtime_config_schema["type"], "object"); + assert_eq!(runtime_config_schema["additionalProperties"], false); + assert!(runtime_config_schema["properties"]["metadata"].is_object()); + assert!(runtime_config_schema["properties"]["profiles"].is_object()); + assert!(runtime_config_schema["properties"]["declarations"].is_object()); + assert!( + value["components"]["schemas"]["WorkspaceRuntimeConfigMetadata"]["properties"] + ["digest"] + .is_object() + ); + assert!(runtime_config["responses"]["304"].get("content").is_none()); + for status in ["200", "304"] { + assert!(runtime_config["responses"][status]["headers"]["etag"].is_object()); + assert!(runtime_config["responses"][status]["headers"]["cache-control"].is_object()); + } + for schema in [ + "RepositoryApiError", + "WorkspaceCreateRequest", + "WorkspaceDeletionRequest", + "DeviceLoginStartResponse", + ] { + assert_eq!( + value["components"]["schemas"][schema]["additionalProperties"], false, + "{schema} must reject unknown fields" + ); + } + } + #[test] fn repository_operations_are_in_the_canonical_contract() { let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index ac459aa91..a561bf71c 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -7,6 +7,31 @@ 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"; +pub const WORKDIR_GENERATOR_NAME: &str = "yoi-workdir-openapi-typescript"; +pub const WORKDIR_GENERATOR_VERSION: &str = "1"; +pub const WORKDIR_GENERATOR_OPTIONS: &str = + "workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output"; +pub const WORKDIR_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/workdir-api.ts"; +pub const RUNTIME_GENERATOR_NAME: &str = "yoi-runtime-openapi-typescript"; +pub const RUNTIME_GENERATOR_VERSION: &str = "1"; +pub const RUNTIME_GENERATOR_OPTIONS: &str = + "runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output"; +pub const RUNTIME_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/runtime-api.ts"; + +pub const WORKER_GENERATOR_NAME: &str = "yoi-worker-openapi-typescript"; +pub const WORKER_GENERATOR_VERSION: &str = "1"; +pub const WORKER_GENERATOR_OPTIONS: &str = + "worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output"; +pub const WORKER_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/worker-launch-api.ts"; +pub const COMPANION_GENERATOR_NAME: &str = "yoi-companion-openapi-typescript"; +pub const COMPANION_GENERATOR_VERSION: &str = "1"; +pub const COMPANION_GENERATOR_OPTIONS: &str = + "companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output"; +pub const COMPANION_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/companion-api.ts"; +pub const TICKET_BROWSER_GENERATOR_NAME: &str = "yoi-ticket-browser-openapi-typescript"; +pub const TICKET_BROWSER_GENERATOR_VERSION: &str = "1"; +pub const TICKET_BROWSER_GENERATOR_OPTIONS: &str = "ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output"; +pub const TICKET_BROWSER_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/ticket-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; @@ -72,6 +97,179 @@ pub fn generate_repository_typescript(input: &str) -> Result 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( + "Workdir 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_workdir_operations(paths)?; + let closure = schema_closure(schemas, &roots)?; + + let mut output = format!( + "// @generated by {WORKDIR_GENERATOR_NAME}@{WORKDIR_GENERATOR_VERSION}. Do not edit.\n\ +// Input: {INPUT_PATH}\n\ +// Input contract: {contract_version} ({contract_digest})\n\ +// Options: {WORKDIR_GENERATOR_OPTIONS}\n\ +// Output: {WORKDIR_OUTPUT_PATH}\n\ +// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_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) +} + +pub fn generate_runtime_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( + "Runtime 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_runtime_operations(paths)?; + let closure = schema_closure(schemas, &roots)?; + + let mut output = format!( + "// @generated by {RUNTIME_GENERATOR_NAME}@{RUNTIME_GENERATOR_VERSION}. Do not edit.\n\ +// Input: {INPUT_PATH}\n\ +// Input contract: {contract_version} ({contract_digest})\n\ +// Options: {RUNTIME_GENERATOR_OPTIONS}\n\ +// Output: {RUNTIME_OUTPUT_PATH}\n\ +// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_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) +} + +pub fn generate_worker_typescript(input: &str) -> Result { + generate_domain_typescript( + input, + "Worker", + validate_worker_operations, + WORKER_GENERATOR_NAME, + WORKER_GENERATOR_VERSION, + WORKER_GENERATOR_OPTIONS, + WORKER_OUTPUT_PATH, + "generate_worker_launch_api_types", + ) +} + +pub fn generate_companion_typescript(input: &str) -> Result { + generate_domain_typescript( + input, + "Companion", + validate_companion_operations, + COMPANION_GENERATOR_NAME, + COMPANION_GENERATOR_VERSION, + COMPANION_GENERATOR_OPTIONS, + COMPANION_OUTPUT_PATH, + "generate_companion_api_types", + ) +} + +pub fn generate_ticket_browser_typescript(input: &str) -> Result { + generate_domain_typescript( + input, + "Ticket Browser", + validate_ticket_browser_operations, + TICKET_BROWSER_GENERATOR_NAME, + TICKET_BROWSER_GENERATOR_VERSION, + TICKET_BROWSER_GENERATOR_OPTIONS, + TICKET_BROWSER_OUTPUT_PATH, + "generate_ticket_api_types", + ) +} + +#[allow(clippy::too_many_arguments)] +fn generate_domain_typescript( + input: &str, + domain: &str, + validate_operations: fn(&Map) -> Result, GenerationError>, + generator_name: &str, + generator_version: &str, + generator_options: &str, + output_path: &str, + example_name: &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(format!( + "{domain} 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_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 {example_name} -- --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( @@ -86,11 +284,258 @@ fn validate_contract_digest(digest: &str) -> Result<(), GenerationError> { Ok(()) } +fn validate_worker_operations( + paths: &Map, +) -> Result, GenerationError> { + let operations = [ + OperationContract { + path: "/api/workers", + method: "get", + operation_id: "workspace_worker_list_alias", + request: None, + successes: &[("200", "WorkerListResponse")], + }, + OperationContract { + path: "/api/workers", + method: "post", + operation_id: "workspace_worker_create_alias", + request: Some("CreateWorkspaceWorkerRequest"), + successes: &[("200", "BrowserCreateWorkerResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/orchestrator", + method: "get", + operation_id: "workspace_orchestrator_status", + request: None, + successes: &[("200", "BrowserWorkspaceOrchestratorResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/orchestrator", + method: "post", + operation_id: "workspace_orchestrator_start", + request: None, + successes: &[("200", "BrowserWorkspaceOrchestratorResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/workers", + method: "get", + operation_id: "workspace_worker_list", + request: None, + successes: &[("200", "WorkerListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/workers", + method: "post", + operation_id: "workspace_worker_create", + request: Some("CreateWorkspaceWorkerRequest"), + successes: &[("200", "BrowserCreateWorkerResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/workers/{worker_ref}", + method: "get", + operation_id: "workspace_worker_detail", + request: None, + successes: &[("200", "WorkerSummary")], + }, + OperationContract { + path: "/api/workers/launch-options", + method: "get", + operation_id: "workspace_worker_launch_options_alias", + request: None, + successes: &[("200", "WorkerLaunchOptionsResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/workers/launch-options", + method: "get", + operation_id: "workspace_worker_launch_options", + request: None, + successes: &[("200", "WorkerLaunchOptionsResponse")], + }, + ]; + validate_operation_contracts(paths, &operations) +} + +fn validate_companion_operations( + paths: &Map, +) -> Result, GenerationError> { + let operations = [ + OperationContract { + path: "/api/companion/status", + method: "get", + operation_id: "companion_status_alias", + request: None, + successes: &[("200", "CompanionStatusResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/companion/status", + method: "get", + operation_id: "companion_status", + request: None, + successes: &[("200", "CompanionStatusResponse")], + }, + OperationContract { + path: "/api/companion/transcript", + method: "get", + operation_id: "companion_transcript_alias", + request: None, + successes: &[("200", "CompanionTranscriptProjection")], + }, + OperationContract { + path: "/api/w/{workspace_id}/companion/transcript", + method: "get", + operation_id: "companion_transcript", + request: None, + successes: &[("200", "CompanionTranscriptProjection")], + }, + OperationContract { + path: "/api/companion/messages", + method: "post", + operation_id: "companion_message_alias", + request: Some("CompanionMessageRequest"), + successes: &[("200", "CompanionMessageResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/companion/messages", + method: "post", + operation_id: "companion_message", + request: Some("CompanionMessageRequest"), + successes: &[("200", "CompanionMessageResponse")], + }, + OperationContract { + path: "/api/companion/cancel", + method: "post", + operation_id: "companion_cancel_alias", + request: Some("CompanionCancelRequest"), + successes: &[("200", "CompanionMessageResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/companion/cancel", + method: "post", + operation_id: "companion_cancel", + request: Some("CompanionCancelRequest"), + successes: &[("200", "CompanionMessageResponse")], + }, + ]; + validate_operation_contracts(paths, &operations) +} + +fn validate_ticket_browser_operations( + paths: &Map, +) -> Result, GenerationError> { + let operations = [ + OperationContract { + path: "/api/w/{workspace_id}/tickets", + method: "get", + operation_id: "ticket_list", + request: None, + successes: &[("200", "TicketListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets", + method: "post", + operation_id: "ticket_create_record", + request: Some("NewTicket"), + successes: &[("200", "TicketRef")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}", + method: "get", + operation_id: "ticket_get", + request: None, + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}", + method: "patch", + operation_id: "ticket_edit", + request: Some("BrowserEditTicketRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}", + method: "put", + operation_id: "ticket_assignment_set", + request: Some("SetTicketRoleAssignmentRequest"), + successes: &[("200", "TicketRoleAssignmentMutationResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations", + method: "post", + operation_id: "ticket_implementation_cancel", + request: Some("CancelTicketImplementationRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/state", + method: "post", + operation_id: "ticket_state_transition", + request: Some("BrowserTransitionTicketStateRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/ready", + method: "post", + operation_id: "ticket_ready", + request: Some("TicketMarkReadyRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/events", + method: "post", + operation_id: "ticket_event_append", + request: Some("BrowserAppendTicketEventRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/queue", + method: "post", + operation_id: "ticket_queue", + request: Some("BrowserQueueTicketRequest"), + successes: &[("200", "TicketQueueOutcome")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/close", + method: "post", + operation_id: "ticket_close", + request: Some("BrowserCloseTicketRequest"), + successes: &[("200", "TicketDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/objectives", + method: "get", + operation_id: "objective_list", + request: None, + successes: &[("200", "ObjectiveListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/objectives/{objective_id}", + method: "get", + operation_id: "objective_get", + request: None, + successes: &[("200", "ObjectiveDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/merge-requests", + method: "get", + operation_id: "merge_request_list", + request: None, + successes: &[("200", "MergeRequestListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/merge-requests/{merge_request_id}", + method: "get", + operation_id: "merge_request_show", + request: None, + successes: &[("200", "MergeRequestDetailResponse")], + }, + ]; + validate_operation_contracts(paths, &operations) +} + fn validate_repository_operations( paths: &Map, ) -> Result, GenerationError> { - let mut roots = BTreeSet::new(); - for operation in [ + let operations = [ OperationContract { path: "/api/repositories", method: "get", @@ -129,7 +574,551 @@ fn validate_repository_operations( request: None, successes: &[("200", "RepositoryDetailResponse")], }, - ] { + OperationContract { + path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", + method: "post", + operation_id: "repository_ssh_connection_probe", + request: Some("RepositorySshConnectionProbeRequest"), + successes: &[("200", "RepositorySshConnectionProbeResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", + method: "put", + operation_id: "repository_ssh_host_trust_confirm", + request: Some("ConfirmRepositorySshHostTrustRequest"), + successes: &[("200", "RepositorySshHostTrust")], + }, + OperationContract { + path: "/api/w/{workspace_id}/repositories/{repository_key}/log", + method: "get", + operation_id: "repository_log", + request: None, + successes: &[("200", "RepositoryLogResponse")], + }, + OperationContract { + path: "/api/repositories/{repository_key}/log", + method: "get", + operation_id: "repository_log_alias", + request: None, + successes: &[("200", "RepositoryLogResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/hosts", + method: "get", + operation_id: "host_list", + request: None, + successes: &[("200", "HostListResponse")], + }, + OperationContract { + path: "/api/hosts", + method: "get", + operation_id: "host_list_alias", + request: None, + successes: &[("200", "HostListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/hosts/{host_id}/workers", + method: "get", + operation_id: "host_worker_list", + request: None, + successes: &[("200", "HostWorkerListResponse")], + }, + OperationContract { + path: "/api/hosts/{host_id}/workers", + method: "get", + operation_id: "host_worker_list_alias", + request: None, + successes: &[("200", "HostWorkerListResponse")], + }, + ]; + validate_operation_contracts(paths, &operations) +} + +fn validate_workdir_operations( + paths: &Map, +) -> Result, GenerationError> { + let operations = [ + OperationContract { + path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", + method: "get", + operation_id: "runtime_workdir_list", + request: None, + successes: &[("200", "WorkingDirectoryListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", + method: "post", + operation_id: "runtime_workdir_create", + request: Some("WorkingDirectoryCreateRequest"), + successes: &[ + ("200", "WorkingDirectoryCreateResponse"), + ("201", "WorkingDirectoryCreateResponse"), + ], + }, + OperationContract { + path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", + method: "get", + operation_id: "runtime_workdir_detail", + request: None, + successes: &[("200", "WorkingDirectoryDetailResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", + method: "delete", + operation_id: "runtime_workdir_cleanup", + request: Some("WorkingDirectoryRemovalRequest"), + successes: &[("200", "WorkingDirectoryRemovalResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/external-workdir-grants", + method: "post", + operation_id: "external_workdir_grant_create", + request: Some("ExternalWorkdirGrantCreateRequest"), + successes: &[("201", "ExternalWorkdirGrantResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", + method: "get", + operation_id: "external_workdir_grant_get", + request: None, + successes: &[("200", "ExternalWorkdirGrantResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", + method: "delete", + operation_id: "external_workdir_grant_revoke", + request: None, + successes: &[("200", "ExternalWorkdirGrantResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/working-directories", + method: "get", + operation_id: "workspace_workdir_list", + request: None, + successes: &[("200", "WorkingDirectoryListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/working-directories", + method: "post", + operation_id: "workspace_workdir_create", + request: Some("WorkingDirectoryCreateRequest"), + successes: &[ + ("200", "WorkingDirectoryCreateResponse"), + ("201", "WorkingDirectoryCreateResponse"), + ], + }, + OperationContract { + path: "/api/w/{workspace_id}/working-directories/{working_directory_id}", + method: "get", + operation_id: "workspace_workdir_detail", + request: None, + successes: &[("200", "WorkingDirectoryDetailResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/working-directories/{working_directory_id}", + method: "delete", + operation_id: "workspace_workdir_cleanup", + request: Some("WorkingDirectoryRemovalRequest"), + successes: &[("200", "WorkingDirectoryRemovalResponse")], + }, + ]; + validate_operation_contracts(paths, &operations) +} + +fn validate_runtime_operations( + paths: &Map, +) -> Result, GenerationError> { + let operations = [ + runtime_operation( + "/api/runtimes", + "get", + "runtime_list_alias", + None, + &[("200", "RuntimeListResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes", + "get", + "runtime_list", + None, + &[("200", "WorkspaceRuntimeListResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes", + "post", + "runtime_create", + Some("CreateRemoteRuntimeRequest"), + &[ + ("200", "WorkspaceRuntimeResource"), + ("201", "WorkspaceRuntimeResource"), + ], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}", + "get", + "runtime_detail", + None, + &[("200", "WorkspaceRuntimeDetail")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}", + "post", + "runtime_update", + Some("UpdateRemoteRuntimeRequest"), + &[("200", "WorkspaceRuntimeDetail")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}", + "delete", + "runtime_remove", + Some("RemoveRuntimeRequest"), + &[("200", "RuntimeRemovalOperationResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", + "get", + "runtime_trust_key_reveal", + None, + &[("200", "RuntimeTrustKeyRevealResponse")], + ), + RuntimeOperationContract { + error: "RuntimeManagementApiError", + ..runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", + "delete", + "runtime_trust_key_revoke", + Some("RevokeRuntimeTrustKeyRequest"), + &[("200", "WorkspaceRuntimeDetail")], + ) + }, + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests", + "post", + "runtime_connection_test", + None, + &[("200", "RuntimeConnectionTestResponse")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/workers", + "get", + "runtime_worker_list_alias", + None, + &[("200", "RuntimeWorkerListResponse")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/workers", + "post", + "runtime_worker_create_alias", + Some("RuntimeWorkerSpawnRequest"), + &[("200", "RuntimeWorkerSpawnResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers", + "get", + "runtime_worker_list", + None, + &[("200", "RuntimeWorkerListResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers", + "post", + "runtime_worker_create", + Some("RuntimeWorkerSpawnRequest"), + &[("200", "RuntimeWorkerSpawnResponse")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/config-bundles", + "post", + "runtime_config_bundle_sync_alias", + Some("RuntimeConfigBundleSyncRequest"), + &[("200", "RuntimeConfigBundleResult")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles", + "post", + "runtime_config_bundle_sync", + Some("RuntimeConfigBundleSyncRequest"), + &[("200", "RuntimeConfigBundleResult")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability", + "get", + "runtime_config_bundle_availability_alias", + None, + &[("200", "RuntimeConfigBundleResult")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability", + "get", + "runtime_config_bundle_availability", + None, + &[("200", "RuntimeConfigBundleResult")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/workers/{worker_id}", + "get", + "runtime_worker_detail_alias", + None, + &[("200", "RuntimeWorkerShowResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}", + "get", + "runtime_worker_detail", + None, + &[("200", "RuntimeWorkerShowResponse")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/workers/{worker_id}/restore", + "post", + "runtime_worker_restore_alias", + None, + &[("200", "WorkerRestoreResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore", + "post", + "runtime_worker_restore", + None, + &[("200", "WorkerRestoreResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/pin", + "put", + "runtime_worker_pin", + None, + &[("200", "WorkerRetentionResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/pin", + "delete", + "runtime_worker_unpin", + None, + &[("200", "WorkerRetentionResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-plan", + "get", + "runtime_cleanup_plan", + None, + &[("200", "RuntimeCleanupPlanResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-executions", + "post", + "runtime_cleanup_execute", + Some("ExecuteRuntimeCleanupRequest"), + &[("200", "RuntimeCleanupExecutionResponse")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/workers/{worker_id}/input", + "post", + "runtime_worker_input_alias", + Some("RuntimeWorkerInputRequest"), + &[("200", "RuntimeWorkerInputResult")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/input", + "post", + "runtime_worker_input", + Some("RuntimeWorkerInputRequest"), + &[("200", "RuntimeWorkerInputResult")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-upload-grants", + "post", + "runtime_worker_attachment_upload_grant", + None, + &[("200", "AttachmentUploadGrantResponse")], + ), + binary_runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", + "put", + "runtime_worker_attachment_upload", + &[("200", "WorkerFileUploadResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", + "delete", + "runtime_worker_attachment_upload_cancel", + None, + &[("200", "AttachmentUploadCancelResponse")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachments/{artifact_id}", + "delete", + "runtime_worker_attachment_delete", + None, + &[("200", "WorkerFileDeleteResponse")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/workers/{worker_id}/completions", + "post", + "runtime_worker_completions_alias", + Some("RuntimeWorkerCompletionsRequest"), + &[("200", "RuntimeWorkerCompletionsResult")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/completions", + "post", + "runtime_worker_completions", + Some("RuntimeWorkerCompletionsRequest"), + &[("200", "RuntimeWorkerCompletionsResult")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/workers/{worker_id}/stop", + "post", + "runtime_worker_stop_alias", + Some("RuntimeWorkerLifecycleRequest"), + &[("200", "RuntimeWorkerLifecycleResult")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/stop", + "post", + "runtime_worker_stop", + Some("RuntimeWorkerLifecycleRequest"), + &[("200", "RuntimeWorkerLifecycleResult")], + ), + runtime_operation( + "/api/runtimes/{runtime_id}/workers/{worker_id}/cancel", + "post", + "runtime_worker_cancel_alias", + Some("RuntimeWorkerLifecycleRequest"), + &[("200", "RuntimeWorkerLifecycleResult")], + ), + runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/cancel", + "post", + "runtime_worker_cancel", + Some("RuntimeWorkerLifecycleRequest"), + &[("200", "RuntimeWorkerLifecycleResult")], + ), + ]; + validate_runtime_operation_contracts(paths, &operations) +} + +const fn runtime_operation( + path: &'static str, + method: &'static str, + operation_id: &'static str, + request: Option<&'static str>, + successes: &'static [(&'static str, &'static str)], +) -> RuntimeOperationContract { + RuntimeOperationContract { + path, + method, + operation_id, + request, + binary_request: false, + successes, + error: "RepositoryApiError", + } +} + +const fn binary_runtime_operation( + path: &'static str, + method: &'static str, + operation_id: &'static str, + successes: &'static [(&'static str, &'static str)], +) -> RuntimeOperationContract { + RuntimeOperationContract { + path, + method, + operation_id, + request: None, + binary_request: true, + successes, + error: "RepositoryApiError", + } +} + +fn validate_runtime_operation_contracts( + paths: &Map, + operations: &[RuntimeOperationContract], +) -> Result, GenerationError> { + let mut roots = BTreeSet::new(); + for operation in operations { + 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 + ))); + } + if operation.binary_request { + let request = object_field(value, "requestBody", operation.operation_id)?; + validate_binary_request_body(request, operation.operation_id)?; + } else { + match operation.request { + Some(expected) => { + let request = object_field(value, "requestBody", operation.operation_id)?; + require_schema_ref( + json_content_schema(request, operation.operation_id)?, + 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 expected = successes + .get(status.as_str()) + .copied() + .unwrap_or(operation.error); + require_schema_ref( + json_content_schema(response, operation.operation_id)?, + 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 RuntimeOperationContract { + path: &'static str, + method: &'static str, + operation_id: &'static str, + request: Option<&'static str>, + binary_request: bool, + successes: &'static [(&'static str, &'static str)], + error: &'static str, +} + +fn validate_operation_contracts( + paths: &Map, + operations: &[OperationContract], +) -> Result, GenerationError> { + let mut roots = BTreeSet::new(); + for operation in operations { 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) { @@ -194,6 +1183,36 @@ struct OperationContract { successes: &'static [(&'static str, &'static str)], } +fn validate_binary_request_body( + request: &Map, + context: &str, +) -> Result<(), GenerationError> { + if request.get("required").and_then(Value::as_bool) != Some(true) { + return Err(GenerationError::invalid(format!( + "{context} binary request body must be required" + ))); + } + let content = object_field(request, "content", context)?; + if content.len() != 1 { + return Err(GenerationError::invalid(format!( + "{context} binary request body must expose only application/octet-stream" + ))); + } + let media = object_field(content, "application/octet-stream", context)?; + let schema = media.get("schema").ok_or_else(|| { + GenerationError::invalid(format!("{context} is missing its binary schema")) + })?; + let schema = object(schema, context)?; + if schema.get("type").and_then(Value::as_str) != Some("string") + || schema.get("format").and_then(Value::as_str) != Some("binary") + { + return Err(GenerationError::invalid(format!( + "{context} binary request schema must be type string with format binary" + ))); + } + Ok(()) +} + fn json_content_schema<'a>( value: &'a Map, context: &str, @@ -315,6 +1334,25 @@ fn literal_matches_type(kind: &str, value: &Value) -> bool { ) } +fn tagged_object_discriminator(value: &Value) -> Option<(String, String)> { + let schema = value.as_object()?; + if schema.get("type")?.as_str()? != "object" { + return None; + } + let required = schema.get("required")?.as_array()?; + let properties = schema.get("properties")?.as_object()?; + properties.iter().find_map(|(name, property)| { + if !required + .iter() + .any(|required| required.as_str() == Some(name)) + { + return None; + } + let literal = property.as_object()?.get("const")?.as_str()?; + Some((name.clone(), literal.to_owned())) + }) +} + fn literal_schema_matches_type(schema: &Map, kind: &str) -> bool { if let Some(constant) = schema.get("const") { return literal_matches_type(kind, constant); @@ -328,6 +1366,14 @@ fn literal_schema_matches_type(schema: &Map, kind: &str) -> bool } fn render_schema(value: &Value, context: &str) -> Result { + if value == &Value::Bool(true) { + return Ok("unknown".to_owned()); + } + if value == &Value::Bool(false) { + return Err(GenerationError::invalid(format!( + "{context} rejects every value" + ))); + } let schema = object(value, context)?; if schema.contains_key("$ref") { let reference = schema_reference(value, context)?; @@ -403,6 +1449,49 @@ fn render_schema(value: &Value, context: &str) -> Result>>(); + if let Some(discriminators) = tagged_discriminators { + if outer_type.is_some() { + return Err(GenerationError::invalid(format!( + "{context} combines a tagged object union with an outer type" + ))); + } + let Some((discriminator_name, _)) = discriminators.first() else { + unreachable!("empty oneOf rejected above"); + }; + let mut seen = BTreeSet::new(); + for (name, literal) in &discriminators { + if name != discriminator_name || !seen.insert(literal.clone()) { + return Err(GenerationError::invalid(format!( + "{context} contains overlapping tagged oneOf branches" + ))); + } + } + return branches + .iter() + .map(|branch| render_schema(branch, context)) + .collect::, _>>() + .map(|branches| branches.join(" | ")); + } + + let disjoint_types = branches + .iter() + .map(|branch| branch.as_object()?.get("type")?.as_str()) + .collect::>>(); + if let Some(types) = disjoint_types { + let unique = types.iter().copied().collect::>(); + if outer_type.is_none() && unique.len() == branches.len() { + return branches + .iter() + .map(|branch| render_schema(branch, context)) + .collect::, _>>() + .map(|branches| branches.join(" | ")); + } + } + let mut rendered = Vec::with_capacity(branches.len()); let mut seen_literals = BTreeSet::new(); for branch in branches { @@ -438,12 +1527,12 @@ fn render_schema(value: &Value, context: &str) -> Result { 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)) + if matches!( + format.as_str(), + "uint" | "uint8" | "uint16" | "uint32" | "uint64" | "int32" + ) => + { + Some(format.as_str()) + } Some(Value::String(format)) => { return Err(GenerationError::invalid(format!( "{context} uses unsupported integer format `{format}`" @@ -545,24 +1641,65 @@ fn render_typed_schema( }; 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 + let (expected_minimum, expected_maximum) = match format { + Some("uint") | Some("uint64") => (0.0, MAX_SAFE_INTEGER), + Some("uint8") => (0.0, u8::MAX as f64), + Some("uint16") => (0.0, u16::MAX as f64), + Some("uint32") => (0.0, u32::MAX as f64), + Some("int32") => (i32::MIN as f64, i32::MAX as f64), + None => (MIN_SAFE_INTEGER, MAX_SAFE_INTEGER), + Some(_) => unreachable!("integer format validated above"), }; - if minimum != Some(expected_minimum) || maximum != Some(MAX_SAFE_INTEGER) { + let minimum = minimum.unwrap_or(expected_minimum); + let maximum = maximum.unwrap_or(expected_maximum); + if minimum.fract() != 0.0 + || maximum.fract() != 0.0 + || minimum < expected_minimum + || maximum > expected_maximum + || minimum > maximum + { return Err(GenerationError::invalid(format!( "{context} integer range does not match its JavaScript safe-number mapping" ))); } - Ok("number".to_owned()) + if minimum == expected_minimum && maximum == expected_maximum { + Ok("number".to_owned()) + } else { + Ok(format!( + "number & {{ readonly \"__yoiIntegerRange\": \"{minimum:.0}..{maximum:.0}\"; }}" + )) + } } "array" => { - ensure_schema_keywords(schema, &["items", "type"], context)?; + ensure_schema_keywords(schema, &["items", "maxItems", "minItems", "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)?)) + let item = render_schema(items, context)?; + match (schema.get("minItems"), schema.get("maxItems")) { + (None, None) => Ok(format!("Array<{item}>")), + (Some(minimum), Some(maximum)) if minimum == maximum => { + let length = minimum.as_u64().ok_or_else(|| { + GenerationError::invalid(format!( + "{context} fixed array length must be a non-negative integer" + )) + })?; + if length > 64 { + return Err(GenerationError::invalid(format!( + "{context} fixed array length exceeds the generator limit" + ))); + } + Ok(format!( + "[{}]", + std::iter::repeat_n(item, length as usize) + .collect::>() + .join(", ") + )) + } + _ => Err(GenerationError::invalid(format!( + "{context} contains unsupported variable array bounds" + ))), + } } "object" => { ensure_schema_keywords( @@ -579,10 +1716,13 @@ fn render_typed_schema( } 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 empty_properties = Map::new(); + let properties = match schema.get("properties") { + Some(value) => value.as_object().ok_or_else(|| { + GenerationError::invalid(format!("{context} object properties must be an object")) + })?, + None => &empty_properties, + }; let required = match schema.get("required") { Some(Value::Array(required)) => required .iter() @@ -606,13 +1746,23 @@ fn render_object(schema: &Map, context: &str) -> Result {} + Some(additional) if properties.is_empty() && required.is_empty() => { + return Ok(format!( + "{{ [key: string]: {}; }}", + render_schema(additional, context)? + )); + } + Some(_) => { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported additionalProperties" + ))); + } + } + + if properties.is_empty() { + return Ok("{ [key: string]: never; }".to_owned()); } let mut fields = Vec::with_capacity(properties.len()); @@ -689,6 +1839,130 @@ mod tests { const OPENAPI: &str = include_str!("../../../openapi/server-api.json"); const TYPESCRIPT: &str = include_str!("../../../web/workspace/src/lib/generated/repository-api.ts"); + const RUNTIME_TYPESCRIPT: &str = + include_str!("../../../web/workspace/src/lib/generated/runtime-api.ts"); + const TICKET_BROWSER_TYPESCRIPT: &str = + include_str!("../../../web/workspace/src/lib/generated/ticket-api.ts"); + + #[test] + fn ticket_browser_generator_projects_target_collections_without_singular_authority() { + let openapi = crate::canonical_openapi_document() + .expect("canonical OpenAPI contract must build") + .to_json() + .expect("canonical OpenAPI contract must serialize"); + let output = generate_ticket_browser_typescript(&openapi).unwrap(); + assert!(output.contains( + "export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };" + )); + assert!( + output.contains("export type TicketTargetAccess = \"read_only\" | \"read_write\";") + ); + assert!(output.contains( + "export type BrowserTicketTargetEdit = { action: \"set\"; targets: Array; } | { action: \"clear\"; };" + )); + let edit = output + .lines() + .find(|line| line.starts_with("export type BrowserEditTicketRequest =")) + .expect("BrowserEditTicketRequest declaration"); + assert!(edit.contains("target?: BrowserTicketTargetEdit | null;")); + let create = output + .lines() + .find(|line| line.starts_with("export type NewTicket =")) + .expect("NewTicket declaration"); + assert!(create.contains("targets?: Array;")); + assert!(!create.contains("repository_key")); + assert!(!create.contains("ref_selector")); + assert!(!create.contains(" target?:")); + let detail = output + .lines() + .find(|line| line.starts_with("export type TicketDetail =")) + .expect("TicketDetail declaration"); + assert!(detail.contains("targets: Array;")); + assert!(!detail.contains("repository_key")); + assert!(!detail.contains("ref_selector")); + assert!(!detail.contains(" target:")); + } + + #[test] + fn checked_in_ticket_browser_typescript_is_current() { + assert_eq!( + generate_ticket_browser_typescript(OPENAPI).unwrap(), + TICKET_BROWSER_TYPESCRIPT, + "regenerate with `cargo run -q -p server-api --example generate_ticket_api_types`", + ); + } + + #[test] + fn ticket_browser_generator_validates_operation_ownership_and_schema_closure() { + let output = generate_ticket_browser_typescript(OPENAPI).unwrap(); + assert!(output.contains(&format!( + "@generated by {TICKET_BROWSER_GENERATOR_NAME}@{TICKET_BROWSER_GENERATOR_VERSION}" + ))); + assert!(output.contains("export type TicketListResponse =")); + assert!( + output.contains("limit: number & { readonly \"__yoiIntegerRange\": \"0..1000\"; }") + ); + assert!( + output.contains("export type BrowserQueueTicketRequest = { [key: string]: never; };") + ); + assert!(output.contains("attributes: { [key: string]: string; }")); + assert!(output.contains("export type ObjectiveDetail =")); + assert!(output.contains("export type MergeRequestDetailResponse =")); + assert!(!output.contains("export type RuntimeListResponse =")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["paths"]["/api/w/{workspace_id}/tickets"]["get"]["operationId"] = + serde_json::json!("manual_ticket_list"); + let error = generate_ticket_browser_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("ticket_list")); + } + + #[test] + fn checked_in_runtime_typescript_is_current() { + assert_eq!( + generate_runtime_typescript(OPENAPI).unwrap(), + RUNTIME_TYPESCRIPT, + "regenerate with `cargo run -q -p server-api --example generate_runtime_api_types`", + ); + } + + #[test] + fn runtime_generator_validates_operation_ownership_and_schema_closure() { + let output = generate_runtime_typescript(OPENAPI).unwrap(); + assert!(output.contains(&format!( + "@generated by {RUNTIME_GENERATOR_NAME}@{RUNTIME_GENERATOR_VERSION}" + ))); + assert!(output.contains("export type RuntimeCleanupPlanResponse =")); + assert!(output.contains("export type RuntimeWorkerLifecycleResult =")); + assert!(output.contains("export type WorkerFileUploadResponse =")); + assert!(output.contains("export type UploadedFileRef =")); + assert!(output.contains( + "export type RuntimeWorkerSpawnAcceptanceRequirement = { kind: \"socket_ready\"; } | { expected_segments: number; kind: \"run_accepted\"; };" + )); + assert!(!output.contains("export type UnmigratedDomain =")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["paths"]["/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-plan"]["get"]["operationId"] = + serde_json::json!("manual_cleanup_plan"); + let error = generate_runtime_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("runtime_cleanup_plan")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["paths"]["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}"] + ["put"]["requestBody"]["content"]["application/octet-stream"]["schema"]["format"] = + serde_json::json!("byte"); + let error = generate_runtime_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("format binary")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RuntimeWorkerSpawnAcceptanceRequirement"] + ["oneOf"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"type": "object", "properties": {}, "additionalProperties": false})); + let error = generate_runtime_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("ambiguous oneOf")); + } #[test] fn checked_in_repository_typescript_is_current() { @@ -713,7 +1987,7 @@ mod tests { } #[test] - fn generator_rejects_lossy_safe_integer_and_union_changes() { + fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() { 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); @@ -729,8 +2003,10 @@ mod tests { 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 output = generate_repository_typescript(&document.to_string()).unwrap(); + assert!(output.contains( + "source_revision: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }" + )); let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] = @@ -778,13 +2054,13 @@ mod tests { ); let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); - document["components"]["schemas"]["RepositoryListResponse"]["properties"]["items"]["maxItems"] = - serde_json::json!(1); + document["components"]["schemas"]["RepositoryListResponse"]["properties"]["items"]["uniqueItems"] = + serde_json::json!(true); let error = generate_repository_typescript(&document.to_string()).unwrap_err(); assert!( error .to_string() - .contains("unsupported schema keyword `maxItems`") + .contains("unsupported schema keyword `uniqueItems`") ); } diff --git a/crates/ticket/src/latest_schema.sql b/crates/ticket/src/latest_schema.sql index 942dc3c8b..a06926101 100644 --- a/crates/ticket/src/latest_schema.sql +++ b/crates/ticket/src/latest_schema.sql @@ -84,9 +84,20 @@ CREATE TABLE typed_tickets ( workflow_state_explicit INTEGER NOT NULL, queued_by TEXT, queued_at TEXT, - resolution TEXT, repository_id TEXT, ref_selector TEXT, + resolution TEXT, PRIMARY KEY (workspace_id, ticket_id) ); +CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); CREATE TABLE "workspace_resource_key_counters" ( workspace_id TEXT NOT NULL, resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 56531a027..0ce6dc7e0 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -76,13 +76,17 @@ pub enum TicketError { #[error("ticket target selector is required for repository `{0}`")] MissingTargetSelector(String), #[error( - "ticket target selector `{selector}` is invalid for repository `{repository_id}`: {reason}" + "ticket target selector `{selector}` is invalid for repository `{repository_key}`: {reason}" )] InvalidTargetSelector { - repository_id: String, + repository_key: String, selector: String, reason: String, }, + #[error("ticket target repository `{0}` appears more than once")] + DuplicateTargetRepository(String), + #[error("ticket requires exactly one read_write target, found {0}")] + InvalidReadWriteTargetCount(usize), #[error("ticket target authority is unavailable")] TargetAuthorityUnavailable, #[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")] @@ -133,7 +137,19 @@ fn sqlite_err(error: impl std::fmt::Display) -> TicketError { TicketError::Sqlite(error.to_string()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + schemars::JsonSchema, +)] pub enum TicketStatus { Open, Closed, @@ -162,7 +178,9 @@ impl fmt::Display for TicketStatus { } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema, +)] pub enum ExtensibleTicketStatus { Open, Closed, @@ -206,7 +224,19 @@ impl From for ExtensibleTicketStatus { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + schemars::JsonSchema, +)] #[serde(rename_all = "snake_case")] pub enum TicketWorkflowState { Planning, @@ -273,7 +303,7 @@ impl fmt::Display for TicketWorkflowState { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct MarkdownText(pub String); impl MarkdownText { @@ -298,7 +328,7 @@ impl From for MarkdownText { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub enum TicketIdOrSlug { Id(String), Slug(String), @@ -325,7 +355,7 @@ impl From for TicketIdOrSlug { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TicketEventKind { Create, @@ -390,13 +420,13 @@ impl From<&str> for TicketEventKind { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketReference { pub kind: String, pub target: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct NewTicketEvent { pub kind: TicketEventKind, pub author: Option, @@ -415,7 +445,7 @@ impl NewTicketEvent { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketStateChange { pub from: String, pub to: String, @@ -443,7 +473,7 @@ impl TicketStateChange { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketIntakeSummary { pub author: Option, pub body: MarkdownText, @@ -460,7 +490,7 @@ impl TicketIntakeSummary { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct NewTicket { pub title: String, pub slug: Option, @@ -475,9 +505,8 @@ pub struct NewTicket { pub workflow_state: Option, pub queued_by: Option, pub queued_at: Option, - #[serde(rename = "repository_key")] - pub repository_id: Option, - pub ref_selector: Option, + #[serde(default)] + pub targets: Vec, } impl NewTicket { @@ -496,27 +525,68 @@ impl NewTicket { workflow_state: None, queued_by: None, queued_at: None, - repository_id: None, - ref_selector: None, + targets: Vec::new(), + } + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + schemars::JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum TicketTargetAccess { + ReadOnly, + ReadWrite, +} + +impl TicketTargetAccess { + pub fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "read_only", + Self::ReadWrite => "read_write", + } + } + + fn parse(value: &str) -> Option { + match value { + "read_only" => Some(Self::ReadOnly), + "read_write" => Some(Self::ReadWrite), + _ => None, } } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketTarget { + pub repository_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ref_selector: Option, + pub access: TicketTargetAccess, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(tag = "action", rename_all = "snake_case")] -pub enum TicketTargetEdit { - Set { - #[serde(rename = "repository_key")] - repository_id: String, - ref_selector: Option, - }, +pub enum TicketTargetsEdit { + Set { targets: Vec }, Clear, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedTicketTarget { - pub repository_id: String, + pub repository_key: String, pub ref_selector: String, + pub access: TicketTargetAccess, } /// Workspace-owned authority used to resolve and validate implementation targets. @@ -528,12 +598,12 @@ pub trait TicketTargetAuthority: Send + Sync { fn resolve_target( &self, workspace_id: &str, - repository_id: Option<&str>, + repository_key: &str, ref_selector: Option<&str>, ) -> Result; } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketMarkReady { pub operation_key: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -544,50 +614,83 @@ pub struct TicketMarkReady { pub intake_summary: Option, } -impl TicketTargetEdit { +impl TicketTargetsEdit { fn validate(&self) -> Result<()> { - if let Self::Set { - repository_id, - ref_selector, - } = self - { - validate_ticket_target(Some(repository_id), ref_selector.as_deref())?; + if let Self::Set { targets } = self { + validate_ticket_targets(targets)?; } Ok(()) } } -fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str>) -> Result<()> { - let Some(repository_id) = repository_id else { - if ref_selector.is_some() { - return Err(TicketError::Conflict( - "ref_selector requires repository_id".to_string(), +fn validate_ticket_targets(targets: &[TicketTarget]) -> Result<()> { + let mut repositories = BTreeSet::new(); + for target in targets { + validate_required_event_value("repository_key", &target.repository_key)?; + if let Some(ref_selector) = target.ref_selector.as_deref() { + validate_required_event_value("ref_selector", ref_selector)?; + } + if !repositories.insert(target.repository_key.as_str()) { + return Err(TicketError::DuplicateTargetRepository( + target.repository_key.clone(), )); } - return Ok(()); - }; - validate_required_event_value("repository_id", repository_id)?; - if let Some(ref_selector) = ref_selector { - validate_required_event_value("ref_selector", ref_selector)?; } Ok(()) } -fn resolve_ready_target( +fn resolve_ready_targets( authority: Option<&Arc>, workspace_id: &str, - ticket: &Ticket, -) -> Result { - authority - .ok_or(TicketError::TargetAuthorityUnavailable)? - .resolve_target( + targets: &[TicketTarget], +) -> Result> { + validate_ticket_targets(targets)?; + let read_write_count = targets + .iter() + .filter(|target| target.access == TicketTargetAccess::ReadWrite) + .count(); + if read_write_count != 1 { + return Err(TicketError::InvalidReadWriteTargetCount(read_write_count)); + } + let authority = authority.ok_or(TicketError::TargetAuthorityUnavailable)?; + let mut resolved = Vec::with_capacity(targets.len()); + let mut repositories = BTreeSet::new(); + for target in targets { + let mut canonical = authority.resolve_target( workspace_id, - ticket.meta.repository_id.as_deref(), - ticket.meta.ref_selector.as_deref(), - ) + &target.repository_key, + target.ref_selector.as_deref(), + )?; + validate_required_event_value("repository_key", &canonical.repository_key)?; + validate_required_event_value("ref_selector", &canonical.ref_selector)?; + canonical.access = target.access; + if !repositories.insert(canonical.repository_key.clone()) { + return Err(TicketError::DuplicateTargetRepository( + canonical.repository_key, + )); + } + resolved.push(canonical); + } + Ok(resolved) } -fn mark_ready_fingerprint( +fn canonical_ticket_targets(targets: &[ResolvedTicketTarget]) -> Vec { + targets + .iter() + .map(|target| TicketTarget { + repository_key: target.repository_key.clone(), + ref_selector: Some(target.ref_selector.clone()), + access: target.access, + }) + .collect() +} + +fn encoded_resolved_targets(targets: &[ResolvedTicketTarget]) -> Result { + serde_json::to_string(targets) + .map_err(|error| TicketError::Conflict(format!("failed to encode Ticket targets: {error}"))) +} + +fn mark_ready_fingerprint_v1( ticket: &Ticket, request: &TicketMarkReady, target: &ResolvedTicketTarget, @@ -596,7 +699,7 @@ fn mark_ready_fingerprint( digest.update(b"ticket.mark-ready.v1\0"); digest.update(ticket.meta.id.as_str().as_bytes()); digest.update(b"\0planning\0"); - digest.update(target.repository_id.as_bytes()); + digest.update(target.repository_key.as_bytes()); digest.update(b"\0"); digest.update(target.ref_selector.as_bytes()); digest.update(b"\0"); @@ -620,6 +723,43 @@ fn mark_ready_fingerprint( .collect() } +fn mark_ready_fingerprint( + ticket: &Ticket, + request: &TicketMarkReady, + targets: &[ResolvedTicketTarget], +) -> String { + let mut digest = Sha256::new(); + digest.update(b"ticket.mark-ready.v2\0"); + digest.update(ticket.meta.id.as_str().as_bytes()); + digest.update(b"\0planning\0"); + for target in targets { + digest.update(target.repository_key.as_bytes()); + digest.update(b"\0"); + digest.update(target.ref_selector.as_bytes()); + digest.update(b"\0"); + digest.update(target.access.as_str().as_bytes()); + digest.update(b"\0"); + } + if let Some(reason) = request.reason.as_deref() { + digest.update(reason.as_bytes()); + } + if let Some(summary) = request.intake_summary.as_ref() { + digest.update(b"\0intake-summary\0"); + digest.update(summary.body.as_str().as_bytes()); + for reference in &summary.references { + digest.update(b"\0"); + digest.update(reference.kind.as_bytes()); + digest.update(b":"); + digest.update(reference.target.as_bytes()); + } + } + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Result { let Some(event) = ticket .events @@ -628,31 +768,36 @@ fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Res else { return Ok(false); }; - let target = ResolvedTicketTarget { - repository_id: event - .attributes - .get("repository_id") - .cloned() - .ok_or_else(|| { - TicketError::Conflict("mark-ready event is missing repository_id".to_owned()) - })?, - ref_selector: event - .attributes - .get("ref_selector") - .cloned() - .ok_or_else(|| { - TicketError::Conflict("mark-ready event is missing ref_selector".to_owned()) - })?, + let targets = event + .attributes + .get("targets") + .ok_or_else(|| TicketError::Conflict("mark-ready event is missing targets".to_owned())) + .and_then(|targets| { + serde_json::from_str::>(targets).map_err(|error| { + TicketError::Conflict(format!("mark-ready event has invalid targets: {error}")) + }) + })?; + let fingerprint = match event + .attributes + .get("fingerprint_version") + .map(String::as_str) + .unwrap_or("1") + { + "1" if targets.len() == 1 => mark_ready_fingerprint_v1(ticket, request, &targets[0]), + "2" => mark_ready_fingerprint(ticket, request, &targets), + version => { + return Err(TicketError::Conflict(format!( + "mark-ready event has unsupported fingerprint version {version:?}" + ))); + } }; - let fingerprint = mark_ready_fingerprint(ticket, request, &target); if event.attributes.get("request_fingerprint") != Some(&fingerprint) { return Err(TicketError::OperationFingerprintMismatch { operation_key: request.operation_key.clone(), }); } if ticket.meta.workflow_state != TicketWorkflowState::Ready - || ticket.meta.repository_id.as_deref() != Some(target.repository_id.as_str()) - || ticket.meta.ref_selector.as_deref() != Some(target.ref_selector.as_str()) + || ticket.meta.targets != canonical_ticket_targets(&targets) { return Err(TicketError::StaleWorkflowState { expected: TicketWorkflowState::Ready.as_str().to_owned(), @@ -685,7 +830,7 @@ fn validate_generic_state_change( Ok(()) } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketItemEdit { pub title: Option, /// Whole-body replacement for legacy authoring surfaces. Prefer `body_replacement` @@ -693,11 +838,11 @@ pub struct TicketItemEdit { pub body: Option, #[serde(default)] pub body_replacement: Option, - pub target: Option, + pub targets: Option, pub author: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketBodyReplacement { pub old_string: String, pub new_string: String, @@ -729,8 +874,8 @@ impl TicketItemEdit { if let Some(replacement) = &self.body_replacement { replacement.validate()?; } - if let Some(target) = &self.target { - target.validate()?; + if let Some(targets) = &self.targets { + targets.validate()?; } Ok(()) @@ -740,7 +885,7 @@ impl TicketItemEdit { self.title.is_some() || self.body.is_some() || self.body_replacement.is_some() - || self.target.is_some() + || self.targets.is_some() } } @@ -787,7 +932,7 @@ impl TicketBodyReplacement { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketDependencyCheck { pub ticket: TicketSummary, pub blockers: Vec, @@ -796,7 +941,7 @@ pub struct TicketDependencyCheck { pub recommended_action: TicketWorkspaceNextAction, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketQueueOutcome { pub requested_ticket: String, pub queued_tickets: Vec, @@ -923,7 +1068,7 @@ impl TicketListQuery { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketRef { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -932,7 +1077,19 @@ pub struct TicketRef { pub status: TicketStatus, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + schemars::JsonSchema, +)] #[serde(rename_all = "snake_case")] pub enum TicketRelationKind { DependsOn, @@ -971,7 +1128,7 @@ impl fmt::Display for TicketRelationKind { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct NewTicketRelation { pub kind: TicketRelationKind, pub target: String, @@ -979,7 +1136,7 @@ pub struct NewTicketRelation { pub author: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct TicketRelation { pub ticket_id: String, @@ -991,7 +1148,7 @@ pub struct TicketRelation { pub at: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct DerivedTicketRelation { pub source_ticket: String, pub inverse_kind: String, @@ -1001,7 +1158,7 @@ pub struct DerivedTicketRelation { pub at: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketRelationBlocker { pub blocking_ticket: String, pub reason_kind: String, @@ -1010,14 +1167,14 @@ pub struct TicketRelationBlocker { pub blocking_state: TicketWorkflowState, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketRelationNotice { pub related_ticket: String, pub kind: TicketRelationKind, pub message: String, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketRelationView { pub outgoing: Vec, pub incoming: Vec, @@ -1033,7 +1190,7 @@ pub enum TicketWorkspaceActionPriority { Background, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TicketWorkspaceNextAction { Clarify, @@ -1057,7 +1214,7 @@ pub struct TicketWorkspaceStateOverlay { pub workflow_state: TicketWorkflowState, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketQueueGuard { pub can_queue_for_orchestrator: bool, pub reason: Option, @@ -1473,7 +1630,19 @@ fn format_workspace_relation_blockers(blockers: &[&TicketRelationBlocker]) -> St formatted } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + schemars::JsonSchema, +)] #[serde(rename_all = "snake_case")] pub enum OrchestrationPlanKind { Before, @@ -1533,7 +1702,7 @@ impl fmt::Display for OrchestrationPlanKind { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct AcceptedOrchestrationPlan { pub summary: String, @@ -1545,7 +1714,7 @@ pub struct AcceptedOrchestrationPlan { pub role_plan: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct NewOrchestrationPlanRecord { pub kind: OrchestrationPlanKind, pub related_ticket: Option, @@ -1554,7 +1723,7 @@ pub struct NewOrchestrationPlanRecord { pub author: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct OrchestrationPlanRecord { pub id: String, @@ -1570,7 +1739,7 @@ pub struct OrchestrationPlanRecord { pub at: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketMeta { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1590,13 +1759,12 @@ pub struct TicketMeta { pub workflow_state_explicit: bool, pub queued_by: Option, pub queued_at: Option, - #[serde(rename = "repository_key")] - pub repository_id: Option, - pub ref_selector: Option, + #[serde(default)] + pub targets: Vec, pub raw: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketSummary { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1651,13 +1819,13 @@ pub struct SqliteTicketListPage { pub next: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketDocument { pub body: MarkdownText, pub raw_frontmatter: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketEvent { pub kind: TicketEventKind, pub author: Option, @@ -1673,13 +1841,13 @@ pub struct TicketEvent { pub attributes: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketArtifactRef { /// Path relative to the ticket's `artifacts/` directory. pub relative_path: PathBuf, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct Ticket { pub meta: TicketMeta, pub document: TicketDocument, @@ -1689,20 +1857,20 @@ pub struct Ticket { pub resolution: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub enum TicketDoctorSeverity { Error, Warning, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketDoctorDiagnostic { pub severity: TicketDoctorSeverity, pub message: String, pub path: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketDoctorReport { pub diagnostics: Vec, } @@ -2502,10 +2670,11 @@ impl SqliteTicketBackend { fn insert_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> { conn.execute(r#"INSERT INTO typed_tickets - (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution, repository_id, ref_selector) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)"#, - params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str()), ticket.meta.repository_id, ticket.meta.ref_selector] + (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)"#, + params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str())] ).map_err(sqlite_err)?; + self.replace_ticket_targets(conn, &ticket.meta.id, &ticket.meta.targets)?; self.insert_ordered_values( conn, "typed_ticket_labels", @@ -2532,6 +2701,67 @@ impl SqliteTicketBackend { Ok(()) } + fn replace_ticket_targets( + &self, + conn: &Connection, + ticket_id: &str, + targets: &[TicketTarget], + ) -> Result<()> { + validate_ticket_targets(targets)?; + conn.execute( + "DELETE FROM typed_ticket_targets WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id], + ) + .map_err(sqlite_err)?; + for (ordinal, target) in targets.iter().enumerate() { + conn.execute( + "INSERT INTO typed_ticket_targets + (workspace_id, ticket_id, ordinal, repository_key, ref_selector, access) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + self.workspace_id, + ticket_id, + ordinal as i64, + target.repository_key, + target.ref_selector, + target.access.as_str(), + ], + ) + .map_err(sqlite_err)?; + } + Ok(()) + } + + fn load_ticket_targets(&self, conn: &Connection, ticket_id: &str) -> Result> { + let mut statement = conn + .prepare( + "SELECT repository_key, ref_selector, access + FROM typed_ticket_targets + WHERE workspace_id = ?1 AND ticket_id = ?2 + ORDER BY ordinal ASC", + ) + .map_err(sqlite_err)?; + let rows = statement + .query_map(params![self.workspace_id, ticket_id], |row| { + let access = row.get::<_, String>(2)?; + let access = TicketTargetAccess::parse(&access).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + format!("invalid Ticket target access {access:?}").into(), + ) + })?; + Ok(TicketTarget { + repository_key: row.get(0)?, + ref_selector: row.get(1)?, + access, + }) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err) + } + fn insert_ordered_values( &self, conn: &Connection, @@ -2573,8 +2803,7 @@ impl SqliteTicketBackend { workflow_state_explicit: row.get::<_, i64>(13)? != 0, queued_by: row.get(14)?, queued_at: row.get(15)?, - repository_id: row.get(16)?, - ref_selector: row.get(17)?, + targets: Vec::new(), raw: BTreeMap::new(), }) } @@ -2582,9 +2811,10 @@ impl SqliteTicketBackend { fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result { #[cfg(test)] self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst); - let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, + let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?; meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, ticket_id)?; + meta.targets = self.load_ticket_targets(conn, ticket_id)?; meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?; meta.risk_flags = self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?; @@ -2749,7 +2979,7 @@ impl SqliteTicketBackend { conn: &Connection, filter: TicketListQuery, ) -> Result> { - let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; + let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; let rows = stmt .query_map(params![self.workspace_id], Self::ticket_meta_from_row) .map_err(sqlite_err)?; @@ -2863,10 +3093,7 @@ impl TicketBackend for SqliteTicketBackend { "ticket title must not be empty".to_string(), )); } - validate_ticket_target( - input.repository_id.as_deref(), - input.ref_selector.as_deref(), - )?; + validate_ticket_targets(&input.targets)?; let base_millis = unix_epoch_millis_now().map_err(|err| { TicketError::Conflict(format!("failed to read ticket id timestamp: {err}")) })?; @@ -2903,8 +3130,7 @@ impl TicketBackend for SqliteTicketBackend { workflow_state_explicit: true, queued_by: input.queued_by, queued_at: input.queued_at, - repository_id: input.repository_id, - ref_selector: input.ref_selector, + targets: input.targets, raw: BTreeMap::new(), }; let body = if input.body.as_str() == DEFAULT_TICKET_BODY { @@ -2955,7 +3181,7 @@ impl TicketBackend for SqliteTicketBackend { edit.validate_body_edit_request()?; if !edit.has_changes() { return Err(TicketError::Conflict( - "TicketEditItem requires at least one of title, body, body_replacement, or target" + "TicketEditItem requires at least one of title, body, body_replacement, or targets" .to_string(), )); } @@ -2966,7 +3192,7 @@ impl TicketBackend for SqliteTicketBackend { validate_required_event_value("author", author)?; } let ticket_id = self.resolve_ticket_id(conn, id)?; - if edit.target.is_some() { + if edit.targets.is_some() { let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state; if current != TicketWorkflowState::Planning { return Err(TicketError::Conflict(format!( @@ -3002,19 +3228,13 @@ impl TicketBackend for SqliteTicketBackend { if let Some(body) = updated_body.as_ref() { conn.execute("UPDATE typed_tickets SET body = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, body.as_str(), now]).map_err(sqlite_err)?; } - if let Some(target) = edit.target.as_ref() { - let (repository_id, ref_selector) = match target { - TicketTargetEdit::Set { - repository_id, - ref_selector, - } => (Some(repository_id.as_str()), ref_selector.as_deref()), - TicketTargetEdit::Clear => (None, None), + if let Some(targets) = edit.targets.as_ref() { + let targets = match targets { + TicketTargetsEdit::Set { targets } => targets.as_slice(), + TicketTargetsEdit::Clear => &[], }; - conn.execute( - "UPDATE typed_tickets SET repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2", - params![self.workspace_id, ticket_id, repository_id, ref_selector, now], - ) - .map_err(sqlite_err)?; + self.replace_ticket_targets(conn, &ticket_id, targets)?; + self.touch_ticket(conn, &ticket_id, &now)?; } let mut changes = Vec::new(); @@ -3024,8 +3244,8 @@ impl TicketBackend for SqliteTicketBackend { if !matches!(body_edit_audit, TicketBodyEditAudit::None) { changes.push("body"); } - if edit.target.is_some() { - changes.push("target"); + if edit.targets.is_some() { + changes.push("targets"); } let mut attributes = BTreeMap::new(); attributes.insert("changes".to_string(), changes.join(",")); @@ -3073,10 +3293,10 @@ impl TicketBackend for SqliteTicketBackend { let target_error = queue_tickets.iter().find_map(|candidate| { self.load_ticket(conn, candidate) .and_then(|ticket| { - resolve_ready_target( + resolve_ready_targets( self.target_authority.as_ref(), &self.workspace_id, - &ticket, + &ticket.meta.targets, ) }) .err() @@ -3235,18 +3455,31 @@ impl TicketBackend for SqliteTicketBackend { if validate_mark_ready_replay(&ticket, &request)? { return Ok(ticket); } - let target = resolve_ready_target( - self.target_authority.as_ref(), - &self.workspace_id, - &ticket, - )?; - let fingerprint = mark_ready_fingerprint(&ticket, &request, &target); if ticket.meta.workflow_state != TicketWorkflowState::Planning { return Err(TicketError::StaleWorkflowState { expected: TicketWorkflowState::Planning.as_str().to_owned(), actual: ticket.meta.workflow_state.as_str().to_owned(), }); } + let targets = resolve_ready_targets( + self.target_authority.as_ref(), + &self.workspace_id, + &ticket.meta.targets, + )?; + let fingerprint = mark_ready_fingerprint(&ticket, &request, &targets); + let encoded_targets = encoded_resolved_targets(&targets)?; + let target_summary = targets + .iter() + .map(|target| { + format!( + "{}@{} ({})", + target.repository_key, + target.ref_selector, + target.access.as_str() + ) + }) + .collect::>() + .join(", "); let reason = request .reason .as_deref() @@ -3291,23 +3524,32 @@ impl TicketBackend for SqliteTicketBackend { state_field: Some("state".to_owned()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!( - "Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.", - target.repository_id, target.ref_selector + "Implementation targets [{target_summary}] were validated and the Ticket was marked ready." )), references: Vec::new(), attributes: BTreeMap::from([ ("operation_key".to_owned(), request.operation_key), ("request_fingerprint".to_owned(), fingerprint), - ("repository_id".to_owned(), target.repository_id.clone()), - ("ref_selector".to_owned(), target.ref_selector.clone()), + ("fingerprint_version".to_owned(), "2".to_owned()), + ("targets".to_owned(), encoded_targets), ]), }, )?; - conn.execute( - "UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'", - params![self.workspace_id, ticket_id, target.repository_id, target.ref_selector, at], + let updated = conn.execute( + "UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'", + params![self.workspace_id, ticket_id, at], ) .map_err(sqlite_err)?; + if updated != 1 { + return Err(TicketError::Conflict( + "Ticket changed while ready targets were being persisted".to_owned(), + )); + } + self.replace_ticket_targets( + conn, + &ticket_id, + &canonical_ticket_targets(&targets), + )?; self.load_ticket(conn, &ticket_id) }) } @@ -3339,33 +3581,37 @@ impl TicketBackend for SqliteTicketBackend { } } - let mut targets = Vec::with_capacity(queued_tickets.len()); + let mut resolved_targets = Vec::with_capacity(queued_tickets.len()); for ticket_id in &queued_tickets { let ticket = self.load_ticket(conn, ticket_id)?; - let target = resolve_ready_target( + let targets = resolve_ready_targets( self.target_authority.as_ref(), &self.workspace_id, - &ticket, + &ticket.meta.targets, )?; - targets.push((ticket_id.clone(), target)); + resolved_targets.push((ticket_id.clone(), targets)); } let at = now_utc(); - for (ticket_id, target) in targets { + for (ticket_id, targets) in resolved_targets { let updated = conn.execute( - "UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, repository_id = ?5, ref_selector = ?6, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", - params![self.workspace_id, ticket_id, queued_by, at, target.repository_id, target.ref_selector], + "UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", + params![self.workspace_id, ticket_id, queued_by, at], ).map_err(sqlite_err)?; if updated != 1 { return Err(TicketError::Conflict(format!( "Ticket {ticket_id} changed while the dependency queue plan was being applied" ))); } + self.replace_ticket_targets( + conn, + &ticket_id, + &canonical_ticket_targets(&targets), + )?; let mut attributes = BTreeMap::from([ ("queued_by".to_owned(), queued_by.to_owned()), ("queued_at".to_owned(), at.clone()), - ("repository_id".to_owned(), target.repository_id), - ("ref_selector".to_owned(), target.ref_selector), + ("targets".to_owned(), encoded_resolved_targets(&targets)?), ("queue_root_ticket".to_owned(), requested_ticket.clone()), ]); if let Some(assignment_id) = self @@ -4281,22 +4527,38 @@ mod tests { fn resolve_target( &self, _workspace_id: &str, - repository_id: Option<&str>, + repository_key: &str, ref_selector: Option<&str>, ) -> Result { - let repository_id = repository_id.unwrap_or("main"); - if repository_id == "unknown" { + if repository_key == "unknown" { return Err(TicketError::UnknownTargetRepository( - repository_id.to_owned(), + repository_key.to_owned(), )); } Ok(ResolvedTicketTarget { - repository_id: repository_id.to_owned(), + repository_key: repository_key.to_owned(), ref_selector: ref_selector.unwrap_or("develop").to_owned(), + access: TicketTargetAccess::ReadOnly, }) } } + fn ticket_target( + repository_key: &str, + ref_selector: Option<&str>, + access: TicketTargetAccess, + ) -> TicketTarget { + TicketTarget { + repository_key: repository_key.to_owned(), + ref_selector: ref_selector.map(str::to_owned), + access, + } + } + + fn write_target(repository_key: &str) -> TicketTarget { + ticket_target(repository_key, None, TicketTargetAccess::ReadWrite) + } + fn backend(dir: &TempDir) -> SqliteTicketBackend { SqliteTicketBackend::open(dir.path().join("tickets.db"), "workspace-test") .unwrap() @@ -4305,30 +4567,46 @@ mod tests { fn assert_ticket_target_edit_semantics(backend: &B) { let mut input = NewTicket::new("Target Ticket"); - input.repository_id = Some("main".to_string()); - input.ref_selector = Some("feature/api".to_string()); + input.targets = vec![ticket_target( + "main", + Some("feature/api"), + TicketTargetAccess::ReadWrite, + )]; let created = backend.create(input).unwrap(); let ticket = backend .show(TicketIdOrSlug::Id(created.id.clone())) .unwrap(); - assert_eq!(ticket.meta.repository_id.as_deref(), Some("main")); - assert_eq!(ticket.meta.ref_selector.as_deref(), Some("feature/api")); + assert_eq!( + ticket.meta.targets, + vec![ticket_target( + "main", + Some("feature/api"), + TicketTargetAccess::ReadWrite, + )] + ); let edited = backend .edit_item( TicketIdOrSlug::Id(created.id.clone()), TicketItemEdit { - target: Some(TicketTargetEdit::Set { - repository_id: "secondary".to_string(), - ref_selector: None, + targets: Some(TicketTargetsEdit::Set { + targets: vec![ + write_target("secondary"), + ticket_target("docs", Some("main"), TicketTargetAccess::ReadOnly), + ], }), author: Some("tester".to_string()), ..Default::default() }, ) .unwrap(); - assert_eq!(edited.meta.repository_id.as_deref(), Some("secondary")); - assert_eq!(edited.meta.ref_selector, None); + assert_eq!( + edited.meta.targets, + vec![ + write_target("secondary"), + ticket_target("docs", Some("main"), TicketTargetAccess::ReadOnly), + ] + ); let edit_event = edited .events .iter() @@ -4337,20 +4615,19 @@ mod tests { .expect("item_edit event"); assert_eq!( edit_event.attributes.get("changes"), - Some(&"target".to_string()) + Some(&"targets".to_string()) ); let cleared = backend .edit_item( TicketIdOrSlug::Id(created.id), TicketItemEdit { - target: Some(TicketTargetEdit::Clear), + targets: Some(TicketTargetsEdit::Clear), ..Default::default() }, ) .unwrap(); - assert_eq!(cleared.meta.repository_id, None); - assert_eq!(cleared.meta.ref_selector, None); + assert!(cleared.meta.targets.is_empty()); } fn assert_partial_body_replacement_semantics(backend: &B) { @@ -4826,11 +5103,11 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut root_input = NewTicket::new("Ready root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some("main".to_string()); + root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); let mut middle_input = NewTicket::new("Queued middle"); middle_input.workflow_state = Some(TicketWorkflowState::Queued); - middle_input.repository_id = Some("main".to_string()); + middle_input.targets = vec![write_target("main")]; let middle = backend.create(middle_input).unwrap(); let leaf = backend.create(NewTicket::new("Planning leaf")).unwrap(); for (ticket, target) in [ @@ -4891,8 +5168,11 @@ mod tests { .unwrap(); let mut sqlite_input = NewTicket::new("SQLite ready Ticket"); sqlite_input.workflow_state = Some(TicketWorkflowState::Ready); - sqlite_input.repository_id = Some("main".to_string()); - sqlite_input.ref_selector = Some("develop".to_string()); + sqlite_input.targets = vec![ticket_target( + "main", + Some("develop"), + TicketTargetAccess::ReadWrite, + )]; let sqlite_ticket = sqlite.create(sqlite_input).unwrap(); let sqlite_check = sqlite .dependency_check(TicketIdOrSlug::Id(sqlite_ticket.id.clone())) @@ -4920,11 +5200,11 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut root_input = NewTicket::new("Ready root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some("main".to_string()); + root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); let mut done_input = NewTicket::new("Done dependency"); done_input.workflow_state = Some(TicketWorkflowState::Done); - done_input.repository_id = Some("main".to_string()); + done_input.targets = vec![write_target("main")]; let done = backend.create(done_input).unwrap(); for (ticket_id, target) in [ (root.id.clone(), done.id.clone()), @@ -4973,15 +5253,15 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut first_input = NewTicket::new("First ready Ticket"); first_input.workflow_state = Some(TicketWorkflowState::Ready); - first_input.repository_id = Some("main".to_string()); + first_input.targets = vec![write_target("main")]; let first = backend.create(first_input).unwrap(); let mut second_input = NewTicket::new("Second ready Ticket"); second_input.workflow_state = Some(TicketWorkflowState::Ready); - second_input.repository_id = Some("main".to_string()); + second_input.targets = vec![write_target("main")]; let second = backend.create(second_input).unwrap(); let mut third_input = NewTicket::new("Third ready Ticket"); third_input.workflow_state = Some(TicketWorkflowState::Ready); - third_input.repository_id = Some("main".to_string()); + third_input.targets = vec![write_target("main")]; let third = backend.create(third_input).unwrap(); for (ticket, target) in [ (first.id.clone(), second.id.clone()), @@ -5055,11 +5335,11 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut dependency_input = NewTicket::new("Invalid ready dependency"); dependency_input.workflow_state = Some(TicketWorkflowState::Ready); - dependency_input.repository_id = Some("unknown".to_string()); + dependency_input.targets = vec![write_target("unknown")]; let dependency = backend.create(dependency_input).unwrap(); let mut root_input = NewTicket::new("Queue root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some("main".to_string()); + root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); backend .add_ticket_relation( @@ -5108,11 +5388,11 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut dependency = NewTicket::new("Dependency"); - dependency.repository_id = Some("main".to_owned()); + dependency.targets = vec![write_target("main")]; dependency.workflow_state = Some(TicketWorkflowState::Ready); let dependency = backend.create(dependency).unwrap(); let mut implementation = NewTicket::new("Implementation"); - implementation.repository_id = Some("main".to_owned()); + implementation.targets = vec![write_target("main")]; let implementation = backend.create(implementation).unwrap(); backend .add_ticket_relation( @@ -5137,7 +5417,14 @@ mod tests { request.clone(), ) .unwrap(); - assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop")); + assert_eq!( + ready.meta.targets, + vec![ticket_target( + "main", + Some("develop"), + TicketTargetAccess::ReadWrite, + )] + ); let replay = backend .mark_ready(TicketIdOrSlug::Id(implementation.id.clone()), request) .unwrap(); @@ -5570,8 +5857,11 @@ mod tests { let mut ready_input = NewTicket::new("Ready Workflow"); ready_input.workflow_state = Some(TicketWorkflowState::Ready); - ready_input.repository_id = Some("main".to_owned()); - ready_input.ref_selector = Some("develop".to_owned()); + ready_input.targets = vec![ticket_target( + "main", + Some("develop"), + TicketTargetAccess::ReadWrite, + )]; let ready = backend.create(ready_input).unwrap(); backend .queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel") @@ -5591,6 +5881,15 @@ mod tests { assert_eq!(event.from.as_deref(), Some("ready")); assert_eq!(event.to.as_deref(), Some("queued")); assert_eq!(event.reason.as_deref(), Some("queued")); + let event_targets = serde_json::from_str::>( + event.attributes.get("targets").expect("canonical targets"), + ) + .unwrap(); + assert_eq!(event_targets.len(), 1); + assert_eq!(event_targets[0].repository_key, "main"); + assert_eq!(event_targets[0].access, TicketTargetAccess::ReadWrite); + assert!(!event.attributes.contains_key("repository_id")); + assert!(!event.attributes.contains_key("ref_selector")); } #[test] @@ -5619,7 +5918,10 @@ mod tests { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut input = NewTicket::new("Planning Ready"); - input.repository_id = Some("main".to_owned()); + input.targets = vec![ + write_target("main"), + ticket_target("docs", None, TicketTargetAccess::ReadOnly), + ]; let ticket = backend.create(input).unwrap(); let request = TicketMarkReady { operation_key: "ready-op-1".to_owned(), @@ -5635,9 +5937,37 @@ mod tests { .mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request) .unwrap(); assert_eq!(first.meta.workflow_state, TicketWorkflowState::Ready); - assert_eq!(first.meta.repository_id.as_deref(), Some("main")); - assert_eq!(first.meta.ref_selector.as_deref(), Some("develop")); + assert_eq!( + first.meta.targets, + vec![ + ticket_target("main", Some("develop"), TicketTargetAccess::ReadWrite,), + ticket_target("docs", Some("develop"), TicketTargetAccess::ReadOnly), + ] + ); assert_eq!(first.events, second.events); + let ready_event = first + .events + .iter() + .find(|event| event.attributes.contains_key("operation_key")) + .unwrap(); + let event_targets = serde_json::from_str::>( + ready_event.attributes.get("targets").unwrap(), + ) + .unwrap(); + assert_eq!(event_targets.len(), 2); + assert_eq!(event_targets[0].repository_key, "main"); + assert_eq!(event_targets[0].access, TicketTargetAccess::ReadWrite); + assert_eq!(event_targets[1].repository_key, "docs"); + assert_eq!(event_targets[1].access, TicketTargetAccess::ReadOnly); + assert_eq!( + ready_event + .attributes + .get("fingerprint_version") + .map(String::as_str), + Some("2") + ); + assert!(!ready_event.attributes.contains_key("repository_id")); + assert!(!ready_event.attributes.contains_key("ref_selector")); assert_eq!( first .events @@ -5664,6 +5994,97 @@ mod tests { )); } + #[test] + fn target_validation_rejects_duplicates_and_invalid_write_layouts_without_state_change() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + + let mut duplicate = NewTicket::new("Duplicate targets"); + duplicate.targets = vec![ + write_target("main"), + ticket_target("main", Some("docs"), TicketTargetAccess::ReadOnly), + ]; + assert!(matches!( + backend.create(duplicate), + Err(TicketError::DuplicateTargetRepository(repository)) if repository == "main" + )); + + for (name, targets, expected_count) in [ + ( + "No write target", + vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)], + 0, + ), + ( + "Multiple write targets", + vec![write_target("main"), write_target("docs")], + 2, + ), + ] { + let mut input = NewTicket::new(name); + input.targets = targets; + let ticket = backend.create(input).unwrap(); + let error = backend + .mark_ready( + TicketIdOrSlug::Id(ticket.id.clone()), + TicketMarkReady { + operation_key: format!("ready-{expected_count}"), + reason: None, + author: None, + intake_summary: None, + }, + ) + .unwrap_err(); + assert!(matches!( + error, + TicketError::InvalidReadWriteTargetCount(count) if count == expected_count + )); + let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Planning); + assert!( + unchanged + .events + .iter() + .all(|event| !event.attributes.contains_key("operation_key")) + ); + } + } + + #[test] + fn targets_are_locked_after_planning_and_queue_revalidates_every_target() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut ready_input = NewTicket::new("Ready with unknown reference target"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + ready_input.targets = vec![ + ticket_target("main", Some("develop"), TicketTargetAccess::ReadWrite), + ticket_target("unknown", Some("main"), TicketTargetAccess::ReadOnly), + ]; + let ticket = backend.create(ready_input).unwrap(); + + let edit_error = backend + .edit_item( + TicketIdOrSlug::Id(ticket.id.clone()), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Clear), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(edit_error.to_string().contains("locked after planning")); + + let queue_error = backend + .queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "orchestrator") + .unwrap_err(); + assert!(matches!( + queue_error, + TicketError::UnknownTargetRepository(repository) if repository == "unknown" + )); + let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); + assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Ready); + assert!(unchanged.meta.queued_at.is_none()); + } + #[test] fn close_sets_state_closed() { let tmp = TempDir::new().unwrap(); @@ -5744,6 +6165,7 @@ mod tests { let backend = backend(&tmp); let mut waiter_input = NewTicket::new("Ready After Queued Dependency"); waiter_input.workflow_state = Some(TicketWorkflowState::Ready); + waiter_input.targets = vec![write_target("main")]; let waiter = backend.create(waiter_input).unwrap(); let mut dependency_input = NewTicket::new("Queued Dependency"); dependency_input.workflow_state = Some(TicketWorkflowState::Queued); @@ -5769,6 +6191,7 @@ mod tests { let mut incoming_input = NewTicket::new("Ready After Inprogress Blocker"); incoming_input.workflow_state = Some(TicketWorkflowState::Ready); + incoming_input.targets = vec![write_target("main")]; let incoming = backend.create(incoming_input).unwrap(); let mut blocker_input = NewTicket::new("Inprogress Blocker"); blocker_input.workflow_state = Some(TicketWorkflowState::InProgress); diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index d95e5cc15..6b8f3c990 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,7 +7,9 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7; +const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; +const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; #[derive(Clone, Copy)] struct Migration { @@ -18,7 +20,7 @@ struct Migration { const MIGRATIONS: &[Migration] = &[Migration { version: LATEST_SQLITE_TICKET_SCHEMA_VERSION, - name: "ticket schema baseline", + name: BASELINE_MIGRATION_NAME, apply: create_latest_ticket_schema, }]; @@ -40,6 +42,7 @@ struct ExpectedForeignKey { const OWNED_TABLES: &[&str] = &[ "typed_tickets", + "typed_ticket_targets", "typed_ticket_labels", "typed_ticket_risk_flags", "typed_ticket_raw_frontmatter", @@ -91,8 +94,15 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[ column("queued_by", "TEXT", false, 0), column("queued_at", "TEXT", false, 0), column("resolution", "TEXT", false, 0), - column("repository_id", "TEXT", false, 0), +]; + +const TARGET_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("ordinal", "INTEGER", true, 3), + column("repository_key", "TEXT", true, 0), column("ref_selector", "TEXT", false, 0), + column("access", "TEXT", true, 0), ]; const LABEL_COLUMNS: &[ExpectedColumn] = &[ @@ -273,6 +283,30 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { ], ) .map_err(sqlite_err)?; + } else if applied + == BTreeMap::from([( + PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION, + BASELINE_MIGRATION_NAME.to_string(), + )]) + { + migrate_v6_to_v7(connection)?; + connection + .execute( + "DELETE FROM ticket_schema_migrations WHERE version = ?1", + params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION], + ) + .map_err(sqlite_err)?; + connection + .execute( + "INSERT INTO ticket_schema_migrations (version, name, applied_at) + VALUES (?1, ?2, ?3)", + params![ + LATEST_SQLITE_TICKET_SCHEMA_VERSION, + BASELINE_MIGRATION_NAME, + chrono::Utc::now().to_rfc3339() + ], + ) + .map_err(sqlite_err)?; } else { validate_applied_migrations(&applied)?; } @@ -321,6 +355,7 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { for (table, columns, foreign_keys) in [ ("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS), + ("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS), ("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS), ( "typed_ticket_risk_flags", @@ -362,6 +397,14 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); } + collect_index_diagnostics( + connection, + "typed_ticket_targets", + None, + true, + &["workspace_id", "ticket_id", "repository_key"], + &mut diagnostics, + ); collect_column_diagnostics( connection, "workspace_resource_keys", @@ -431,6 +474,144 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { .map_err(sqlite_err) } +fn migrate_v6_to_v7(connection: &Connection) -> Result<()> { + connection + .execute_batch( + "CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE + );", + ) + .map_err(sqlite_err)?; + + let legacy_targets = { + let mut statement = connection + .prepare( + "SELECT workspace_id, ticket_id, repository_id, ref_selector + FROM typed_tickets + WHERE repository_id IS NOT NULL OR ref_selector IS NOT NULL + ORDER BY workspace_id, ticket_id", + ) + .map_err(sqlite_err)?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err)? + }; + for (workspace_id, ticket_id, repository_key, ref_selector) in legacy_targets { + let repository_key = repository_key.ok_or_else(|| { + TicketError::Sqlite(format!( + "legacy Ticket {ticket_id:?} in Workspace {workspace_id:?} has ref_selector without repository_id" + )) + })?; + connection + .execute( + "INSERT INTO typed_ticket_targets + (workspace_id, ticket_id, ordinal, repository_key, ref_selector, access) + VALUES (?1, ?2, 0, ?3, ?4, 'read_write')", + params![workspace_id, ticket_id, repository_key, ref_selector], + ) + .map_err(sqlite_err)?; + } + + let legacy_event_targets = { + let mut statement = connection + .prepare( + "SELECT repository.workspace_id, repository.ticket_id, repository.event_index, + repository.value, selector.value + FROM typed_ticket_event_attributes AS repository + JOIN typed_ticket_event_attributes AS selector + ON selector.workspace_id = repository.workspace_id + AND selector.ticket_id = repository.ticket_id + AND selector.event_index = repository.event_index + AND selector.key = 'ref_selector' + WHERE repository.key = 'repository_id' + ORDER BY repository.workspace_id, repository.ticket_id, repository.event_index", + ) + .map_err(sqlite_err)?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err)? + }; + for (workspace_id, ticket_id, event_index, repository_key, ref_selector) in legacy_event_targets + { + let targets = serde_json::to_string(&[serde_json::json!({ + "repository_key": repository_key, + "ref_selector": ref_selector, + "access": "read_write", + })]) + .map_err(sqlite_err)?; + connection + .execute( + "INSERT OR IGNORE INTO typed_ticket_event_attributes + (workspace_id, ticket_id, event_index, key, value) + VALUES (?1, ?2, ?3, 'targets', ?4)", + params![workspace_id, ticket_id, event_index, targets], + ) + .map_err(sqlite_err)?; + connection + .execute( + "INSERT OR IGNORE INTO typed_ticket_event_attributes + (workspace_id, ticket_id, event_index, key, value) + SELECT ?1, ?2, ?3, 'fingerprint_version', '1' + WHERE EXISTS ( + SELECT 1 FROM typed_ticket_event_attributes + WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = ?3 + AND key = 'request_fingerprint' + )", + params![workspace_id, ticket_id, event_index], + ) + .map_err(sqlite_err)?; + } + connection + .execute( + "DELETE FROM typed_ticket_event_attributes AS legacy + WHERE legacy.key IN ('repository_id', 'ref_selector') + AND EXISTS ( + SELECT 1 FROM typed_ticket_event_attributes AS canonical + WHERE canonical.workspace_id = legacy.workspace_id + AND canonical.ticket_id = legacy.ticket_id + AND canonical.event_index = legacy.event_index + AND canonical.key = 'targets' + )", + [], + ) + .map_err(sqlite_err)?; + + connection + .execute_batch( + "ALTER TABLE typed_tickets DROP COLUMN repository_id; + ALTER TABLE typed_tickets DROP COLUMN ref_selector;", + ) + .map_err(sqlite_err) +} + fn load_applied_migrations(connection: &Connection) -> Result> { let mut statement = connection .prepare("SELECT version, name FROM ticket_schema_migrations ORDER BY version") @@ -842,6 +1023,124 @@ mod tests { assert_eq!(load_applied_migrations(&connection).unwrap(), versions); } + #[test] + fn migrates_single_target_rows_to_ordered_read_write_targets() { + let connection = Connection::open_in_memory().unwrap(); + create_latest_ticket_schema(&connection).unwrap(); + connection + .execute_batch( + "DROP TABLE typed_ticket_targets; + ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; + ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; + CREATE TABLE ticket_schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + ); + INSERT INTO ticket_schema_migrations (version, name, applied_at) + VALUES (6, 'ticket schema baseline', '2026-09-23T00:00:00Z'); + INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit, repository_id, ref_selector + ) VALUES ( + 'workspace-1', 'ticket-1', 'ticket-1', 'Migrated', 'open', 'task', 'P2', + 'body', 'ready', 1, 'main', 'develop' + ); + INSERT INTO typed_ticket_events ( + workspace_id, ticket_id, event_index, kind, body + ) VALUES ( + 'workspace-1', 'ticket-1', 0, 'state_changed', 'ready' + ); + INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES + ('workspace-1', 'ticket-1', 0, 'repository_id', 'main'), + ('workspace-1', 'ticket-1', 0, 'ref_selector', 'develop'), + ('workspace-1', 'ticket-1', 0, 'request_fingerprint', 'legacy');", + ) + .unwrap(); + + migrate_sqlite_ticket_schema(&connection).unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); + + let target = connection + .query_row( + "SELECT ordinal, repository_key, ref_selector, access + FROM typed_ticket_targets + WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1'", + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .unwrap(); + assert_eq!( + target, + ( + 0, + "main".to_owned(), + Some("develop".to_owned()), + "read_write".to_owned() + ) + ); + let columns = load_columns(&connection, "typed_tickets").unwrap(); + assert!( + columns + .iter() + .all(|column| { column.name != "repository_id" && column.name != "ref_selector" }) + ); + let encoded_targets: String = connection + .query_row( + "SELECT value FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1' + AND event_index = 0 AND key = 'targets'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&encoded_targets).unwrap(), + serde_json::json!([{ + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }]) + ); + let migrated_attributes = { + let mut statement = connection + .prepare( + "SELECT key, value FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-1' AND ticket_id = 'ticket-1' + AND event_index = 0 ORDER BY key", + ) + .unwrap(); + statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert_eq!( + migrated_attributes + .get("fingerprint_version") + .map(String::as_str), + Some("1") + ); + assert!(!migrated_attributes.contains_key("repository_id")); + assert!(!migrated_attributes.contains_key("ref_selector")); + assert_eq!( + load_applied_migrations(&connection).unwrap(), + BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())]) + ); + } + #[test] fn rejects_unknown_future_migration_history_without_changing_schema() { let connection = Connection::open_in_memory().unwrap(); @@ -856,7 +1155,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 6 baseline marker" + "migration history must contain only the canonical version 7 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -878,7 +1177,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 6 baseline marker" + "migration history must contain only the canonical version 7 baseline marker" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 5acbec2ee..d04b6d97f 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -134,11 +134,11 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` const IMPLEMENTATION_REPORT_DESCRIPTION: &str = "Append a typed Ticket implementation_report event. `body` is Markdown."; const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \ -The backend atomically validates and normalizes the persisted repository/ref target, records one typed \ -state_changed event, and transitions planning -> ready. `reason` is optional."; +The backend atomically validates and normalizes every persisted repository target, requires exactly one \ +read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional."; const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \ -The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \ -state_changed event, effective target, and planning -> ready transition atomically."; +The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \ +state_changed event, effective targets, and planning -> ready transition atomically."; const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \ Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \ requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged."; @@ -397,12 +397,9 @@ struct TicketCreateParams { /// Optional queued_at frontmatter value. #[serde(default)] queued_at: Option, - /// Optional target Workspace repository id. + /// Ordered implementation targets. Selectors may be omitted while planning. #[serde(default)] - repository_key: Option, - /// Optional target Git ref selector. Requires `repository_key`. - #[serde(default)] - ref_selector: Option, + targets: Vec, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -424,9 +421,9 @@ struct TicketEditItemParams { /// Replace every occurrence of `old_string`; by default exactly one occurrence is required. #[serde(default)] replace_all: bool, - /// Optional target repository/ref update. + /// Optional ordered targets replacement or clear operation. #[serde(default)] - target: Option, + targets: Option, } #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] @@ -941,8 +938,7 @@ impl Tool for TicketCreateTool { input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state); input.queued_by = None; input.queued_at = params.queued_at; - input.repository_id = params.repository_key; - input.ref_selector = params.ref_selector; + input.targets = params.targets; let created = self .backend @@ -986,7 +982,7 @@ impl Tool for TicketEditItemTool { title: params.title, body: params.body.map(MarkdownText::new), body_replacement, - target: params.target, + targets: params.targets, author: None, }; let ticket = self @@ -1170,8 +1166,7 @@ impl Tool for TicketMarkReadyTool { json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), - "repository_key": ticket.meta.repository_id, - "ref_selector": ticket.meta.ref_selector, + "targets": ticket.meta.targets, "ok": true }), )) @@ -1203,8 +1198,7 @@ impl Tool for TicketIntakeReadyTool { json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), - "repository_key": ticket.meta.repository_id, - "ref_selector": ticket.meta.ref_selector, + "targets": ticket.meta.targets, "ok": true }), )) @@ -1719,6 +1713,7 @@ fn ticket_json( "risk_flags": ticket.meta.risk_flags, "queued_by": ticket.meta.queued_by, "queued_at": ticket.meta.queued_at, + "targets": ticket.meta.targets, }, "body": truncate_text(ticket.document.body.as_str(), body_max_bytes), "events": { @@ -1937,16 +1932,25 @@ mod tests { fn resolve_target( &self, _workspace_id: &str, - repository_key: Option<&str>, + repository_key: &str, ref_selector: Option<&str>, ) -> crate::Result { Ok(crate::ResolvedTicketTarget { - repository_id: repository_key.unwrap_or("main").to_owned(), + repository_key: repository_key.to_owned(), ref_selector: ref_selector.unwrap_or("develop").to_owned(), + access: crate::TicketTargetAccess::ReadOnly, }) } } + fn write_target(repository_key: &str) -> crate::TicketTarget { + crate::TicketTarget { + repository_key: repository_key.to_owned(), + ref_selector: None, + access: crate::TicketTargetAccess::ReadWrite, + } + } + fn backend(temp: &TempDir) -> crate::SqliteTicketBackend { sqlite_backend(temp) } @@ -2076,7 +2080,18 @@ mod tests { .execute( &json!({ "title": "Tool Created", - "body": "## Background\n\nCreated by tool.\n" + "body": "## Background\n\nCreated by tool.\n", + "targets": [ + { + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }, + { + "repository_key": "docs", + "access": "read_only" + } + ] }) .to_string(), Default::default(), @@ -2114,6 +2129,11 @@ mod tests { .unwrap(); assert!(shown.summary.contains(&id)); let shown_content = shown.content.unwrap(); + let shown_json: Value = serde_json::from_str(&shown_content).unwrap(); + assert_eq!(shown_json["meta"]["targets"][0]["repository_key"], "main"); + assert_eq!(shown_json["meta"]["targets"][0]["access"], "read_write"); + assert_eq!(shown_json["meta"]["targets"][1]["repository_key"], "docs"); + assert_eq!(shown_json["meta"]["targets"][1]["access"], "read_only"); assert!(shown_content.contains("Created by tool")); assert!(!shown_content.contains("legacy_ticket")); assert!(!shown_content.contains("needs_preflight")); @@ -2574,10 +2594,10 @@ mod tests { let temp = TempDir::new().unwrap(); let inner = sqlite_backend(&temp); let mut dependency_input = NewTicket::new("Dependency"); - dependency_input.repository_id = Some("main".to_string()); + dependency_input.targets = vec![write_target("main")]; let dependency = inner.create(dependency_input).unwrap(); let mut target_input = NewTicket::new("Target"); - target_input.repository_id = Some("main".to_string()); + target_input.targets = vec![write_target("main")]; let target = inner.create(target_input).unwrap(); inner .add_ticket_relation( @@ -2669,7 +2689,7 @@ mod tests { let temp = TempDir::new().unwrap(); let backend = backend(&temp); let mut input = NewTicket::new("Workflow Tool"); - input.repository_id = Some("main".to_owned()); + input.targets = vec![write_target("main")]; let created = backend.create(input).unwrap(); let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady"); let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); @@ -2752,7 +2772,7 @@ mod tests { let temp = TempDir::new().unwrap(); let backend = backend(&temp); let mut input = NewTicket::new("Intake Workflow"); - input.repository_id = Some("main".to_owned()); + input.targets = vec![write_target("main")]; let created = backend.create(input).unwrap(); tool_by_name(backend.clone(), "TicketIntakeReady") .execute( @@ -2768,7 +2788,10 @@ mod tests { .unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); - assert_eq!(record.meta.ref_selector.as_deref(), Some("develop")); + assert_eq!( + record.meta.targets[0].ref_selector.as_deref(), + Some("develop") + ); assert_eq!( record .events @@ -3088,6 +3111,15 @@ mod tests { assert!(!create_schema.contains("needs_preflight")); assert!(!create_schema.contains("action_required")); assert!(!create_schema.contains("attention_required")); + let create_properties = input_schema("TicketCreate")["properties"] + .as_object() + .unwrap() + .clone(); + assert!(create_properties.contains_key("targets")); + assert!(!create_properties.contains_key("repository_key")); + assert!(!create_properties.contains_key("ref_selector")); + assert!(create_schema.contains("read_only")); + assert!(create_schema.contains("read_write")); let plan_record_schema = tools .iter() .map(|definition| definition().0) @@ -3115,6 +3147,13 @@ mod tests { assert!(edit_schema.contains("old_string")); assert!(edit_schema.contains("new_string")); assert!(edit_schema.contains("replace_all")); + assert!(edit_schema.contains("targets")); + assert!( + !input_schema("TicketEditItem")["properties"] + .as_object() + .unwrap() + .contains_key("target") + ); for name in [ "TicketCreate", "TicketEditItem", diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index 22c70a495..b5b7a99c8 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -13,6 +13,7 @@ async-trait.workspace = true fs-operation.workspace = true manifest.workspace = true reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } +schemars.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true sha2.workspace = true diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 00fa2330f..0a3f7e8aa 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -5,6 +5,7 @@ //! optional so Runtime servers can share these DTOs without depending on a //! client stack. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::{ @@ -16,7 +17,7 @@ use crate::{ }; /// Opaque Runtime-owned identifier for one ephemeral Workdir session. -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] pub struct WorkdirSessionId(String); @@ -37,7 +38,7 @@ impl WorkdirSessionId { } /// Open a fresh session for a persisted Workdir identity. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct OpenWorkdirSessionRequest { /// Optional Runtime Worker whose persisted binding establishes workspace /// ownership of the Workdir. Runtime servers reject cross-workspace owners. @@ -45,7 +46,7 @@ pub struct OpenWorkdirSessionRequest { pub owner_worker_id: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct OpenWorkdirSessionResponse { pub session_id: WorkdirSessionId, pub workdir_id: WorkdirId, @@ -53,7 +54,7 @@ pub struct OpenWorkdirSessionResponse { } /// One provider-side Workdir operation. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde( tag = "operation", content = "request", @@ -77,14 +78,14 @@ pub enum WorkdirSessionOperation { } /// Wire envelope for one provider operation. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkdirSessionOperationRequest { pub operation: WorkdirSessionOperation, } /// Typed result paired with [`WorkdirSessionOperation`]. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde( tag = "operation", content = "result", @@ -174,7 +175,7 @@ pub async fn dispatch_workdir_session_operation( } /// Stable, host-path-free error code crossing the Runtime boundary. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum WorkdirTransportErrorCode { NotFound, @@ -238,7 +239,7 @@ impl WorkdirTransportErrorCode { } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct WorkdirTransportError { pub code: WorkdirTransportErrorCode, pub message: String, diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index 6a13a5b46..62b613d45 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -20,6 +20,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use async_trait::async_trait; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; @@ -45,7 +46,7 @@ pub use scope::{ }; /// Persistent, opaque identity of one materialized Workdir. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct Workdir { id: WorkdirId, } @@ -63,7 +64,7 @@ impl Workdir { } /// Opaque Workdir identifier assigned by the materialization authority. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] pub struct WorkdirId(String); @@ -79,7 +80,7 @@ impl std::fmt::Display for WorkdirId { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum WorkdirSessionCapability { Read, @@ -90,7 +91,7 @@ pub enum WorkdirSessionCapability { Command, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct WorkdirSessionCapabilities { bits: u8, } @@ -139,6 +140,12 @@ impl WorkdirSessionCapabilities { } } + pub const fn intersection(self, other: Self) -> Self { + Self { + bits: self.bits & other.bits, + } + } + pub const fn supports(self, capability: WorkdirSessionCapability) -> bool { let bit = match capability { WorkdirSessionCapability::Read => Self::READ, diff --git a/crates/workdir/src/operation.rs b/crates/workdir/src/operation.rs index 5af8b54a3..c0c242774 100644 --- a/crates/workdir/src/operation.rs +++ b/crates/workdir/src/operation.rs @@ -1,15 +1,18 @@ use std::path::PathBuf; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] pub struct CommandHandle(pub String); -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct CommandRequest { pub command: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub timeout_secs: u64, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub output_limit: usize, /// Workdir-relative command directory. Providers validate it against the /// active session before process start. @@ -25,15 +28,17 @@ pub struct CommandRequest { pub tool_call_id: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct CommandOutputRequest { pub handle: CommandHandle, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub cursor: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub limit: usize, pub wait: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum CommandStatus { Running, @@ -43,35 +48,41 @@ pub enum CommandStatus { Cancelled, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum CommandStream { Stdout, Stderr, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] pub struct CommandStreamSlice { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub start_offset: u64, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub end_offset: u64, pub content: String, pub truncated: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct CommandSnapshot { pub command_id: String, pub tool_call_id: Option, pub status: CommandStatus, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub started_at_ms: u64, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub observed_at_ms: u64, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub last_output_at_ms: Option, pub stdout: CommandStreamSlice, pub stderr: CommandStreamSlice, + #[schemars(range(min = -2147483648_i32, max = 2147483647_i32))] pub exit_code: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum CommandEvent { Started { @@ -97,12 +108,14 @@ pub enum CommandEvent { }, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct CommandOutput { pub status: CommandStatus, + #[schemars(range(min = -2147483648_i32, max = 2147483647_i32))] pub exit_code: Option, pub timed_out: bool, pub content: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub next_cursor: Option, pub truncated: bool, /// Complete output retained by the provider when `truncated` is true. diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index abd7d1024..4f6297792 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -9,6 +9,7 @@ use fs_operation::{ ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, }; use manifest::SymlinkPolicy; +use schemars::JsonSchema; use tokio::sync::broadcast; const MAX_SCOPED_COMMANDS: usize = 16; @@ -19,25 +20,26 @@ use crate::{ WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, }; -#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum WorkdirToolScopePermission { Read, Write, } -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkdirToolScopeRule { pub target: FsPath, pub permission: WorkdirToolScopePermission, pub recursive: bool, #[serde(default)] + #[schemars(with = "String")] pub symlink_policy: SymlinkPolicy, } /// Provider-side check for one operation under an attenuated tool scope. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkdirScopeAuthorizationRequest { pub rules: Vec, @@ -46,14 +48,14 @@ pub struct WorkdirScopeAuthorizationRequest { } /// Provider-side overlap comparison that keeps resolved host paths private. -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkdirScopeOverlapRequest { pub left: WorkdirToolScopeRule, pub right: WorkdirToolScopeRule, } -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkdirToolScope { pub rules: Vec, @@ -82,6 +84,16 @@ impl WorkdirToolBroker { /// Own the parent Worker's active session and mediate every scoped child operation. pub fn new(source: WorkdirSessionHandle) -> Self { let capabilities = source.capabilities(); + Self::with_capabilities(source, capabilities) + } + + /// Own a session behind an additional Backend-authored capability ceiling. + /// The ceiling can only attenuate provider capabilities; it can never add one. + pub fn with_capabilities( + source: WorkdirSessionHandle, + capabilities: WorkdirSessionCapabilities, + ) -> Self { + let capabilities = source.capabilities().intersection(capabilities); let (command_events, _) = broadcast::channel(64); let authority = Arc::new(ScopedWorkdirSession { source, diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index c945accd4..2e7c57582 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -4,9 +4,10 @@ //! transport projections. They intentionally do not expose provider/session //! handles, host paths, Runtime URLs, or credentials. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum WorkingDirectoryMaterializerKind { #[default] @@ -14,7 +15,7 @@ pub enum WorkingDirectoryMaterializerKind { ClientHostedExternal, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum WorkingDirectoryStatusKind { Active, @@ -42,7 +43,7 @@ impl std::fmt::Display for WorkingDirectoryStatusKind { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkingDirectoryCleanupTarget { pub kind: String, @@ -50,7 +51,7 @@ pub struct WorkingDirectoryCleanupTarget { pub repository_key: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkingDirectoryOccupancy { pub runtime_id: String, @@ -59,7 +60,7 @@ pub struct WorkingDirectoryOccupancy { pub linked_at: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct RuntimeWorkingDirectoryCleanupTarget { pub kind: String, @@ -67,7 +68,7 @@ pub struct RuntimeWorkingDirectoryCleanupTarget { pub repository_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct RuntimeWorkingDirectorySummary { pub working_directory_id: String, @@ -99,14 +100,14 @@ pub struct RuntimeWorkingDirectorySummary { pub occupied_by: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum WorkingDirectorySource { Repository { repository_key: String }, ExternalGrant { grant_id: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkingDirectorySummary { pub working_directory_id: String, @@ -146,7 +147,9 @@ impl WorkingDirectorySummary { 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)] +#[derive( + Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] #[serde(deny_unknown_fields)] pub struct RuntimeWorkerRef { pub runtime_id: String, @@ -164,7 +167,7 @@ impl RuntimeWorkerRef { } /// Immutable materialization provenance retained by Workspace inventory. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkingDirectoryProvenance { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -179,7 +182,7 @@ pub struct WorkingDirectoryProvenance { } /// Latest provider-neutral observation attached to Workspace inventory. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkingDirectoryCurrentObservation { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -247,7 +250,7 @@ mod tests { } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkspaceWorkdirSessionOperationRequest { /// Worker-local attachment alias selected by the calling tool router. diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 4970b1a84..40bb79f65 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -209,7 +209,9 @@ pub struct LogicalWorkdirAttachment { } fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities { - workdir::WorkdirSessionCapabilities::ALL + // Capability-less wire and persisted records fail closed instead of + // silently regaining write or command authority. + workdir::WorkdirSessionCapabilities::READ_ONLY } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -218,6 +220,9 @@ pub struct WorkingDirectoryAttachmentClaim { pub working_directory_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub relative_cwd: Option, + /// Backend-authoritative maximum capabilities for the attachment session. + #[serde(default = "default_workdir_session_capabilities")] + pub capabilities: workdir::WorkdirSessionCapabilities, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/worker-runtime/src/config_bundle.rs b/crates/worker-runtime/src/config_bundle.rs index a57af4e34..dd2b227d5 100644 --- a/crates/worker-runtime/src/config_bundle.rs +++ b/crates/worker-runtime/src/config_bundle.rs @@ -1,10 +1,13 @@ use crate::catalog::{ConfigBundleRef, ProfileSelector}; use crate::error::RuntimeError; use crate::profile_archive::{ - ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef, + ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, VerifiedProfileSourceArchive, }; -use crate::resource::{BackendResourceHandle, validate_resource_handle_text}; +use crate::resource::{ + BackendResourceHandle, BackendResourceKind, BackendResourceOperation, ResourceRedactionPolicy, + validate_resource_handle_text, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -162,6 +165,181 @@ impl ConfigBundle { } } +impl From for server_api::WorkspaceRuntimeConfigResponse { + fn from(bundle: ConfigBundle) -> Self { + let ConfigBundle { + metadata, + profiles, + declarations, + prompt_catalog, + profile_source_archive, + profile_source_archive_handle, + } = bundle; + Self { + metadata: metadata.into(), + profiles: profiles.into_iter().map(Into::into).collect(), + declarations: declarations.into_iter().map(Into::into).collect(), + prompt_catalog: prompt_catalog.map(|catalog| { + server_api::WorkspaceRuntimePromptCatalog { + templates: catalog.templates, + config_revision: catalog.config_revision, + source_digest: catalog.source_digest, + schema_fingerprint: catalog.schema_fingerprint, + toolchain_fingerprint: catalog.toolchain_fingerprint, + catalog_digest: catalog.catalog_digest, + } + }), + profile_source_archive: profile_source_archive.map(Into::into), + profile_source_archive_handle: profile_source_archive_handle.map(Into::into), + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigMetadata { + fn from(metadata: ConfigBundleMetadata) -> Self { + Self { + id: metadata.id, + digest: metadata.digest, + revision: metadata.revision, + workspace_id: metadata.workspace_id, + created_at: metadata.created_at, + provenance: metadata.provenance.into(), + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigProvenance { + fn from(provenance: ConfigBundleProvenance) -> Self { + Self { + source: provenance.source, + detail: provenance.detail, + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigProfile { + fn from(profile: ConfigProfileDescriptor) -> Self { + Self { + selector: profile.selector.into(), + label: profile.label, + } + } +} + +impl From for server_api::WorkspaceRuntimeProfileSelector { + fn from(selector: ProfileSelector) -> Self { + match selector { + ProfileSelector::Builtin(value) => Self::Builtin(value), + ProfileSelector::Named(value) => Self::Named(value), + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigDeclaration { + fn from(declaration: ConfigDeclaration) -> Self { + Self { + kind: declaration.kind.into(), + name: declaration.name, + reference: declaration.reference, + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigDeclarationKind { + fn from(kind: ConfigDeclarationKind) -> Self { + match kind { + ConfigDeclarationKind::SecretRef => Self::SecretRef, + ConfigDeclarationKind::MountGrant => Self::MountGrant, + ConfigDeclarationKind::NetworkPolicy => Self::NetworkPolicy, + ConfigDeclarationKind::ShellPolicy => Self::ShellPolicy, + ConfigDeclarationKind::GitPolicy => Self::GitPolicy, + ConfigDeclarationKind::CapabilityGrant => Self::CapabilityGrant, + ConfigDeclarationKind::Unsupported => Self::Unsupported, + } + } +} + +impl From for server_api::WorkspaceRuntimeProfileSourceArchive { + fn from(archive: ProfileSourceArchive) -> Self { + Self { + reference: archive.reference.into(), + content: archive.content, + } + } +} + +impl From for server_api::WorkspaceRuntimeProfileSourceArchiveRef { + fn from(reference: ProfileSourceArchiveRef) -> Self { + Self { + id: reference.id, + digest: reference.digest, + size_bytes: reference.size_bytes, + source_graph: reference.source_graph.into(), + } + } +} + +impl From for server_api::WorkspaceRuntimeProfileSourceGraph { + fn from(summary: ProfileSourceGraphSummary) -> Self { + Self { + source_count: summary.source_count, + total_source_bytes: summary.total_source_bytes, + entrypoints: summary.entrypoints, + import_count: summary.import_count, + } + } +} + +impl From for server_api::WorkspaceRuntimeResourceHandle { + fn from(handle: BackendResourceHandle) -> Self { + Self { + kind: handle.kind.into(), + workspace_id: handle.workspace_id, + scope_id: handle.scope_id, + runtime_id: handle.runtime_id, + worker_id: handle.worker_id, + resource_id: handle.resource_id, + digest: handle.digest, + operation: handle.operation.into(), + expires_at_unix_seconds: handle.expires_at_unix_seconds, + nonce: handle.nonce, + revision: handle.revision, + generation: handle.generation, + max_bytes: handle.max_bytes, + content_type: handle.content_type, + redaction: handle.redaction.into(), + audit_correlation_id: handle.audit_correlation_id, + profile_source_graph: handle.profile_source_graph.map(Into::into), + } + } +} + +impl From for server_api::WorkspaceRuntimeResourceKind { + fn from(kind: BackendResourceKind) -> Self { + match kind { + BackendResourceKind::ProfileSourceArchive => Self::ProfileSourceArchive, + BackendResourceKind::RepositorySshAccess => Self::RepositorySshAccess, + } + } +} + +impl From for server_api::WorkspaceRuntimeResourceOperation { + fn from(operation: BackendResourceOperation) -> Self { + match operation { + BackendResourceOperation::FetchArchive => Self::FetchArchive, + BackendResourceOperation::FetchOnce => Self::FetchOnce, + } + } +} + +impl From for server_api::WorkspaceRuntimeResourceRedactionPolicy { + fn from(policy: ResourceRedactionPolicy) -> Self { + match policy { + ResourceRedactionPolicy::RuntimeInternalOnly => Self::RuntimeInternalOnly, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConfigBundleMetadata { pub id: String, diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index b97101201..7d783f757 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -1801,6 +1801,11 @@ impl<'de> Deserialize<'de> for WorkerExecutionRecord { alias, working_directory_id: claim.working_directory_id, relative_cwd: claim.relative_cwd, + // The removed singular field represented a full local + // Workdir binding. Preserve that known legacy contract + // explicitly; missing capabilities on the current claim + // shape default to read-only instead. + capabilities: workdir::WorkdirSessionCapabilities::ALL, }); } } @@ -2474,10 +2479,41 @@ mod tests { assert_eq!(attachment.alias.as_str(), "workdir"); assert_eq!(attachment.working_directory_id, "wd-legacy"); assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi")); + assert_eq!( + attachment.capabilities, + workdir::WorkdirSessionCapabilities::ALL + ); let serialized = serde_json::to_value(execution).unwrap(); assert!(serialized["request"].get("working_directory").is_none()); } + #[test] + fn current_attachment_claim_without_capabilities_defaults_to_read_only() { + let worker_id = WorkerId::now_v7(); + let mut request = schema_v7_worker_document(worker_id)["request"].clone(); + request["workdir_attachments"] = serde_json::json!([{ + "alias": "docs", + "working_directory_id": "wd-docs" + }]); + let execution: WorkerExecutionRecord = serde_json::from_value(serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "request": request, + "binding": {}, + "restore_intent": "automatic" + })) + .unwrap(); + + assert_eq!( + execution.request.workdir_attachments[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + let serialized = serde_json::to_value(execution).unwrap(); + assert_eq!( + serialized["request"]["workdir_attachments"][0]["capabilities"], + serde_json::to_value(workdir::WorkdirSessionCapabilities::READ_ONLY).unwrap() + ); + } + #[test] fn worker_identity_round_trips_logical_workdir_attachments() { let worker_id = WorkerId::now_v7(); @@ -2512,7 +2548,7 @@ mod tests { [LogicalWorkdirAttachment { alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(), working_directory_id: "remote-workdir".to_string(), - capabilities: workdir::WorkdirSessionCapabilities::ALL, + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }] ); } diff --git a/crates/worker-runtime/src/resource.rs b/crates/worker-runtime/src/resource.rs index 9f76ce34d..8c38f5760 100644 --- a/crates/worker-runtime/src/resource.rs +++ b/crates/worker-runtime/src/resource.rs @@ -1,7 +1,7 @@ use crate::auth::BACKEND_RESOURCE_FETCH_PERMISSION; use crate::identity::WorkerId; use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex}; -use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient}; +use crate::workspace_request::RuntimeWorkspaceRequestClient; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -219,11 +219,6 @@ impl BackendResourceClient for HttpBackendResourceClient { &self, request: BackendResourceFetchRequest, ) -> Result { - let body = serde_json::to_vec(&request).map_err(|error| { - BackendResourceError::InvalidResponse { - message: error.to_string(), - } - })?; let endpoint = reqwest::Url::parse(&self.endpoint).map_err(|error| { BackendResourceError::Transport { message: error.to_string(), @@ -248,17 +243,17 @@ impl BackendResourceClient for HttpBackendResourceClient { .to_string(), } })?; - if !endpoint_suffix.starts_with('/') { + let expected_path = format!( + "/api/runtime/v1/workspaces/{}/resources/fetch", + client.workspace_id() + ); + if endpoint_suffix != expected_path { return Err(BackendResourceError::Unauthorized { message: "Workspace resource endpoint does not match its request client" .to_string(), }); } let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::CONTENT_TYPE, - reqwest::header::HeaderValue::from_static("application/json"), - ); if let Some(token) = self.bearer_token.as_deref() { let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) .map_err(|error| BackendResourceError::Transport { @@ -266,41 +261,57 @@ impl BackendResourceClient for HttpBackendResourceClient { })?; headers.insert(reqwest::header::AUTHORIZATION, value); } - let response = client - .execute(RuntimeWorkspaceRequest { - method: reqwest::Method::POST, - path_and_query: endpoint_suffix.to_string(), - body, + let request: server_api::RuntimeResourceFetchRequest = + serde_json::from_value(serde_json::to_value(&request).map_err(|error| { + BackendResourceError::InvalidResponse { + message: error.to_string(), + } + })?) + .map_err(|error| BackendResourceError::InvalidResponse { + message: error.to_string(), + })?; + let generated = client + .server_api_client( + BACKEND_RESOURCE_FETCH_PERMISSION, + None, headers, - permission: BACKEND_RESOURCE_FETCH_PERMISSION.to_string(), - worker_id: None, - timeout: Some(self.request_timeout), - max_response_bytes: 8 * 1024 * 1024, - }) + self.request_timeout, + 8 * 1024 * 1024, + ) + .map_err(|error| BackendResourceError::Transport { + message: error.to_string(), + })?; + match generated + .runtime_resource_fetch(client.workspace_id().to_string(), request) .await - .map_err(|error| { - if error.is_timeout() { - BackendResourceError::Timeout - } else { - BackendResourceError::Transport { + { + Ok(response) => { + serde_json::from_value(serde_json::to_value(&response).map_err(|error| { + BackendResourceError::InvalidResponse { message: error.to_string(), } - } - })?; - if response.status.is_success() { - serde_json::from_slice::(&response.body).map_err(|err| { - BackendResourceError::InvalidResponse { - message: err.to_string(), - } - }) - } else { - let status = response.status; - match serde_json::from_slice::(&response.body) { - Ok(error) => Err(error), - Err(err) => Err(BackendResourceError::Transport { - message: format!("backend resource fetch failed with HTTP {status}: {err}"), - }), + })?) + .map_err(|error| BackendResourceError::InvalidResponse { + message: error.to_string(), + }) } + Err(server_api::client_support::ClientError::Public { error, .. }) => { + serde_json::from_value(serde_json::to_value(error).map_err(|error| { + BackendResourceError::InvalidResponse { + message: error.to_string(), + } + })?) + .map_err(|error| BackendResourceError::InvalidResponse { + message: error.to_string(), + }) + .and_then(Err) + } + Err(server_api::client_support::ClientError::Failure( + server_api::client_support::ClientFailure::Timeout, + )) => Err(BackendResourceError::Timeout), + Err(error) => Err(BackendResourceError::Transport { + message: error.to_string(), + }), } } } @@ -437,16 +448,15 @@ mod tests { let base_url = format!("http://{address}"); let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); let handle = handle_for(b"archive-bytes"); - let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None) - .with_request_timeout(std::time::Duration::from_millis(25)) - .with_workspace_request_client( - RuntimeWorkspaceRequestClient::new( - "workspace-test", - base_url.clone(), - "runtime-test", - ) + let client = HttpBackendResourceClient::new( + format!("{base_url}/api/runtime/v1/workspaces/workspace-test/resources/fetch"), + None, + ) + .with_request_timeout(std::time::Duration::from_millis(25)) + .with_workspace_request_client( + RuntimeWorkspaceRequestClient::new("workspace-test", base_url.clone(), "runtime-test") .with_runtime_request_source(&identity, base_url), - ); + ); let error = client .fetch_resource(BackendResourceFetchRequest { diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 19ca2f55d..f8628d27c 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2217,8 +2217,13 @@ impl Runtime { let _ = worker.apply_worker_state(snapshot); } worker.restore_intent = restore_intent_for_status(worker.status); + let claims = worker + .request + .as_ref() + .map(|request| request.workdir_attachments.as_slice()) + .unwrap_or_default(); worker.logical_workdir_attachments = - logical_workdir_attachments_from_statuses(&workdir_attachments); + logical_workdir_attachments_from_statuses(&workdir_attachments, claims); worker.workdir_attachments = workdir_attachments; worker.detail() }; @@ -2869,8 +2874,13 @@ impl Runtime { candidate.execution_bound = true; candidate.status = WorkerStatus::Idle; candidate.restore_intent = WorkerRestoreIntent::Explicit; + let claims = candidate + .request + .as_ref() + .map(|request| request.workdir_attachments.as_slice()) + .unwrap_or_default(); candidate.logical_workdir_attachments = - logical_workdir_attachments_from_statuses(&workdir_attachments); + logical_workdir_attachments_from_statuses(&workdir_attachments, claims); candidate.workdir_attachments = workdir_attachments; state.workers.insert(worker_ref.worker_id, candidate); state.publish_worker_upsert(worker_ref.worker_id)?; @@ -3215,7 +3225,11 @@ impl RuntimeState { } }; let logical_workdir_attachments = if worker.logical_workdir_attachments.is_empty() { - logical_workdir_attachments_from_statuses(&worker.workdir_attachments) + let claims = request + .as_ref() + .map(|request| request.workdir_attachments.as_slice()) + .unwrap_or_default(); + logical_workdir_attachments_from_statuses(&worker.workdir_attachments, claims) } else { worker.logical_workdir_attachments }; @@ -4285,17 +4299,29 @@ fn requested_workdir_ids(request: &CreateWorkerRequest) -> Vec<&str> { fn logical_workdir_attachments_from_statuses( attachments: &[crate::catalog::WorkingDirectoryAttachmentStatus], + claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], ) -> Vec { attachments .iter() - .map(|attachment| LogicalWorkdirAttachment { - alias: attachment.alias.clone(), - working_directory_id: attachment - .working_directory - .summary - .working_directory_id - .clone(), - capabilities: workdir::WorkdirSessionCapabilities::ALL, + .map(|attachment| { + let working_directory_id = &attachment.working_directory.summary.working_directory_id; + let capabilities = match claims.iter().find(|claim| claim.alias == attachment.alias) { + Some(claim) if claim.working_directory_id == *working_directory_id => { + claim.capabilities + } + // An alias/id mismatch is invalid at the operation boundary. If + // corrupted persisted state reaches this compatibility path, + // fail closed rather than silently restoring write authority. + Some(_) => workdir::WorkdirSessionCapabilities::READ_ONLY, + // Runtime-materialized attachment requests predate capability + // claims and retain their existing full local-session behavior. + None => workdir::WorkdirSessionCapabilities::ALL, + }; + LogicalWorkdirAttachment { + alias: attachment.alias.clone(), + working_directory_id: working_directory_id.clone(), + capabilities, + } }) .collect() } @@ -6586,6 +6612,7 @@ mod tests { alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), working_directory_id: "workdir-idempotent".to_string(), relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, }]; let first = runtime.create_worker(request.clone()).unwrap(); @@ -6976,7 +7003,13 @@ mod tests { .unwrap(), }], }); - let request = task_request("uncertain create cleanup"); + let mut request = task_request("uncertain create cleanup"); + request.workdir_attachments = vec![WorkingDirectoryAttachmentClaim { + alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(), + working_directory_id: "wd-uncertain".to_string(), + relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + }]; let worker_ref = WorkerRef::new(request.worker_id); let error = runtime.create_worker(request).unwrap_err(); @@ -6988,6 +7021,16 @@ mod tests { let retained = runtime.worker_detail(&worker_ref).unwrap(); assert_eq!(retained.status, WorkerStatus::Idle); assert!(!retained.execution_metadata_available); + assert_eq!( + runtime + .lock() + .unwrap() + .worker(&worker_ref) + .unwrap() + .logical_workdir_attachments[0] + .capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); assert!(matches!( runtime.delete_worker(&worker_ref), Err(RuntimeError::InvalidRequest(_)) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index de9cf6e53..3fd83e22b 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -755,11 +755,25 @@ async fn fetch_workspace_config_http( Err("Workspace Config fetch requires the worker-runtime http-server feature".to_string()) } +fn initial_workdir_session_capabilities( + alias: &WorkdirAttachmentAlias, + claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], +) -> WorkdirSessionCapabilities { + claims + .iter() + .find(|claim| &claim.alias == alias) + .map(|claim| claim.capabilities) + // New Runtime materialization requests have no claim and retain their + // established full local-session behavior. + .unwrap_or(WorkdirSessionCapabilities::ALL) +} + fn runtime_local_workdir_session( workdir_id: &str, root: &Path, cwd: &Path, scope: manifest::SharedScope, + capabilities: WorkdirSessionCapabilities, command_environment: std::collections::BTreeMap, resources: Vec>, ) -> WorkdirSessionHandle { @@ -768,7 +782,7 @@ fn runtime_local_workdir_session( root.to_path_buf(), cwd.to_path_buf(), scope, - WorkdirSessionCapabilities::ALL, + capabilities, command_environment, resources, )) @@ -776,6 +790,7 @@ fn runtime_local_workdir_session( fn runtime_local_workdir_router( attachments: &BTreeMap, + capabilities: &BTreeMap, scope: manifest::SharedScope, ) -> Result, String> { let router = Arc::new(WorkdirSessionRouter::new()); @@ -788,6 +803,10 @@ fn runtime_local_workdir_router( binding.root(), binding.cwd(), scope.clone(), + capabilities + .get(alias) + .copied() + .unwrap_or(WorkdirSessionCapabilities::READ_ONLY), binding.command_environment(), binding.session_resources(), ), @@ -800,10 +819,25 @@ fn runtime_local_workdir_router( fn restored_workdir_router( local_attachments: &BTreeMap, logical_attachments: &[crate::catalog::LogicalWorkdirAttachment], + claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], scope: manifest::SharedScope, workspace_client: Arc, ) -> Result, String> { - let router = runtime_local_workdir_router(local_attachments, scope)?; + let mut local_capabilities = local_attachments + .keys() + .map(|alias| { + ( + alias.clone(), + initial_workdir_session_capabilities(alias, claims), + ) + }) + .collect::>(); + for attachment in logical_attachments { + if local_attachments.contains_key(&attachment.alias) { + local_capabilities.insert(attachment.alias.clone(), attachment.capabilities); + } + } + let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?; for attachment in logical_attachments { if let Some(local) = local_attachments.get(&attachment.alias) { if local.working_directory.id != attachment.working_directory_id { @@ -1068,8 +1102,22 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { })?; let worker = prepared.worker_mut(); validate_worker_memory_settings(worker.manifest(), &request.request)?; + let workdir_capabilities = request + .workdir_attachments + .keys() + .map(|alias| { + ( + alias.clone(), + initial_workdir_session_capabilities( + alias, + &request.request.workdir_attachments, + ), + ) + }) + .collect::>(); worker.bind_workdir_sessions(runtime_local_workdir_router( &request.workdir_attachments, + &workdir_capabilities, worker.scope().clone(), )?); if let (Some(runtime_id), Some(workspace_id)) = @@ -1300,6 +1348,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let workdir_sessions = restored_workdir_router( &request.workdir_attachments, &request.logical_workdir_attachments, + &request.request.workdir_attachments, worker.scope().clone(), worker.workspace_client_handle(), )?; @@ -2177,6 +2226,7 @@ where binding.root(), binding.cwd(), manifest::SharedScope::new(scope), + WorkdirSessionCapabilities::ALL, binding.command_environment(), binding.session_resources(), )) @@ -2979,6 +3029,7 @@ mod tests { working_directory_id: "remote-workdir".to_string(), capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }], + &[], scope, client, ) @@ -2992,6 +3043,37 @@ mod tests { ); } + #[test] + fn restored_router_preserves_read_only_capabilities_for_local_attachment() { + let runtime_base = tempfile::tempdir().unwrap(); + let repo = create_clean_repo(); + let materializer = RuntimeGitMaterializer::new(runtime_base.path()); + let worker_ref = WorkerRef::new(crate::identity::WorkerId::now_v7()); + let binding = materializer + .materialize(&worker_ref, &working_directory_request(repo.path())) + .unwrap(); + let alias = WorkdirAttachmentAlias::new("docs").unwrap(); + let workdir_id = binding.working_directory.id.clone(); + let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap()); + let attachments = BTreeMap::from([(alias.clone(), binding)]); + let logical = [LogicalWorkdirAttachment { + alias: alias.clone(), + working_directory_id: workdir_id, + capabilities: WorkdirSessionCapabilities::READ_ONLY, + }]; + let client = WorkerWorkspaceContext::local_filesystem(Some( + WorkspaceId::new("workspace-a").unwrap(), + )) + .client_handle(); + + let router = restored_workdir_router(&attachments, &logical, &[], scope, client).unwrap(); + + assert_eq!( + router.session(&alias).unwrap().capabilities(), + WorkdirSessionCapabilities::READ_ONLY + ); + } + #[test] fn profile_factory_routes_workspace_requests_by_workspace_id() { let profiles = tempfile::tempdir().unwrap(); @@ -3949,13 +4031,35 @@ mod tests { } #[test] - fn restore_opens_a_fresh_session_for_the_same_workdir_identity() { + fn initial_claim_capabilities_override_full_materialization_default() { + let checkout = WorkdirAttachmentAlias::new("checkout").unwrap(); + let docs = WorkdirAttachmentAlias::new("docs").unwrap(); + let claims = [WorkingDirectoryAttachmentClaim { + alias: docs.clone(), + working_directory_id: "workdir-docs".to_string(), + relative_cwd: None, + capabilities: WorkdirSessionCapabilities::READ_ONLY, + }]; + + assert_eq!( + initial_workdir_session_capabilities(&docs, &claims), + WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + initial_workdir_session_capabilities(&checkout, &claims), + WorkdirSessionCapabilities::ALL + ); + } + + #[test] + fn restore_opens_a_fresh_session_with_the_same_read_only_capabilities() { let root = tempfile::tempdir().unwrap(); let spawned = runtime_local_workdir_session( "working-directory-42", root.path(), root.path(), manifest::SharedScope::new(Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::READ_ONLY, Default::default(), Vec::new(), ); @@ -3964,12 +4068,21 @@ mod tests { root.path(), root.path(), manifest::SharedScope::new(Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::READ_ONLY, Default::default(), Vec::new(), ); assert_eq!(spawned.workdir().id().as_str(), "working-directory-42"); assert_eq!(restored.workdir().id().as_str(), "working-directory-42"); + assert_eq!( + spawned.capabilities(), + WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + restored.capabilities(), + WorkdirSessionCapabilities::READ_ONLY + ); assert!(!Arc::ptr_eq(&spawned, &restored)); } @@ -4961,6 +5074,7 @@ mod tests { alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), working_directory_id: workdir_id.clone(), relative_cwd: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, }]; let error = runtime.create_worker(request).unwrap_err(); diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index f42aba2a3..cb4d322f2 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -5,6 +5,7 @@ use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestC use worker::{ WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, + WorkspaceServerOperation, }; use crate::auth::{ @@ -254,45 +255,53 @@ fn execute_remote_worker_remove_http( fn execute_remote_worker_remove_http_blocking( request: RemoteWorkerRemoveHttpRequest, ) -> Result { - let path = format!( - "/api/w/{}/workers/remove", - request.request_client.workspace_id() - ); - let body = serde_json::to_vec(&serde_json::json!({ - "target_runtime_id": request.target_runtime_id, - "target_worker_id": request.target_worker_id, - "reason": request.reason, - })) - .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; let mut headers = reqwest::header::HeaderMap::new(); headers.insert( crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER, reqwest::header::HeaderValue::from_str(&request.token) .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?, ); - headers.insert( - reqwest::header::CONTENT_TYPE, - reqwest::header::HeaderValue::from_static("application/json"), - ); - let response = request + let client = request .request_client - .execute_blocking(RuntimeWorkspaceRequest { - method: reqwest::Method::POST, - path_and_query: path, - body, + .server_api_client( + WORKSPACE_REQUEST_PERMISSION, + Some(request.source_worker_id), headers, - permission: WORKSPACE_REQUEST_PERMISSION.to_string(), - worker_id: Some(request.source_worker_id), - timeout: Some(Duration::from_secs(5)), - max_response_bytes: 8 * 1024 * 1024, - }) + Duration::from_secs(5), + 8 * 1024 * 1024, + ) .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; - let body = String::from_utf8(response.body) + let workspace_id = request.request_client.workspace_id().to_string(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; - Ok(WorkspaceResponse { - status: response.status.as_u16(), - body, - }) + let result = runtime.block_on(client.workspace_worker_remove( + workspace_id, + server_api::WorkerRemoveRequest { + target_runtime_id: request.target_runtime_id, + target_worker_id: request.target_worker_id, + reason: request.reason, + }, + )); + match result { + Ok(response) => Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&response) + .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?, + }), + Err(server_api::client_support::ClientError::Public { status, error }) => { + Ok(WorkspaceResponse { + status: status.as_u16(), + body: serde_json::to_string(&error).map_err(|error| { + RuntimeWorkerMutationForwardError::Transport(error.to_string()) + })?, + }) + } + Err(error) => Err(RuntimeWorkerMutationForwardError::Transport( + error.to_string(), + )), + } } #[derive(Clone)] @@ -432,6 +441,26 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient { } } +fn generated_workspace_response( + response: Result>, +) -> Result { + match response { + Ok(response) => Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&response) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?, + }), + Err(server_api::client_support::ClientError::Public { status, error }) => { + Ok(WorkspaceResponse { + status: status.as_u16(), + body: serde_json::to_string(&error) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?, + }) + } + Err(error) => Err(WorkspaceClientError::Request(error.to_string())), + } +} + impl WorkspaceClient for RuntimeOwnedWorkspaceClient { fn workspace_id(&self) -> Option<&str> { Some(self.request_client.workspace_id()) @@ -452,36 +481,143 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { self.execute_with_permission(request, WORKSPACE_REQUEST_PERMISSION) } + fn execute_server_operation( + &self, + operation: WorkspaceServerOperation, + ) -> Result { + let request_client = self.request_client.clone(); + let workspace_id = self.workspace_id.clone(); + let worker_id = self.worker_id.clone(); + let timeout = self.request_timeout.unwrap_or(Duration::from_secs(5)); + std::thread::Builder::new() + .name("yoi-workspace-server-api".to_string()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; + let client = request_client + .server_api_client( + WORKSPACE_REQUEST_PERMISSION, + Some(worker_id), + reqwest::header::HeaderMap::new(), + timeout, + 8 * 1024 * 1024, + ) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; + runtime.block_on(async move { + match operation { + WorkspaceServerOperation::WorkerControlList => { + generated_workspace_response( + client.worker_control_list(workspace_id).await, + ) + } + WorkspaceServerOperation::WorkerControlSpawn(request) => { + generated_workspace_response( + client.worker_control_spawn(workspace_id, request).await, + ) + } + WorkspaceServerOperation::WorkerControlInput { + runtime_id, + worker_id, + request, + } => generated_workspace_response( + client + .worker_control_input(workspace_id, runtime_id, worker_id, request) + .await, + ), + WorkspaceServerOperation::WorkerControlCancel { + runtime_id, + worker_id, + request, + } => generated_workspace_response( + client + .worker_control_cancel(workspace_id, runtime_id, worker_id, request) + .await, + ), + WorkspaceServerOperation::WorkerControlStop { + runtime_id, + worker_id, + request, + } => generated_workspace_response( + client + .worker_control_stop(workspace_id, runtime_id, worker_id, request) + .await, + ), + WorkspaceServerOperation::WorkerControlRestore { + runtime_id, + worker_id, + } => generated_workspace_response( + client + .worker_control_restore(workspace_id, runtime_id, worker_id) + .await, + ), + WorkspaceServerOperation::WorkerObservationSessions => { + generated_workspace_response( + client.worker_observation_sessions(workspace_id).await, + ) + } + WorkspaceServerOperation::WorkerObservationCapture(request) => { + generated_workspace_response( + client + .worker_observation_capture(workspace_id, request) + .await, + ) + } + } + }) + }) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))? + .join() + .map_err(|_| { + WorkspaceClientError::Request( + "Workspace Server API request thread panicked".to_string(), + ) + })? + } + fn list_workspace_workers( &self, request: worker::WorkspaceWorkerDiscoveryRequest, ) -> Result { - let mut path = format!( - "/api/w/{}/worker-discovery/workers?limit={}", - self.workspace_id, request.limit - ); - if let Some(cursor) = request.cursor.as_deref() { - path.push_str("&cursor="); - path.push_str(&percent_encode_query(cursor)); - } - if let Some(query) = request.query.as_deref() { - path.push_str("&query="); - path.push_str(&percent_encode_query(query)); - } - let response = self.execute_with_permission( - WorkspaceRequest::get(path), - WORKSPACE_WORKER_DISCOVERY_PERMISSION, - )?; - if !(200..300).contains(&response.status) { - return Err(WorkspaceClientError::Request(format!( - "Workspace Worker discovery failed with HTTP {}: {}", - response.status, response.body - ))); - } - serde_json::from_str(&response.body).map_err(|error| { - WorkspaceClientError::Request(format!( - "invalid Workspace Worker discovery response: {error}" - )) + let request_client = self.request_client.clone(); + let workspace_id = self.workspace_id.clone(); + let worker_id = self.worker_id.clone(); + let timeout = self.request_timeout.unwrap_or(Duration::from_secs(5)); + let query = server_api::WorkspaceWorkerDiscoveryQuery { + cursor: request.cursor, + limit: Some(request.limit), + query: request.query, + }; + let result = std::thread::Builder::new() + .name("yoi-worker-discovery-http".to_string()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + let client = request_client + .server_api_client( + WORKSPACE_WORKER_DISCOVERY_PERMISSION, + Some(worker_id), + reqwest::header::HeaderMap::new(), + timeout, + 8 * 1024 * 1024, + ) + .map_err(|error| error.to_string())?; + runtime + .block_on(client.workspace_worker_discovery(workspace_id, query)) + .map_err(|error| error.to_string()) + }) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))? + .join() + .map_err(|_| { + WorkspaceClientError::Request( + "Workspace Worker discovery HTTP thread panicked".to_string(), + ) + })?; + result.map_err(|error| { + WorkspaceClientError::Request(format!("Workspace Worker discovery failed: {error}")) }) } @@ -532,18 +668,29 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { response.status, response.body ))); } - let projection: WorkspacePromptProjection = + let wire_projection: server_api::WorkspacePromptProjection = serde_json::from_str(&response.body).map_err(|error| { WorkspaceClientError::Request(format!( "invalid active Workspace Prompt projection response: {error}" )) })?; - if projection.workspace_id != self.workspace_id { + if wire_projection.workspace_id != self.workspace_id { return Err(WorkspaceClientError::Request(format!( "active Workspace Prompt projection scope mismatch: expected {}, got {}", - self.workspace_id, projection.workspace_id + self.workspace_id, wire_projection.workspace_id ))); } + let projection: WorkspacePromptProjection = + serde_json::from_value(serde_json::to_value(wire_projection).map_err(|error| { + WorkspaceClientError::Request(format!( + "project active Workspace Prompt projection response: {error}" + )) + })?) + .map_err(|error| { + WorkspaceClientError::Request(format!( + "convert active Workspace Prompt projection response: {error}" + )) + })?; let resolution = cache .observe(projection) .map_err(WorkspaceClientError::Request)?; @@ -576,19 +723,6 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { } } -fn percent_encode_query(value: &str) -> String { - let mut encoded = String::with_capacity(value.len()); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { - encoded.push(char::from(byte)); - } else { - use std::fmt::Write as _; - let _ = write!(encoded, "%{byte:02X}"); - } - } - encoded -} - #[derive(Debug, thiserror::Error)] pub enum RuntimeWorkerMutationForwardError { #[error(transparent)] @@ -936,9 +1070,12 @@ mod tests { server.join().unwrap(); let request = received.lock().unwrap().clone(); - assert!(request.contains( - "GET /api/w/workspace-a/worker-discovery/workers?limit=1&cursor=v1%3A0&query=coder%20two " - )); + assert!( + request.contains( + "GET /api/w/workspace-a/worker-discovery/workers?cursor=v1%3A0&limit=1&query=coder+two " + ), + "unexpected discovery request: {request}" + ); let token = request .lines() .find_map(|line| { @@ -952,7 +1089,7 @@ mod tests { assert_eq!(claims.permission, WORKSPACE_WORKER_DISCOVERY_PERMISSION); assert_eq!( claims.path, - "/api/w/workspace-a/worker-discovery/workers?limit=1&cursor=v1%3A0&query=coder%20two" + "/api/w/workspace-a/worker-discovery/workers?cursor=v1%3A0&limit=1&query=coder+two" ); } @@ -1011,9 +1148,15 @@ mod tests { let count = stream.read(&mut bytes).unwrap(); *received_for_server.lock().unwrap() = String::from_utf8_lossy(&bytes[..count]).into_owned(); - stream - .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") - .unwrap(); + let body = + r#"{"removed":true,"runtime_id":"runtime-target","worker_id":"worker-target"}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); }); let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap(); @@ -1032,7 +1175,7 @@ mod tests { let response = forwarder .execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker") .unwrap(); - assert_eq!(response.status, 204); + assert_eq!(response.status, 200); server.join().unwrap(); let request = received.lock().unwrap().clone(); diff --git a/crates/worker-runtime/src/workspace_request.rs b/crates/worker-runtime/src/workspace_request.rs index 507e69c71..a075144a3 100644 --- a/crates/worker-runtime/src/workspace_request.rs +++ b/crates/worker-runtime/src/workspace_request.rs @@ -62,9 +62,57 @@ impl RuntimeWorkspaceRequestError { timeout, } } +} - pub(crate) fn is_timeout(&self) -> bool { - matches!(self, Self::Transport { timeout: true, .. }) +#[derive(Clone)] +pub(crate) struct RuntimeWorkspaceRequestAuthorizer { + workspace_id: String, + runtime_id: String, + request_source: Option<(RuntimeRequestSourceSigner, String)>, + permission: String, + worker_id: Option, + extra_headers: HeaderMap, +} + +impl server_api::client_support::RequestAuthorizer for RuntimeWorkspaceRequestAuthorizer { + fn authorize( + &self, + request: server_api::client_support::AuthorizerRequest<'_>, + ) -> Result { + let mut headers = self.extra_headers.clone(); + headers.insert( + RUNTIME_ID_HEADER, + reqwest::header::HeaderValue::from_str(&self.runtime_id) + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + if let Some(worker_id) = self.worker_id.as_deref() { + headers.insert( + WORKER_ID_HEADER, + reqwest::header::HeaderValue::from_str(worker_id) + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + } + if let Some((signer, audience)) = self.request_source.as_ref() { + let proof = signer + .issue( + audience, + &self.workspace_id, + self.worker_id.as_deref(), + &self.permission, + request.method.as_str(), + request.path_and_query, + request.body, + unix_now_seconds(), + DEFAULT_REQUEST_PROOF_TTL_SECONDS, + ) + .map_err(|_| server_api::client_support::AuthorizationError::new())?; + headers.insert( + RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + reqwest::header::HeaderValue::from_str(&proof) + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + } + Ok(headers) } } @@ -117,6 +165,34 @@ impl RuntimeWorkspaceRequestClient { && self.base_url.trim_end_matches('/') == base_url.trim_end_matches('/') } + pub(crate) fn server_api_client( + &self, + permission: impl Into, + worker_id: Option, + extra_headers: HeaderMap, + timeout: Duration, + response_body_limit: usize, + ) -> Result< + server_api::ServerApiClient, + RuntimeWorkspaceRequestError, + > { + let authorizer = RuntimeWorkspaceRequestAuthorizer { + workspace_id: self.workspace_id.clone(), + runtime_id: self.runtime_id.clone(), + request_source: self.request_source.clone(), + permission: permission.into(), + worker_id, + extra_headers, + }; + server_api::ServerApiClient::builder(&self.base_url) + .map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string()))? + .authorizer(authorizer) + .request_timeout(timeout) + .response_body_limit(response_body_limit) + .build() + .map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string())) + } + pub(crate) async fn execute( &self, request: RuntimeWorkspaceRequest, diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 676f73963..371c18206 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -19,10 +19,7 @@ use crate::feature::{ ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, }; use crate::spawn::registry::{SpawnedWorkerRegistry, SubWorkerStopSummary}; -use crate::worker::{ - WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, - WorkspaceResponse, -}; +use crate::worker::{WorkspaceClient, WorkspaceClientError, WorkspaceResponse}; const FEATURE_ID: &str = "worker"; const FEATURE_NAME: &str = "Worker"; @@ -76,16 +73,22 @@ pub trait WorkerControlService: Send + Sync { &self, request: WorkerLifecycleSpawnRequest, ) -> Result; + async fn restore_worker( + &self, + runtime_id: String, + worker_id: String, + ) -> Result { + let _ = (runtime_id, worker_id); + Err(WorkspaceClientError::Unavailable( + "Runtime Worker restore is unavailable".to_string(), + )) + } fn remove_runtime_worker( &self, runtime_id: &str, worker_id: &str, reason: &str, ) -> Result; - async fn execute_runtime( - &self, - request: WorkspaceRequest, - ) -> Result; async fn ensure_permission( &self, subject: &super::worker_observation::WorkerObservationSubjectRef, @@ -144,11 +147,9 @@ impl WorkerControlService for WorkspaceWorkerControlService { async fn list_workers(&self) -> Result { let response = if self.runtime_worker_control { - self.execute_runtime(WorkspaceRequest::get(format!( - "/api/w/{}/worker-control/workers", - self.workspace_id - ))) - .await? + self.client.execute_server_operation( + crate::worker::WorkspaceServerOperation::WorkerControlList, + )? } else { WorkspaceResponse { status: 200, @@ -210,19 +211,17 @@ impl WorkerControlService for WorkspaceWorkerControlService { WorkerControlInputKind::User => "user", WorkerControlInputKind::Notify => "notify", }; - self.execute_runtime(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!( - "/api/w/{}/worker-control/workers/{runtime_id}/{worker_id}/input", - self.workspace_id - ), - serde_json::json!({ - "kind": kind, - "content": content, - }) - .to_string(), - )) - .await + self.client.execute_server_operation( + crate::worker::WorkspaceServerOperation::WorkerControlInput { + runtime_id, + worker_id, + request: server_api::RuntimeWorkerInputRequest { + kind: Some(kind.to_string()), + content, + segments: None, + }, + }, + ) } WorkerControlSubject::SubWorker { name } => { if kind != WorkerControlInputKind::User { @@ -273,19 +272,29 @@ impl WorkerControlService for WorkspaceWorkerControlService { runtime_id, worker_id, } => { - let action = match kind { - WorkerControlStopKind::Cancel => "cancel", - WorkerControlStopKind::Stop => "stop", + let operation = match kind { + WorkerControlStopKind::Cancel => { + crate::worker::WorkspaceServerOperation::WorkerControlCancel { + runtime_id, + worker_id, + request: server_api::RuntimeWorkerLifecycleRequest { + reason, + ticket_assignment: None, + }, + } + } + WorkerControlStopKind::Stop => { + crate::worker::WorkspaceServerOperation::WorkerControlStop { + runtime_id, + worker_id, + request: server_api::RuntimeWorkerLifecycleRequest { + reason, + ticket_assignment: None, + }, + } + } }; - self.execute_runtime(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!( - "/api/w/{}/worker-control/workers/{runtime_id}/{worker_id}/{action}", - self.workspace_id - ), - serde_json::json!({ "reason": reason }).to_string(), - )) - .await + self.client.execute_server_operation(operation) } WorkerControlSubject::SubWorker { name } => { if kind != WorkerControlStopKind::Stop { @@ -329,12 +338,29 @@ impl WorkerControlService for WorkspaceWorkerControlService { ) -> Result { WorkspaceWorkerLifecycleService { client: self.client.clone(), - workspace_id: self.workspace_id.clone(), } .spawn(request) .await } + async fn restore_worker( + &self, + runtime_id: String, + worker_id: String, + ) -> Result { + if !self.runtime_worker_control { + return Err(WorkspaceClientError::Unavailable( + "Runtime Worker control is not enabled for this Worker".to_string(), + )); + } + self.client.execute_server_operation( + crate::worker::WorkspaceServerOperation::WorkerControlRestore { + runtime_id, + worker_id, + }, + ) + } + fn remove_runtime_worker( &self, runtime_id: &str, @@ -350,18 +376,6 @@ impl WorkerControlService for WorkspaceWorkerControlService { .execute_worker_remove(runtime_id, worker_id, reason) } - async fn execute_runtime( - &self, - request: WorkspaceRequest, - ) -> Result { - if !self.runtime_worker_control { - return Err(WorkspaceClientError::Request( - "Runtime Worker control is not enabled for this Worker".to_string(), - )); - } - self.client.execute(request) - } - async fn ensure_permission( &self, subject: &super::worker_observation::WorkerObservationSubjectRef, @@ -391,10 +405,9 @@ impl WorkerControlService for WorkspaceWorkerControlService { "Runtime Worker control is not enabled for this Worker".to_string(), )); } - let response = self.client.execute(WorkspaceRequest::get(format!( - "/api/w/{}/worker-control/workers", - self.workspace_id - )))?; + let response = self.client.execute_server_operation( + crate::worker::WorkspaceServerOperation::WorkerControlList, + )?; if !response.is_success() { return Err(WorkspaceClientError::Request(format!( "Workspace control request returned {}: {}", @@ -445,11 +458,21 @@ pub trait WorkerLifecycleService: Send + Sync { ) -> Result; } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerLifecycleWorkdirAttachment { + /// Stable Worker-local routing alias preserved into the canonical create request. + pub alias: String, + /// Workspace-authoritative Workdir id preserved into the canonical create request. + pub working_directory_id: String, + /// Optional normalized initial cwd relative to this Workdir. + pub relative_cwd: Option, +} + #[derive(Debug, Clone)] pub struct WorkerLifecycleSpawnRequest { pub runtime_id: String, - pub working_directory_id: String, - pub relative_cwd: Option, + /// Ordered alias-keyed selections forwarded as canonical Workdir attachments. + pub workdir_attachments: Vec, pub profile: String, pub ticket_id: Option, pub operation_id: Option, @@ -459,7 +482,6 @@ pub struct WorkerLifecycleSpawnRequest { struct WorkspaceWorkerLifecycleService { client: Arc, - workspace_id: String, } fn workspace_worker_create_request( @@ -489,11 +511,15 @@ fn workspace_worker_create_request( profile: Some(request.profile), ticket_assignment, initial_submit: request.initial_submit, - workdir_attachments: vec![BrowserWorkerWorkingDirectorySelection { - alias: INITIAL_WORKDIR_ALIAS.to_string(), - working_directory_id: request.working_directory_id, - relative_cwd: request.relative_cwd, - }], + workdir_attachments: request + .workdir_attachments + .into_iter() + .map(|attachment| BrowserWorkerWorkingDirectorySelection { + alias: attachment.alias, + working_directory_id: attachment.working_directory_id, + relative_cwd: attachment.relative_cwd, + }) + .collect(), control_operation_id: Some(control_operation_id), }) } @@ -505,12 +531,9 @@ impl WorkerLifecycleService for WorkspaceWorkerLifecycleService { request: WorkerLifecycleSpawnRequest, ) -> Result { let body = workspace_worker_create_request(request)?; - self.client.execute(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!("/api/w/{}/worker-control/workers", self.workspace_id), - serde_json::to_string(&body) - .map_err(|error| WorkspaceClientError::Request(error.to_string()))?, - )) + self.client.execute_server_operation( + crate::worker::WorkspaceServerOperation::WorkerControlSpawn(body), + ) } } @@ -639,20 +662,17 @@ impl FeatureModule for ManageWorkerFeature { } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { - let workspace_id = self - .client + self.client .workspace_id() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { FeatureInstallError::InvalidDescriptor( "worker feature requires a Workspace id".to_string(), ) - })? - .to_string(); + })?; let lifecycle: Arc = Arc::new(WorkspaceWorkerLifecycleService { client: self.client.clone(), - workspace_id: workspace_id.clone(), }); context.services().provide( ServiceDeclaration::new( @@ -692,6 +712,8 @@ struct WorkerListInput {} #[serde(deny_unknown_fields)] struct WorkerSpawnInput { runtime_id: String, + /// Singular Workdir selection wrapped into the shared attachment collection + /// under the stable `workdir` alias. working_directory_id: String, profile: String, /// Optional inprogress Ticket already accepted by the Orchestrator. Set @@ -825,7 +847,7 @@ impl WorkerOperation { "List only known Runtime Workers and direct SubWorkers granted to the current Worker." } Self::Spawn => { - "Spawn a Backend/Runtime Worker session in an existing Workspace Workdir. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input." + "Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input." } Self::SendInput => { "Start a fresh turn by sending user input to a known Idle Worker. Running or Paused targets reject Submit; use WorkerNotify for advisory information during work already in progress." @@ -876,14 +898,17 @@ impl Tool for WorkspaceWorkerTool { self.control .spawn_worker(WorkerLifecycleSpawnRequest { runtime_id: authority_id(&input.runtime_id, "runtime_id")?, - working_directory_id: authority_id( - &input.working_directory_id, - "working_directory_id", - )?, - relative_cwd: input - .relative_cwd - .map(|value| validate_relative_cwd(&value)) - .transpose()?, + workdir_attachments: vec![WorkerLifecycleWorkdirAttachment { + alias: INITIAL_WORKDIR_ALIAS.to_string(), + working_directory_id: authority_id( + &input.working_directory_id, + "working_directory_id", + )?, + relative_cwd: input + .relative_cwd + .map(|value| validate_relative_cwd(&value)) + .transpose()?, + }], profile: non_empty(input.profile, "profile")?, ticket_id, operation_id, @@ -952,14 +977,7 @@ impl Tool for WorkspaceWorkerTool { let input = parse::(input_json, "WorkerRestore")?; let (runtime_id, worker_id) = runtime_subject_ids(&input.subject, self.operation)?; self.control - .execute_runtime(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!( - "/api/w/{}/worker-control/workers/{runtime_id}/{worker_id}/restore", - self.control.workspace_id() - ), - "{}", - )) + .restore_worker(runtime_id, worker_id) .await .map_err(control_tool_error)? } @@ -1197,7 +1215,7 @@ mod tests { use std::sync::Mutex; use super::*; - use crate::worker::{WorkspaceClientError, WorkspaceResponse}; + use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; #[derive(Debug, Default)] struct RecordingWorkspaceClient { @@ -1367,15 +1385,6 @@ mod tests { )) } - async fn execute_runtime( - &self, - _request: WorkspaceRequest, - ) -> Result { - Err(WorkspaceClientError::Unavailable( - "Runtime Worker control is disabled in this test".to_string(), - )) - } - async fn ensure_permission( &self, _subject: &crate::feature::builtin::WorkerObservationSubjectRef, @@ -1406,6 +1415,7 @@ mod tests { &serde_json::json!({ "runtime_id": "runtime-1", "working_directory_id": "workdir-1", + "relative_cwd": "crates/worker", "profile": "builtin:coder", "ticket_id": "00001KZ9E0DBS", "initial_submit": [ @@ -1451,7 +1461,7 @@ mod tests { ); assert_eq!( body["workdir_attachments"][0]["relative_cwd"], - serde_json::Value::Null + "crates/worker" ); assert!(body.get("working_directory").is_none()); assert!(body.get("initial_text").is_none()); @@ -1725,11 +1735,21 @@ mod tests { } #[test] - fn worker_spawn_request_uses_canonical_initial_attachment_contract() { + fn worker_lifecycle_spawn_request_preserves_canonical_attachment_collection() { let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest { runtime_id: "runtime-1".to_string(), - working_directory_id: "wd-1".to_string(), - relative_cwd: Some("repo".to_string()), + workdir_attachments: vec![ + WorkerLifecycleWorkdirAttachment { + alias: "checkout".to_string(), + working_directory_id: "wd-1".to_string(), + relative_cwd: Some("repo".to_string()), + }, + WorkerLifecycleWorkdirAttachment { + alias: "docs".to_string(), + working_directory_id: "wd-2".to_string(), + relative_cwd: None, + }, + ], profile: "builtin:coder".to_string(), ticket_id: None, operation_id: None, @@ -1744,16 +1764,22 @@ mod tests { .unwrap(); let value = serde_json::to_value(request).unwrap(); assert_eq!(value["runtime_id"], "runtime-1"); - assert_eq!(value["workdir_attachments"].as_array().unwrap().len(), 1); - assert_eq!( - value["workdir_attachments"][0]["alias"], - INITIAL_WORKDIR_ALIAS - ); + assert_eq!(value["workdir_attachments"].as_array().unwrap().len(), 2); + assert_eq!(value["workdir_attachments"][0]["alias"], "checkout"); assert_eq!( value["workdir_attachments"][0]["working_directory_id"], "wd-1" ); assert_eq!(value["workdir_attachments"][0]["relative_cwd"], "repo"); + assert_eq!(value["workdir_attachments"][1]["alias"], "docs"); + assert_eq!( + value["workdir_attachments"][1]["working_directory_id"], + "wd-2" + ); + assert_eq!( + value["workdir_attachments"][1]["relative_cwd"], + serde_json::Value::Null + ); assert!(value.get("working_directory").is_none()); assert!(value.get("cwd").is_none()); assert!(value.get("runtime_url").is_none()); diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index ea4c68b1d..3198b5ea6 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -104,7 +104,7 @@ async fn execute_memory_backend( let response = client.execute(WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!("/api/w/{workspace_id}/memory/backend"), - serde_json::to_string(&operation)?, + serde_json::to_string(&server_api::MemoryBackendRequest(operation))?, ))?; let status = reqwest::StatusCode::from_u16(response.status) .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); @@ -114,7 +114,7 @@ async fn execute_memory_backend( body: response.body, }); } - match serde_json::from_str::(&response.body)? { + match serde_json::from_str::(&response.body)?.0 { MemoryBackendHttpResponse::Ok { result } => Ok(result), MemoryBackendHttpResponse::Error { message } => { Err(WorkspaceMemoryBackendError::Backend(message)) @@ -138,7 +138,9 @@ async fn execute_memory_consolidation( let response = client.execute(WorkspaceRequest::json( WorkspaceRequestMethod::Post, format!("/api/w/{workspace_id}/memory/consolidation"), - serde_json::to_string(&operation)?, + serde_json::to_string(&server_api::MemoryConsolidateStagingRequest { + force: operation.force, + })?, ))?; let status = reqwest::StatusCode::from_u16(response.status) .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR); @@ -148,7 +150,13 @@ async fn execute_memory_consolidation( body: response.body, }); } - serde_json::from_str::(&response.body).map_err(Into::into) + let response = serde_json::from_str::(&response.body)?; + Ok(MemoryConsolidationOutput { + status: response.status, + summary: response.summary, + candidate_count: response.candidate_count, + total_bytes: response.total_bytes, + }) } pub fn workspace_http_memory_tools(client: Arc) -> Vec { diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index a16858705..953284271 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -1,5 +1,6 @@ //! Semantic Ticket orchestration tools backed by Feature Services. +use std::collections::BTreeSet; use std::sync::Arc; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -10,6 +11,7 @@ use serde::Deserialize; use super::manage_worker::{ WORKER_LIFECYCLE_SERVICE_ID, WorkerLifecycleService, WorkerLifecycleSpawnRequest, + WorkerLifecycleWorkdirAttachment, }; use super::ticket::{TICKET_SERVICE_ID, TicketService}; use crate::feature::{ @@ -43,7 +45,7 @@ impl FeatureModule for OrchestrationFeature { )) .with_tool(ToolDeclaration::new( TOOL_NAME, - "Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.", + "Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. Select every required Workdir by stable alias; Backend authority derives each attachment's access from the Ticket targets. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.", )) } @@ -64,14 +66,26 @@ impl FeatureModule for OrchestrationFeature { } } +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct SpawnTicketCoderWorkdirInput { + /// Stable Worker-local routing alias (for example `checkout` or `docs`). + alias: String, + /// Workspace-authoritative Workdir id. Paths and URLs are not accepted. + working_directory_id: String, + /// Optional normalized path inside this Workdir for the Worker's initial cwd. + #[serde(default)] + relative_cwd: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] struct SpawnTicketCoderInput { ticket_id: String, runtime_id: String, - working_directory_id: String, - #[serde(default)] - relative_cwd: Option, + /// Every Workdir required by the Ticket target set. Backend authority derives + /// effective read/write capabilities; callers cannot request capabilities. + workdir_attachments: Vec, } struct SpawnTicketCoderTool { @@ -105,16 +119,13 @@ impl Tool for SpawnTicketCoderTool { ))); } let call_id = non_empty(ctx.call_id, "tool call_id")?; - let relative_cwd = input.relative_cwd.map(validate_relative_cwd).transpose()?; + let runtime_id = authority_id(input.runtime_id, "runtime_id")?; + let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?; let response = self .worker_service .spawn(WorkerLifecycleSpawnRequest { - runtime_id: authority_id(input.runtime_id, "runtime_id")?, - working_directory_id: authority_id( - input.working_directory_id, - "working_directory_id", - )?, - relative_cwd, + runtime_id, + workdir_attachments, profile: CODER_PROFILE.to_string(), ticket_id: Some(ticket.id.clone()), operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)), @@ -142,6 +153,47 @@ impl Tool for SpawnTicketCoderTool { } } +fn validate_workdir_attachments( + attachments: Vec, +) -> Result, ToolError> { + if attachments.is_empty() { + return Err(ToolError::InvalidArgument( + "workdir_attachments must contain at least one Workdir".to_string(), + )); + } + + let mut aliases = BTreeSet::new(); + let mut workdir_ids = BTreeSet::new(); + attachments + .into_iter() + .map(|attachment| { + let alias = authority_id(attachment.alias, "workdir_attachments.alias")?; + if !aliases.insert(alias.clone()) { + return Err(ToolError::InvalidArgument(format!( + "duplicate Workdir attachment alias `{alias}`" + ))); + } + let working_directory_id = authority_id( + attachment.working_directory_id, + "workdir_attachments.working_directory_id", + )?; + if !workdir_ids.insert(working_directory_id.clone()) { + return Err(ToolError::InvalidArgument(format!( + "duplicate Workdir attachment `{working_directory_id}`" + ))); + } + Ok(WorkerLifecycleWorkdirAttachment { + alias, + working_directory_id, + relative_cwd: attachment + .relative_cwd + .map(validate_relative_cwd) + .transpose()?, + }) + }) + .collect() +} + fn definition( ticket_service: Arc, worker_service: Arc, @@ -150,7 +202,9 @@ fn definition( let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput)) .unwrap_or_else(|_| serde_json::json!({})); let meta = ToolMeta::new(TOOL_NAME) - .description("Spawn and atomically assign a policy-configured Coder for a Ticket.") + .description( + "Spawn and atomically assign a policy-configured Coder for a Ticket with every required alias-keyed Workdir attachment.", + ) .input_schema(schema); let tool: Arc = Arc::new(SpawnTicketCoderTool { ticket_service: ticket_service.clone(), @@ -260,8 +314,17 @@ mod tests { &serde_json::json!({ "ticket_id": "T-482", "runtime_id": "runtime-1", - "working_directory_id": "workdir-1", - "relative_cwd": "crates/yoi" + "workdir_attachments": [ + { + "alias": "checkout", + "working_directory_id": "workdir-1", + "relative_cwd": "crates/yoi" + }, + { + "alias": "docs", + "working_directory_id": "workdir-2" + } + ] }) .to_string(), ToolExecutionContext::new("call-7", "batch-1", 0), @@ -278,8 +341,22 @@ mod tests { Some("spawn-ticket-coder:00001KZXN51C7:call-7") ); assert_eq!(request.display_name, "Coder · T-482"); - assert_eq!(request.working_directory_id, "workdir-1"); - assert_eq!(request.relative_cwd.as_deref(), Some("crates/yoi")); + assert_eq!(request.workdir_attachments.len(), 2); + assert_eq!(request.workdir_attachments[0].alias, "checkout"); + assert_eq!( + request.workdir_attachments[0].working_directory_id, + "workdir-1" + ); + assert_eq!( + request.workdir_attachments[0].relative_cwd.as_deref(), + Some("crates/yoi") + ); + assert_eq!(request.workdir_attachments[1].alias, "docs"); + assert_eq!( + request.workdir_attachments[1].working_directory_id, + "workdir-2" + ); + assert_eq!(request.workdir_attachments[1].relative_cwd, None); assert_eq!( request.initial_submit, vec![ @@ -307,7 +384,10 @@ mod tests { &serde_json::json!({ "ticket_id": "00001KZXN51C7", "runtime_id": "runtime-1", - "working_directory_id": "workdir-1" + "workdir_attachments": [{ + "alias": "checkout", + "working_directory_id": "workdir-1" + }] }) .to_string(), ToolExecutionContext::new("call-queued", "batch-1", 0), @@ -318,6 +398,106 @@ mod tests { assert!(worker_service.requests.lock().unwrap().is_empty()); } + #[tokio::test] + async fn spawn_ticket_coder_rejects_invalid_workdir_collections_before_worker_side_effect() { + let worker_service = Arc::new(RecordingService::default()); + let tool = SpawnTicketCoderTool { + ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)), + worker_service: worker_service.clone(), + }; + let cases = [ + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [] + }), + "must contain at least one Workdir", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + {"alias": "checkout", "working_directory_id": "workdir-1"}, + {"alias": " checkout ", "working_directory_id": "workdir-2"} + ] + }), + "duplicate Workdir attachment alias `checkout`", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + {"alias": "checkout", "working_directory_id": "workdir-1"}, + {"alias": "docs", "working_directory_id": " workdir-1 "} + ] + }), + "duplicate Workdir attachment `workdir-1`", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + {"alias": "docs/path", "working_directory_id": "workdir-1"} + ] + }), + "workdir_attachments.alias must be an authority id", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + {"alias": "docs", "working_directory_id": "https://example.test/workdir"} + ] + }), + "workdir_attachments.working_directory_id must be an authority id", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime/1", + "workdir_attachments": [ + {"alias": "checkout", "working_directory_id": "workdir-1"} + ] + }), + "runtime_id must be an authority id", + ), + ( + serde_json::json!({ + "ticket_id": "T-482", + "runtime_id": "runtime-1", + "workdir_attachments": [ + { + "alias": "checkout", + "working_directory_id": "workdir-1", + "relative_cwd": "../outside" + } + ] + }), + "relative_cwd must be a normalized relative path inside the Workdir", + ), + ]; + + for (index, (input, expected)) in cases.into_iter().enumerate() { + let error = tool + .execute( + &input.to_string(), + ToolExecutionContext::new(format!("call-{index}"), "batch-validation", index), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains(expected), + "unexpected error for case {index}: {error}" + ); + } + assert!(worker_service.requests.lock().unwrap().is_empty()); + } + #[test] fn orchestration_descriptor_requires_ticket_and_worker_services() { let descriptor = orchestration_feature().descriptor(); @@ -341,6 +521,8 @@ mod tests { for field in [ "ticket_id", "runtime_id", + "workdir_attachments", + "alias", "working_directory_id", "relative_cwd", ] { diff --git a/crates/worker/src/feature/builtin/worker_observation.rs b/crates/worker/src/feature/builtin/worker_observation.rs index bda05e091..65c895683 100644 --- a/crates/worker/src/feature/builtin/worker_observation.rs +++ b/crates/worker/src/feature/builtin/worker_observation.rs @@ -125,17 +125,16 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider { async fn list_worker_sessions( &self, ) -> Result, WorkerObservationError> { - let workspace_id = self.client.workspace_id().ok_or_else(|| { + let _workspace_id = self.client.workspace_id().ok_or_else(|| { WorkerObservationError::Unavailable( "Workspace observation requires a scoped Workspace client".to_string(), ) })?; let response = self .client - .execute(crate::worker::WorkspaceRequest::get(format!( - "/api/w/{}/worker-observation/sessions", - workspace_id - ))) + .execute_server_operation( + crate::worker::WorkspaceServerOperation::WorkerObservationSessions, + ) .map_err(workspace_client_error)?; let body = workspace_response_body(response)?; serde_json::from_str::(&body) @@ -147,20 +146,28 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider { &self, subject: &WorkerObservationSubjectRef, ) -> Result { - let body = serde_json::to_string(subject) - .map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?; - let workspace_id = self.client.workspace_id().ok_or_else(|| { + let request = match subject { + WorkerObservationSubjectRef::RuntimeWorker { + runtime_id, + worker_id, + } => server_api::WorkerObservationSubjectRef::RuntimeWorker { + runtime_id: runtime_id.clone(), + worker_id: worker_id.clone(), + }, + WorkerObservationSubjectRef::SubWorker { name } => { + server_api::WorkerObservationSubjectRef::SubWorker { name: name.clone() } + } + }; + let _workspace_id = self.client.workspace_id().ok_or_else(|| { WorkerObservationError::Unavailable( "Workspace observation requires a scoped Workspace client".to_string(), ) })?; let response = self .client - .execute(crate::worker::WorkspaceRequest::json( - crate::worker::WorkspaceRequestMethod::Post, - format!("/api/w/{}/worker-observation/session", workspace_id), - body, - )) + .execute_server_operation( + crate::worker::WorkspaceServerOperation::WorkerObservationCapture(request), + ) .map_err(workspace_client_error)?; let body = workspace_response_body(response)?; let response = serde_json::from_str::(&body) diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index c83ef56ae..a5b88a79f 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -60,6 +60,6 @@ pub use worker::{ LocalWorkingDirectory, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, - WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, marker_workspace_client, - unavailable_workspace_client, + WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, + marker_workspace_client, unavailable_workspace_client, }; diff --git a/crates/worker/src/skill.rs b/crates/worker/src/skill.rs index 9eca47898..bdc22c59b 100644 --- a/crates/worker/src/skill.rs +++ b/crates/worker/src/skill.rs @@ -1,23 +1,13 @@ -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use crate::worker::WorkspaceClient; pub use server_api::{ - SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse, - SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity, SkillProjectionStatus, - SkillProvenance, SkillResourceRef, SkillSourceKind, + SkillActivationResponse, SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse, + SkillDetailResponse, SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity, + SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind, }; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SkillActivationResponse { - pub name: String, - pub provenance: SkillProvenance, - #[serde(default)] - pub diagnostics: Vec, - /// Imported Markdown content to append to Worker history on explicit activation. - pub body: String, -} - #[derive(Debug, thiserror::Error)] pub enum SkillClientError { #[error("workspace client is unavailable: {0}")] diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index c020c0782..31aae61b2 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -544,6 +544,112 @@ pub struct WorkspaceWorkerDiscoveryRequest { pub query: Option, } +#[derive(Debug, Clone)] +pub enum WorkspaceServerOperation { + WorkerControlList, + WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest), + WorkerControlInput { + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerInputRequest, + }, + WorkerControlCancel { + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerLifecycleRequest, + }, + WorkerControlStop { + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerLifecycleRequest, + }, + WorkerControlRestore { + runtime_id: String, + worker_id: String, + }, + WorkerObservationSessions, + WorkerObservationCapture(server_api::WorkerObservationSubjectRef), +} + +fn workspace_server_json_request( + path: String, + body: &T, +) -> Result { + Ok(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + path, + serde_json::to_string(body) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?, + )) +} + +fn encode_workspace_path_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(byte as char); + } else { + use std::fmt::Write as _; + let _ = write!(encoded, "%{byte:02X}"); + } + } + encoded +} + +fn workspace_server_operation_request( + workspace_id: &str, + operation: WorkspaceServerOperation, +) -> Result { + let workspace_id = encode_workspace_path_segment(workspace_id); + let base = format!("/api/w/{workspace_id}"); + match operation { + WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!( + "{base}/worker-control/workers" + ))), + WorkspaceServerOperation::WorkerControlSpawn(request) => { + workspace_server_json_request(format!("{base}/worker-control/workers"), &request) + } + WorkspaceServerOperation::WorkerControlInput { + runtime_id, + worker_id, + request, + } => workspace_server_json_request( + format!("{base}/worker-control/workers/{runtime_id}/{worker_id}/input"), + &request, + ), + WorkspaceServerOperation::WorkerControlCancel { + runtime_id, + worker_id, + request, + } => workspace_server_json_request( + format!("{base}/worker-control/workers/{runtime_id}/{worker_id}/cancel"), + &request, + ), + WorkspaceServerOperation::WorkerControlStop { + runtime_id, + worker_id, + request, + } => workspace_server_json_request( + format!("{base}/worker-control/workers/{runtime_id}/{worker_id}/stop"), + &request, + ), + WorkspaceServerOperation::WorkerControlRestore { + runtime_id, + worker_id, + } => Ok(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + format!("{base}/worker-control/workers/{runtime_id}/{worker_id}/restore"), + "".to_string(), + )), + WorkspaceServerOperation::WorkerObservationSessions => Ok(WorkspaceRequest::get(format!( + "{base}/worker-observation/sessions" + ))), + WorkspaceServerOperation::WorkerObservationCapture(request) => { + workspace_server_json_request(format!("{base}/worker-observation/session"), &request) + } + } +} + /// Path-free Workspace operation authority injected by Runtime/host code. /// /// Workers receive this trait object rather than a Backend URL. The concrete @@ -556,6 +662,18 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { fn execute(&self, request: WorkspaceRequest) -> Result; + fn execute_server_operation( + &self, + operation: WorkspaceServerOperation, + ) -> Result { + let workspace_id = self.workspace_id().ok_or_else(|| { + WorkspaceClientError::Unavailable( + "Workspace Server operation requires Workspace identity".to_string(), + ) + })?; + self.execute(workspace_server_operation_request(workspace_id, operation)?) + } + /// Lists Workspace-visible Workers through dedicated Runtime-owned source /// proof. Implementations must not fall back to generic Workspace request /// authority. @@ -3942,8 +4060,8 @@ impl Worker { .ok_or_else(|| { WorkerError::FlowInput("Workspace client has no Workspace scope".to_string()) })?; - let request = flow::FlowSourceResolveRequest { - selector: selector.clone(), + let request = server_api::FlowSourceResolveRequest { + selector: selector.to_string(), }; let response = workspace .execute(WorkspaceRequest { @@ -3968,13 +4086,19 @@ impl Worker { response.status ))); } - let source: flow::ResolvedFlowSource = serde_json::from_str(&response.body) + let wire_source: server_api::ResolvedFlowSource = serde_json::from_str(&response.body) .map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?; - if source.workspace_id != workspace_id || source.selector != selector { + if wire_source.workspace_id != workspace_id || wire_source.selector != selector.to_string() + { return Err(WorkerError::FlowInput( "Workspace returned a Flow source outside the requested scope".to_string(), )); } + let source: flow::ResolvedFlowSource = + serde_json::from_value(serde_json::to_value(wire_source).map_err(|error| { + WorkerError::FlowInput(format!("project Flow source transport: {error}")) + })?) + .map_err(|error| WorkerError::FlowInput(format!("convert Flow source: {error}")))?; let (state, initial_instructions) = flow::FlowRuntimeState::start(&source, uuid::Uuid::now_v7().to_string()) .map_err(|error| WorkerError::FlowInput(error.to_string()))?; diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 5b7330acc..2438e67c8 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -10,10 +10,6 @@ autobins = false name = "yoi-server" path = "src/main.rs" -[features] -default = ["typescript"] -typescript = ["dep:ts-rs", "server-api/typescript"] - [dependencies] async-trait.workspace = true axum = { workspace = true, features = ["ws"] } @@ -47,7 +43,7 @@ worker-runtime.workspace = true toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -ts-rs = { version = "12.0.1", optional = true } +ts-rs = "12.0.1" url.workspace = true uuid = { workspace = true, features = ["v7"] } zeroize.workspace = true @@ -57,3 +53,4 @@ webauthn-rs = { workspace = true } tempfile.workspace = true tower = { workspace = true, features = ["util"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } diff --git a/crates/workspace-server/examples/generate_ticket_api_types.rs b/crates/workspace-server/examples/generate_ticket_api_types.rs deleted file mode 100644 index f30c422ba..000000000 --- a/crates/workspace-server/examples/generate_ticket_api_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", yoi_workspace_server::ticket_api_typescript()); -} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 5069fe9dc..23740b988 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -19,10 +19,10 @@ use crate::records::{ ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary, ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketActionEligibility, TicketAssignmentPrincipalSummary, TicketAssignmentSummary, TicketDetail, TicketEventDetail, - TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary, - TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView, + TicketEvidenceEvent, TicketEvidenceSummary, TicketListProjectionRequest, + TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage, - summarize_body, truncate_body, + summarize_body, ticket_relation_view_from_domain, truncate_body, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, @@ -48,7 +48,7 @@ impl WorkspaceAuthority for T where T: ObjectiveAuthority + TicketAuthority + pub trait TicketAuthority { fn list_tickets(&self, limit: usize) -> Result>; - fn list_ticket_page(&self, request: TicketListPageRequest) -> Result; + fn list_ticket_page(&self, request: TicketListProjectionRequest) -> Result; fn query_tickets(&self, query: TicketQueryRequest) -> Result; fn ticket(&self, id: &str) -> Result; fn show_ticket(&self, id: &str, query: TicketShowRequest) -> Result; @@ -86,14 +86,14 @@ pub trait MemoryAuthority { fn ensure_memory_document(&self) -> Result; fn memory_document(&self) -> Result; fn update_memory_document(&self, body_md: &str) -> Result; - fn list_memory_staging_records(&self, limit: usize) -> Result>; - fn memory_staging_record(&self, candidate_id: &str) -> Result; + fn list_memory_staging_records(&self, limit: usize) -> Result>; + fn memory_staging_record(&self, candidate_id: &str) -> Result; fn upsert_memory_staging_record( &self, candidate_id: &str, raw_json: &str, source_path: Option<&str>, - ) -> Result; + ) -> Result; fn close_memory_staging_record( &self, candidate_id: &str, @@ -114,7 +114,7 @@ pub struct MemoryDocument { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct MemoryStagingEntry { +pub struct InternalMemoryStagingEntry { pub candidate_id: String, pub raw_json: String, pub source_path: Option, @@ -840,7 +840,24 @@ impl SqliteWorkspaceAuthority { let has_coder = role_assignments .iter() .any(|assignment| assignment.role == TicketAssignmentRole::Coder); - let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some(); + let targets = ticket + .meta + .targets + .iter() + .map(|target| { + let repository = self + .store + .get_repository_by_key(&self.workspace_id, &target.repository_key)? + .ok_or_else(|| Error::UnknownRepository(target.repository_key.clone()))?; + Ok::<_, Error>(ticket::TicketTarget { + repository_key: repository.repository_key, + ref_selector: target.ref_selector.clone(), + access: target.access, + }) + }) + .collect::>>()?; + let write_repository_key = ticket_write_target_repository_key(&targets); + let has_target = write_repository_key.is_some(); let has_blockers = !ticket.relations.blockers.is_empty(); let mut queue_assignment_blockers = Vec::new(); for ticket_id in &dependency_check.queue_tickets { @@ -932,22 +949,8 @@ impl SqliteWorkspaceAuthority { Err(MergeRequestError::NotFound) => None, Err(error) => return Err(Error::Store(error.to_string())), }; - let repository_key = ticket - .meta - .repository_id - .as_deref() - .map(|repository_id| { - self.store - .get_repository(&self.workspace_id, repository_id)? - .map(|repository| repository.repository_key) - .ok_or_else(|| Error::UnknownRepository(repository_id.to_string())) - }) - .transpose()?; - let evidence = ticket_evidence_summary( - repository_key.as_deref(), - &ticket.events, - merge_request.as_ref(), - ); + let evidence = + ticket_evidence_summary(write_repository_key, &ticket.events, merge_request.as_ref()); let item_revision = ticket .events .iter() @@ -966,7 +969,7 @@ impl SqliteWorkspaceAuthority { &ticket.meta.id, )?) .ok_or_else(|| Error::Store(format!("missing resource key for {}", ticket.meta.id)))?; - let mut relations: TicketRelationView = ticket.relations.into(); + let mut relations = ticket_relation_view_from_domain(ticket.relations); for relation in &mut relations.outgoing { relation.target_resource_key = self.store.resource_key( &self.workspace_id, @@ -1000,8 +1003,7 @@ impl SqliteWorkspaceAuthority { item_revision, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, - repository_key, - ref_selector: ticket.meta.ref_selector, + targets, risk_flags: ticket.meta.risk_flags, body, body_truncated, @@ -1073,7 +1075,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { }) } - fn list_ticket_page(&self, request: TicketListPageRequest) -> Result { + fn list_ticket_page(&self, request: TicketListProjectionRequest) -> Result { let limit = request.limit.unwrap_or(30).clamp(1, 100); let mut states = request.states; states.sort(); @@ -1587,7 +1589,7 @@ impl MemoryAuthority for SqliteWorkspaceAuthority { Ok(memory_document_from_record(record)) } - fn list_memory_staging_records(&self, limit: usize) -> Result> { + fn list_memory_staging_records(&self, limit: usize) -> Result> { self.store .list_memory_staging_records(&self.workspace_id, limit) .map(|records| { @@ -1598,7 +1600,7 @@ impl MemoryAuthority for SqliteWorkspaceAuthority { }) } - fn memory_staging_record(&self, candidate_id: &str) -> Result { + fn memory_staging_record(&self, candidate_id: &str) -> Result { validate_memory_candidate_id(candidate_id)?; self.store .get_memory_staging_record(&self.workspace_id, candidate_id)? @@ -1613,7 +1615,7 @@ impl MemoryAuthority for SqliteWorkspaceAuthority { candidate_id: &str, raw_json: &str, source_path: Option<&str>, - ) -> Result { + ) -> Result { validate_memory_candidate_id(candidate_id)?; validate_json_object(raw_json, "raw_json")?; let imported_at = now_rfc3339(); @@ -1837,7 +1839,7 @@ fn substantive_item_edit(event: &TicketEvent) -> bool { changes .split(',') .map(str::trim) - .any(|field| matches!(field, "title" | "body" | "target")) + .any(|field| matches!(field, "title" | "body" | "target" | "targets")) } fn event_timestamp(event: &TicketEvent) -> Option> { @@ -1848,15 +1850,26 @@ fn event_timestamp(event: &TicketEvent) -> Option> { .map(|value| value.with_timezone(&Utc)) } +fn ticket_write_target_repository_key(targets: &[ticket::TicketTarget]) -> Option<&str> { + let mut write_targets = targets + .iter() + .filter(|target| target.access == ticket::TicketTargetAccess::ReadWrite); + let write_target = write_targets.next()?; + write_targets + .next() + .is_none() + .then_some(write_target.repository_key.as_str()) +} + fn ticket_evidence_summary( - ticket_repository_id: Option<&str>, + ticket_write_repository_key: Option<&str>, events: &[TicketEvent], merge_request: Option<&TicketMergeRequestSummary>, ) -> TicketEvidenceSummary { let linked_merge_request = merge_request.filter(|request| { request.state == "open" - && ticket_repository_id - .is_some_and(|repository_id| repository_id == request.repository_key) + && ticket_write_repository_key + .is_some_and(|repository_key| repository_key == request.repository_key) }); let has_merge_request = linked_merge_request.is_some(); let has_current_subject_ref = linked_merge_request.is_some_and(|request| { @@ -1906,8 +1919,8 @@ fn ticket_evidence_summary( None => missing.push("merge_request".to_string()), Some(request) if request.state != "open" => missing.push("open_merge_request".to_string()), Some(request) - if ticket_repository_id - .is_none_or(|repository_id| repository_id != request.repository_key) => + if ticket_write_repository_key + .is_none_or(|repository_key| repository_key != request.repository_key) => { missing.push("merge_request_repository".to_string()) } @@ -2634,8 +2647,8 @@ fn memory_document_from_record(record: MemoryDocumentRecord) -> MemoryDocument { } } -fn memory_staging_from_record(record: MemoryStagingRecord) -> MemoryStagingEntry { - MemoryStagingEntry { +fn memory_staging_from_record(record: MemoryStagingRecord) -> InternalMemoryStagingEntry { + InternalMemoryStagingEntry { candidate_id: record.candidate_id, raw_json: record.raw_json, source_path: record.source_path, @@ -2865,6 +2878,54 @@ mod tests { assert_eq!(moved.review_submitted_at, None); } + #[test] + fn ticket_target_projection_requires_exactly_one_write_target() { + let target = |repository_key: &str, access| ticket::TicketTarget { + repository_key: repository_key.to_string(), + ref_selector: Some("develop".to_string()), + access, + }; + + assert_eq!(ticket_write_target_repository_key(&[]), None); + assert_eq!( + ticket_write_target_repository_key(&[target( + "docs", + ticket::TicketTargetAccess::ReadOnly, + )]), + None, + ); + + let one_write = [ + target("main", ticket::TicketTargetAccess::ReadWrite), + target("docs", ticket::TicketTargetAccess::ReadOnly), + ]; + assert_eq!(ticket_write_target_repository_key(&one_write), Some("main"),); + + let two_writes = [ + target("main", ticket::TicketTargetAccess::ReadWrite), + target("docs", ticket::TicketTargetAccess::ReadWrite), + ]; + assert_eq!(ticket_write_target_repository_key(&two_writes), None); + } + + #[test] + fn ticket_evidence_is_tied_only_to_the_write_target() { + let read_only_request = merge_request_summary( + reviewed_merge_request(ReviewDecision::Approve, false), + "docs".to_string(), + Some("commit-1".to_string()), + ); + let evidence = ticket_evidence_summary(Some("main"), &[], Some(&read_only_request)); + + assert!(!evidence.has_merge_request); + assert!(!evidence.complete_for_integration); + assert!( + evidence + .missing + .contains(&"merge_request_repository".to_string()) + ); + } + #[test] fn ticket_readiness_requires_current_unrevoked_approval_without_a_report() { let approved = merge_request_summary( @@ -3013,7 +3074,7 @@ mod tests { let stale_events = vec![ticket_event( "item_edit", "2026-01-01T00:05:00Z", - Some("target"), + Some("targets"), )]; let stale = ticket_evidence_summary(Some("main"), &stale_events, Some(&approved_summary)); assert!(ticket_attention_matches( @@ -3330,7 +3391,7 @@ VALUES ('workspace-test', 'ticket', 4); assert_eq!(incoming_relation.items.len(), 1); assert_eq!(incoming_relation.items[0].id, "00000000001J5"); let summary_page = authority - .list_ticket_page(TicketListPageRequest { + .list_ticket_page(TicketListProjectionRequest { states: vec!["planning".to_string(), "ready".to_string()], limit: Some(1), cursor: None, @@ -3338,7 +3399,7 @@ VALUES ('workspace-test', 'ticket', 4); .unwrap(); assert_eq!(summary_page.items.len(), 1); assert!(summary_page.page.has_more); - let mismatched_summary_cursor = authority.list_ticket_page(TicketListPageRequest { + let mismatched_summary_cursor = authority.list_ticket_page(TicketListProjectionRequest { states: vec!["done".to_string()], limit: Some(1), cursor: summary_page.page.next_cursor, diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index f1d80a494..1629f73c7 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -107,16 +107,16 @@ where #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RuntimeDiagnostic { pub code: String, - pub severity: DiagnosticSeverity, + pub severity: HostDiagnosticSeverity, pub message: String, } impl RuntimeDiagnostic { pub fn new(code: impl Into, severity: &str, message: impl Into) -> Self { let severity = match severity { - "error" => DiagnosticSeverity::Error, - "warning" => DiagnosticSeverity::Warning, - _ => DiagnosticSeverity::Info, + "error" => HostDiagnosticSeverity::Error, + "warning" => HostDiagnosticSeverity::Warning, + _ => HostDiagnosticSeverity::Info, }; diagnostic(code, severity, message) } @@ -124,7 +124,7 @@ impl RuntimeDiagnostic { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum DiagnosticSeverity { +pub enum HostDiagnosticSeverity { Info, Warning, Error, @@ -132,79 +132,79 @@ pub enum DiagnosticSeverity { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum RuntimeSourceKind { +pub enum InternalRuntimeSourceKind { EmbeddedWorkerRuntime, RemoteHttp, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum RuntimeSourceStatus { +pub enum InternalRuntimeSourceStatus { Active, Reserved, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum RuntimeIdentityAuthority { +pub enum InternalRuntimeIdentityAuthority { /// Public Runtime/Host/Worker ids are registry projections, never raw /// socket addresses, session ids, credentials, or paths. RuntimeRegistryProjection, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeSourceSummary { - pub kind: RuntimeSourceKind, - pub status: RuntimeSourceStatus, - pub identity_authority: RuntimeIdentityAuthority, +pub struct InternalRuntimeSourceSummary { + pub kind: InternalRuntimeSourceKind, + pub status: InternalRuntimeSourceStatus, + pub identity_authority: InternalRuntimeIdentityAuthority, pub note: String, } -impl RuntimeSourceSummary { +impl InternalRuntimeSourceSummary { pub fn embedded_worker_runtime() -> Self { Self { - kind: RuntimeSourceKind::EmbeddedWorkerRuntime, - status: RuntimeSourceStatus::Active, - identity_authority: RuntimeIdentityAuthority::RuntimeRegistryProjection, + kind: InternalRuntimeSourceKind::EmbeddedWorkerRuntime, + status: InternalRuntimeSourceStatus::Active, + identity_authority: InternalRuntimeIdentityAuthority::RuntimeRegistryProjection, note: "backend-internal embedded worker-runtime Runtime exposed only through runtime_id plus worker_id projections".to_string(), } } pub fn embedded_worker_runtime_reserved() -> Self { Self { - kind: RuntimeSourceKind::EmbeddedWorkerRuntime, - status: RuntimeSourceStatus::Reserved, - identity_authority: RuntimeIdentityAuthority::RuntimeRegistryProjection, + kind: InternalRuntimeSourceKind::EmbeddedWorkerRuntime, + status: InternalRuntimeSourceStatus::Reserved, + identity_authority: InternalRuntimeIdentityAuthority::RuntimeRegistryProjection, note: "reserved boundary for an embedded worker-runtime adapter; not connected by this fixture source".to_string(), } } pub fn remote_http() -> Self { Self { - kind: RuntimeSourceKind::RemoteHttp, - status: RuntimeSourceStatus::Active, - identity_authority: RuntimeIdentityAuthority::RuntimeRegistryProjection, + kind: InternalRuntimeSourceKind::RemoteHttp, + status: InternalRuntimeSourceStatus::Active, + identity_authority: InternalRuntimeIdentityAuthority::RuntimeRegistryProjection, note: "backend-owned remote worker-runtime REST/WS client; endpoints and credentials remain backend-private".to_string(), } } pub fn remote_http_reserved() -> Self { Self { - kind: RuntimeSourceKind::RemoteHttp, - status: RuntimeSourceStatus::Reserved, - identity_authority: RuntimeIdentityAuthority::RuntimeRegistryProjection, + kind: InternalRuntimeSourceKind::RemoteHttp, + status: InternalRuntimeSourceStatus::Reserved, + identity_authority: InternalRuntimeIdentityAuthority::RuntimeRegistryProjection, note: "reserved boundary for a future remote Runtime adapter; no HTTP client or REST server is implemented here".to_string(), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeSummary { +pub struct InternalRuntimeSummary { pub runtime_id: String, pub label: String, pub kind: String, pub status: String, - pub source: RuntimeSourceSummary, + pub source: InternalRuntimeSourceSummary, pub host_ids: Vec, pub worker_creation_available: bool, pub os: String, @@ -213,7 +213,7 @@ pub struct RuntimeSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct HostSummary { +pub struct InternalHostSummary { pub runtime_id: String, pub host_id: String, pub label: String, @@ -227,7 +227,7 @@ pub struct HostSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkerWorkspaceSummary { +pub struct InternalWorkerWorkspaceSummary { pub visibility: String, pub identity: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -235,19 +235,19 @@ pub struct WorkerWorkspaceSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkerImplementationSummary { +pub struct InternalWorkerImplementationSummary { pub kind: String, pub display_hint: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkerCapabilitySummary { +pub struct InternalWorkerCapabilitySummary { pub can_stop: bool, pub can_spawn_followup: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkerSummary { +pub struct InternalWorkerSummary { #[serde(flatten)] pub worker: RuntimeWorkerRef, pub host_id: String, @@ -259,7 +259,7 @@ pub struct WorkerSummary { pub singleton_key: Option, #[serde(default)] pub tags: Vec, - pub workspace: WorkerWorkspaceSummary, + pub workspace: InternalWorkerWorkspaceSummary, /// Runtime catalog lifecycle compatibility state. pub state: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -269,8 +269,8 @@ pub struct WorkerSummary { pub pinned: bool, #[serde(default)] pub retention_state: String, - pub implementation: WorkerImplementationSummary, - pub capabilities: WorkerCapabilitySummary, + pub implementation: InternalWorkerImplementationSummary, + pub capabilities: InternalWorkerCapabilitySummary, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub workdir_attachments: Vec, pub diagnostics: Vec, @@ -279,9 +279,9 @@ pub struct WorkerSummary { impl From for server_api::Diagnostic { fn from(diagnostic: RuntimeDiagnostic) -> Self { let severity = match diagnostic.severity { - DiagnosticSeverity::Info => server_api::DiagnosticSeverity::Info, - DiagnosticSeverity::Warning => server_api::DiagnosticSeverity::Warning, - DiagnosticSeverity::Error => server_api::DiagnosticSeverity::Error, + HostDiagnosticSeverity::Info => server_api::DiagnosticSeverity::Info, + HostDiagnosticSeverity::Warning => server_api::DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Error => server_api::DiagnosticSeverity::Error, }; Self { code: diagnostic.code, @@ -291,20 +291,37 @@ impl From for server_api::Diagnostic { } } -impl From for server_api::RuntimeSourceSummary { - fn from(source: RuntimeSourceSummary) -> Self { +impl From for server_api::HostSummary { + fn from(host: InternalHostSummary) -> Self { + Self { + runtime_id: host.runtime_id, + host_id: host.host_id, + label: host.label, + kind: host.kind, + status: host.status, + observed_at: host.observed_at, + last_seen_at: host.last_seen_at, + os: host.os, + arch: host.arch, + diagnostics: host.diagnostics.into_iter().map(Into::into).collect(), + } + } +} + +impl From for server_api::RuntimeSourceSummary { + fn from(source: InternalRuntimeSourceSummary) -> Self { let kind = match source.kind { - RuntimeSourceKind::EmbeddedWorkerRuntime => { + InternalRuntimeSourceKind::EmbeddedWorkerRuntime => { server_api::RuntimeSourceKind::EmbeddedWorkerRuntime } - RuntimeSourceKind::RemoteHttp => server_api::RuntimeSourceKind::RemoteHttp, + InternalRuntimeSourceKind::RemoteHttp => server_api::RuntimeSourceKind::RemoteHttp, }; let status = match source.status { - RuntimeSourceStatus::Active => server_api::RuntimeSourceStatus::Active, - RuntimeSourceStatus::Reserved => server_api::RuntimeSourceStatus::Reserved, + InternalRuntimeSourceStatus::Active => server_api::RuntimeSourceStatus::Active, + InternalRuntimeSourceStatus::Reserved => server_api::RuntimeSourceStatus::Reserved, }; let identity_authority = match source.identity_authority { - RuntimeIdentityAuthority::RuntimeRegistryProjection => { + InternalRuntimeIdentityAuthority::RuntimeRegistryProjection => { server_api::RuntimeIdentityAuthority::RuntimeRegistryProjection } }; @@ -317,8 +334,8 @@ impl From for server_api::RuntimeSourceSummary { } } -impl From for server_api::RuntimeSummary { - fn from(runtime: RuntimeSummary) -> Self { +impl From for server_api::RuntimeSummary { + fn from(runtime: InternalRuntimeSummary) -> Self { Self { runtime_id: runtime.runtime_id, label: runtime.label, @@ -335,7 +352,7 @@ impl From for server_api::RuntimeSummary { } pub(crate) fn workspace_worker_summary( - summary: WorkerSummary, + summary: InternalWorkerSummary, resource_key: String, workdir_attachments: Vec, ) -> server_api::WorkerSummary { @@ -374,18 +391,18 @@ pub(crate) fn workspace_worker_summary( #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] -pub struct WorkerRestoreResult { +pub struct InternalWorkerRestoreResult { pub state: server_api::WorkerRestoreState, #[serde(skip_serializing_if = "Option::is_none")] - pub worker: Option, + pub worker: Option, pub diagnostics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerWorkspaceApiResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, #[serde(skip_serializing_if = "Option::is_none")] - pub worker: Option, + pub worker: Option, pub diagnostics: Vec, } @@ -404,13 +421,13 @@ impl RuntimeList { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerLookupResult { #[serde(skip_serializing_if = "Option::is_none")] - pub worker: Option, + pub worker: Option, pub diagnostics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RuntimeWorkingDirectoryResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, #[serde(skip_serializing_if = "Option::is_none")] pub working_directory: Option, pub diagnostics: Vec, @@ -570,16 +587,16 @@ fn initial_worker_input(segments: &[Segment]) -> Option { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerSpawnResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, #[serde(skip_serializing_if = "Option::is_none")] - pub worker: Option, + pub worker: Option, pub acceptance_evidence: Vec, pub diagnostics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ConfigBundleSyncResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, #[serde(skip_serializing_if = "Option::is_none")] pub availability: Option, pub diagnostics: Vec, @@ -587,7 +604,7 @@ pub struct ConfigBundleSyncResult { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ConfigBundleCheckResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, #[serde(skip_serializing_if = "Option::is_none")] pub availability: Option, pub diagnostics: Vec, @@ -605,7 +622,7 @@ fn required_worker_workspace_api( request.resolved_workspace_api.clone().ok_or_else(|| { diagnostic( "worker_workspace_api_missing", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Workspace-bound Worker spawn requires a resolved Workspace API binding", ) }) @@ -613,18 +630,18 @@ fn required_worker_workspace_api( #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum WorkerOperationState { +pub enum InternalWorkerOperationState { Accepted, Unsupported, Rejected, } -impl From for server_api::WorkerOperationState { - fn from(state: WorkerOperationState) -> Self { +impl From for server_api::WorkerOperationState { + fn from(state: InternalWorkerOperationState) -> Self { match state { - WorkerOperationState::Accepted => Self::Accepted, - WorkerOperationState::Unsupported => Self::Unsupported, - WorkerOperationState::Rejected => Self::Rejected, + InternalWorkerOperationState::Accepted => Self::Accepted, + InternalWorkerOperationState::Unsupported => Self::Unsupported, + InternalWorkerOperationState::Rejected => Self::Rejected, } } } @@ -650,7 +667,7 @@ pub enum WorkerStopMode { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerStopResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, pub diagnostics: Vec, } @@ -664,7 +681,7 @@ pub struct WorkerLifecycleRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerLifecycleResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, #[serde(flatten)] pub worker: RuntimeWorkerRef, pub diagnostics: Vec, @@ -682,7 +699,7 @@ pub enum WorkerInputKind { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerDeleteResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, #[serde(flatten)] pub worker: RuntimeWorkerRef, pub deleted: bool, @@ -717,7 +734,7 @@ pub struct WorkerCompletionsResult { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerInputResult { - pub state: WorkerOperationState, + pub state: InternalWorkerOperationState, #[serde(flatten)] pub worker: RuntimeWorkerRef, pub diagnostics: Vec, @@ -816,7 +833,7 @@ impl RuntimePingFailure { ) -> Self { Self { kind, - diagnostic: diagnostic(code, DiagnosticSeverity::Error, message.into()), + diagnostic: diagnostic(code, HostDiagnosticSeverity::Error, message.into()), } } } @@ -824,7 +841,7 @@ impl RuntimePingFailure { pub trait WorkspaceWorkerRuntime: Send + Sync { fn runtime_id(&self) -> &str; - fn runtime_summary(&self, limit: usize) -> RuntimeSummary; + fn runtime_summary(&self, limit: usize) -> InternalRuntimeSummary; fn ping(&self) -> Result { Err(RuntimePingFailure::new( @@ -871,23 +888,23 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { )) } - fn list_hosts(&self, limit: usize) -> RuntimeList; + fn list_hosts(&self, limit: usize) -> RuntimeList; - fn list_workers(&self, limit: usize) -> RuntimeList; + fn list_workers(&self, limit: usize) -> RuntimeList; - fn list_stopped_workers(&self, _limit: usize) -> RuntimeList { + fn list_stopped_workers(&self, _limit: usize) -> RuntimeList { RuntimeList::new(Vec::new(), Vec::new()) } fn worker(&self, worker_id: &str) -> WorkerLookupResult; - fn restore_worker(&self, worker_id: &str) -> WorkerRestoreResult { - WorkerRestoreResult { + fn restore_worker(&self, worker_id: &str) -> InternalWorkerRestoreResult { + InternalWorkerRestoreResult { state: server_api::WorkerRestoreState::Rejected, worker: None, diagnostics: vec![diagnostic( "worker_restore_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!("runtime does not implement worker restore for `{worker_id}`"), )], } @@ -899,11 +916,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { _workspace_api: WorkspaceApiRef, ) -> WorkerWorkspaceApiResult { WorkerWorkspaceApiResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, worker: None, diagnostics: vec![diagnostic( "worker_workspace_api_replace_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "runtime does not support replacing the Workspace API for worker `{worker_id}`" ), @@ -917,11 +934,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { _attachments: Vec, ) -> WorkerWorkspaceApiResult { WorkerWorkspaceApiResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, worker: None, diagnostics: vec![diagnostic( "worker_workdir_attachments_replace_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "runtime does not support replacing Workdir attachments for worker `{worker_id}`" ), @@ -934,11 +951,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { _request: WorkingDirectoryRequest, ) -> RuntimeWorkingDirectoryResult { RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, working_directory: None, diagnostics: vec![diagnostic( "runtime_working_directory_create_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "runtime does not implement working directory creation".to_string(), )], } @@ -971,11 +988,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { fn working_directory(&self, working_directory_id: &str) -> RuntimeWorkingDirectoryResult { RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, working_directory: None, diagnostics: vec![diagnostic( "runtime_working_directory_lookup_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "runtime does not implement working directory lookup for `{working_directory_id}`" ), @@ -1002,11 +1019,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { working_directory_id: &str, ) -> RuntimeWorkingDirectoryResult { RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, working_directory: None, diagnostics: vec![diagnostic( "runtime_working_directory_cleanup_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "runtime does not implement working directory cleanup for `{working_directory_id}`" ), @@ -1020,12 +1037,12 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { request: WorkerSpawnRequest, ) -> WorkerSpawnResult { WorkerSpawnResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, worker: None, acceptance_evidence: Vec::new(), diagnostics: vec![diagnostic( "worker_spawn_resolver_pending", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "worker spawn intent '{}' was accepted as a typed request shape, but launch resolution is not implemented by this registry surface", worker_spawn_intent_label(&request.intent) @@ -1043,11 +1060,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { fn sync_config_bundle(&self, _bundle: ConfigBundle) -> ConfigBundleSyncResult { ConfigBundleSyncResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, availability: None, diagnostics: vec![diagnostic( "config_bundle_sync_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "runtime does not implement config bundle sync".to_string(), )], } @@ -1055,11 +1072,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { fn check_config_bundle(&self, _reference: ConfigBundleRef) -> ConfigBundleCheckResult { ConfigBundleCheckResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, availability: None, diagnostics: vec![diagnostic( "config_bundle_check_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "runtime does not implement config bundle availability checks".to_string(), )], } @@ -1070,7 +1087,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { bundles: Vec::new(), diagnostics: vec![diagnostic( "config_bundle_list_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "runtime does not implement config bundle listing".to_string(), )], } @@ -1094,11 +1111,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { _request: WorkerLifecycleRequest, ) -> WorkerLifecycleResult { WorkerLifecycleResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()), diagnostics: vec![diagnostic( "worker_stop_pending", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "worker stop for '{worker_id}' is reserved for the runtime service boundary and is not implemented by this registry surface" ), @@ -1112,11 +1129,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { _request: WorkerLifecycleRequest, ) -> WorkerLifecycleResult { WorkerLifecycleResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()), diagnostics: vec![diagnostic( "worker_cancel_pending", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "worker cancel for '{worker_id}' is reserved for the runtime service boundary and is not implemented by this registry surface" ), @@ -1126,12 +1143,12 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult { WorkerDeleteResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()), deleted: false, diagnostics: vec![diagnostic( "worker_delete_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!("runtime does not implement worker deletion for '{worker_id}'"), )], } @@ -1176,11 +1193,11 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { fn send_input(&self, worker_id: &str, _request: WorkerInputRequest) -> WorkerInputResult { WorkerInputResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()), diagnostics: vec![diagnostic( "worker_input_pending", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "worker input for '{worker_id}' is reserved for the runtime service boundary and is not implemented by this registry source" ), @@ -1229,7 +1246,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { entries: Vec::new(), diagnostics: vec![diagnostic( "worker_completions_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!("runtime does not implement completions for worker '{worker_id}'"), )], } @@ -1241,7 +1258,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { status: "not_implemented".to_string(), diagnostics: vec![diagnostic( "worker_proxy_pending", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "worker proxy connect points for '{}' are not implemented by this overview-only registry surface", worker_id @@ -1361,7 +1378,7 @@ impl RuntimeRegistry { } } - pub fn list_runtimes(&self, limit: usize) -> RuntimeList { + pub fn list_runtimes(&self, limit: usize) -> RuntimeList { let mut diagnostics = Vec::new(); let mut items = Vec::new(); for runtime in self.runtimes_snapshot().iter().take(limit) { @@ -1373,7 +1390,7 @@ impl RuntimeRegistry { RuntimeList::new(items, diagnostics) } - pub fn list_hosts(&self, limit: usize) -> RuntimeList { + pub fn list_hosts(&self, limit: usize) -> RuntimeList { let mut items = Vec::new(); let mut diagnostics = Vec::new(); for runtime in self.runtimes_snapshot() { @@ -1388,7 +1405,7 @@ impl RuntimeRegistry { RuntimeList::new(items, diagnostics) } - pub fn list_workers(&self, limit: usize) -> RuntimeList { + pub fn list_workers(&self, limit: usize) -> RuntimeList { let mut items = Vec::new(); let mut diagnostics = Vec::new(); for runtime in self.runtimes_snapshot() { @@ -1411,7 +1428,7 @@ impl RuntimeRegistry { &self, runtime_id: &str, limit: usize, - ) -> Result, RuntimeRegistryError> { + ) -> Result, RuntimeRegistryError> { validate_backend_identifier("runtime_id", runtime_id)?; let runtime = self.runtime(runtime_id)?; let worker_list = runtime.list_workers(limit); @@ -1426,7 +1443,7 @@ impl RuntimeRegistry { &self, runtime_id: &str, limit: usize, - ) -> Result, RuntimeRegistryError> { + ) -> Result, RuntimeRegistryError> { validate_backend_identifier("runtime_id", runtime_id)?; let runtime = self.runtime(runtime_id)?; let worker_list = runtime.list_stopped_workers(limit); @@ -1441,7 +1458,7 @@ impl RuntimeRegistry { &self, host_id: &str, limit: usize, - ) -> Result, RuntimeRegistryError> { + ) -> Result, RuntimeRegistryError> { validate_backend_identifier("host_id", host_id)?; let mut host_found = false; @@ -1475,7 +1492,10 @@ impl RuntimeRegistry { } } - pub fn worker(&self, worker: &RuntimeWorkerRef) -> Result { + pub fn worker( + &self, + worker: &RuntimeWorkerRef, + ) -> Result { let runtime_id = worker.runtime_id.as_str(); let worker_id = worker.worker_id.as_str(); validate_backend_identifier("runtime_id", runtime_id)?; @@ -1491,7 +1511,7 @@ impl RuntimeRegistry { pub fn restore_worker( &self, worker: &RuntimeWorkerRef, - ) -> Result { + ) -> Result { let runtime_id = worker.runtime_id.as_str(); let worker_id = worker.worker_id.as_str(); validate_backend_identifier("runtime_id", runtime_id)?; @@ -1539,7 +1559,7 @@ impl RuntimeRegistry { .map(|message| { diagnostic( "workspace_prompt_projection_notification_failed", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, format!( "runtime '{}' rejected Workspace Prompt projection revision {}: {message}", runtime.runtime_id(), projection.config_revision @@ -1587,7 +1607,7 @@ impl RuntimeRegistry { && let Some(bundle) = request.resolved_config_bundle.clone() { let sync = runtime.sync_config_bundle(bundle); - if sync.state != WorkerOperationState::Accepted { + if sync.state != InternalWorkerOperationState::Accepted { let message = sync .diagnostics .first() @@ -2173,7 +2193,10 @@ impl EmbeddedWorkerRuntime { runtime_worker_can_stop(self.execution_enabled, status) } - fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary { + fn map_worker_summary( + &self, + summary: worker_runtime::catalog::WorkerSummary, + ) -> InternalWorkerSummary { let worker_id = summary.worker_ref.worker_id.to_string(); let profile = embedded_profile_label(&summary.profile); let display = worker_display_metadata( @@ -2182,7 +2205,7 @@ impl EmbeddedWorkerRuntime { summary.display_name.as_deref(), true, ); - WorkerSummary { + InternalWorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), host_id: self.host_id.clone(), display_name: display.display_name.clone(), @@ -2190,7 +2213,7 @@ impl EmbeddedWorkerRuntime { profile, singleton_key: display.singleton_key, tags: display.tags, - workspace: WorkerWorkspaceSummary { + workspace: InternalWorkerWorkspaceSummary { visibility: "backend_internal".to_string(), identity: "runtime_registry_worker".to_string(), workspace_id: summary.workspace_id.clone(), @@ -2204,11 +2227,11 @@ impl EmbeddedWorkerRuntime { last_seen_at: None, pinned: false, retention_state: "transient".to_string(), - implementation: WorkerImplementationSummary { + implementation: InternalWorkerImplementationSummary { kind: "embedded_worker_runtime".to_string(), display_hint: "backend-internal worker-runtime Worker".to_string(), }, - capabilities: WorkerCapabilitySummary { + capabilities: InternalWorkerCapabilitySummary { can_stop: summary.execution_metadata_available && self.can_stop_embedded_worker(summary.status), can_spawn_followup: false, @@ -2220,7 +2243,7 @@ impl EmbeddedWorkerRuntime { } } - fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary { + fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> InternalWorkerSummary { let worker_id = detail.worker_id.to_string(); let profile = embedded_profile_label(&detail.profile); let display = worker_display_metadata( @@ -2229,7 +2252,7 @@ impl EmbeddedWorkerRuntime { detail.display_name.as_deref(), true, ); - WorkerSummary { + InternalWorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), host_id: self.host_id.clone(), display_name: display.display_name.clone(), @@ -2237,7 +2260,7 @@ impl EmbeddedWorkerRuntime { profile, singleton_key: display.singleton_key, tags: display.tags, - workspace: WorkerWorkspaceSummary { + workspace: InternalWorkerWorkspaceSummary { visibility: "backend_internal".to_string(), identity: "runtime_registry_worker".to_string(), workspace_id: detail.workspace_id.clone(), @@ -2248,11 +2271,11 @@ impl EmbeddedWorkerRuntime { last_seen_at: None, pinned: false, retention_state: "transient".to_string(), - implementation: WorkerImplementationSummary { + implementation: InternalWorkerImplementationSummary { kind: "embedded_worker_runtime".to_string(), display_hint: "backend-internal worker-runtime Worker".to_string(), }, - capabilities: WorkerCapabilitySummary { + capabilities: InternalWorkerCapabilitySummary { can_stop: detail.execution_metadata_available && self.can_stop_embedded_worker(detail.status), can_spawn_followup: false, @@ -2270,18 +2293,18 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { &self.runtime_id } - fn runtime_summary(&self, limit: usize) -> RuntimeSummary { + fn runtime_summary(&self, limit: usize) -> InternalRuntimeSummary { let mut diagnostics = Vec::new(); let summary = match self.runtime.summary() { Ok(summary) => summary, Err(err) => { diagnostics.push(embedded_runtime_diagnostic(&err)); - return RuntimeSummary { + return InternalRuntimeSummary { runtime_id: self.runtime_id.clone(), label: "Embedded backend Runtime".to_string(), kind: "embedded_worker_runtime".to_string(), status: "unavailable".to_string(), - source: RuntimeSourceSummary::embedded_worker_runtime(), + source: InternalRuntimeSourceSummary::embedded_worker_runtime(), host_ids: Vec::new(), worker_creation_available: false, os: std::env::consts::OS.to_string(), @@ -2291,7 +2314,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { } }; - RuntimeSummary { + InternalRuntimeSummary { runtime_id: self.runtime_id.clone(), label: summary .display_name @@ -2299,7 +2322,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .unwrap_or_else(|| "Embedded backend Runtime".to_string()), kind: "embedded_worker_runtime".to_string(), status: embedded_runtime_status_label(summary.status).to_string(), - source: RuntimeSourceSummary::embedded_worker_runtime(), + source: InternalRuntimeSourceSummary::embedded_worker_runtime(), host_ids: if limit == 0 { Vec::new() } else { @@ -2312,12 +2335,12 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { } } - fn list_hosts(&self, limit: usize) -> RuntimeList { + fn list_hosts(&self, limit: usize) -> RuntimeList { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); } RuntimeList::new( - vec![HostSummary { + vec![InternalHostSummary { runtime_id: self.runtime_id.clone(), host_id: self.host_id.clone(), label: "embedded".to_string(), @@ -2329,7 +2352,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { arch: std::env::consts::ARCH.to_string(), diagnostics: vec![diagnostic( "embedded_runtime_host_boundary", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "Backend-internal host exposes only bounded runtime and worker projections" .to_string(), )], @@ -2338,7 +2361,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { ) } - fn list_workers(&self, limit: usize) -> RuntimeList { + fn list_workers(&self, limit: usize) -> RuntimeList { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); } @@ -2355,7 +2378,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { } } - fn list_stopped_workers(&self, limit: usize) -> RuntimeList { + fn list_stopped_workers(&self, limit: usize) -> RuntimeList { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); } @@ -2378,7 +2401,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { worker: None, diagnostics: vec![diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot be resolved".to_string(), )], }; @@ -2399,14 +2422,14 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { } } - fn restore_worker(&self, worker_id: &str) -> WorkerRestoreResult { + fn restore_worker(&self, worker_id: &str) -> InternalWorkerRestoreResult { let Some(worker_ref) = self.worker_ref(worker_id) else { - return WorkerRestoreResult { + return InternalWorkerRestoreResult { state: server_api::WorkerRestoreState::Rejected, worker: None, diagnostics: vec![diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot be restored".to_string(), )], }; @@ -2415,17 +2438,17 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { Ok(result) => { let diagnostics = match (result.reason_code, result.message) { (Some(code), Some(message)) => { - vec![diagnostic(code, DiagnosticSeverity::Warning, message)] + vec![diagnostic(code, HostDiagnosticSeverity::Warning, message)] } _ => Vec::new(), }; - WorkerRestoreResult { + InternalWorkerRestoreResult { state: result.state, worker: result.worker.map(|detail| self.map_worker_detail(detail)), diagnostics, } } - Err(err) => WorkerRestoreResult { + Err(err) => InternalWorkerRestoreResult { state: server_api::WorkerRestoreState::ReconciliationRequired, worker: None, diagnostics: vec![embedded_runtime_diagnostic(&err)], @@ -2440,11 +2463,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { ) -> WorkerWorkspaceApiResult { let Some(worker_ref) = self.worker_ref(worker_id) else { return WorkerWorkspaceApiResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, diagnostics: vec![diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot receive Workspace access".to_string(), )], }; @@ -2454,12 +2477,12 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .replace_worker_workspace_api(&worker_ref, workspace_api) { Ok(detail) => WorkerWorkspaceApiResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: Some(self.map_worker_detail(detail)), diagnostics: Vec::new(), }, Err(err) => WorkerWorkspaceApiResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, diagnostics: vec![embedded_runtime_diagnostic(&err)], }, @@ -2473,11 +2496,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { ) -> WorkerWorkspaceApiResult { let Some(worker_ref) = self.worker_ref(worker_id) else { return WorkerWorkspaceApiResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, diagnostics: vec![diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot change Workdir attachments".to_string(), )], }; @@ -2487,12 +2510,12 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .replace_worker_workdir_attachments(&worker_ref, attachments) { Ok(detail) => WorkerWorkspaceApiResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: Some(self.map_worker_detail(detail)), diagnostics: Vec::new(), }, Err(err) => WorkerWorkspaceApiResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, diagnostics: vec![embedded_runtime_diagnostic(&err)], }, @@ -2504,7 +2527,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { _request: WorkingDirectoryRequest, ) -> RuntimeWorkingDirectoryResult { RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![embedded_workdir_unsupported_diagnostic()], } @@ -2538,7 +2561,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { fn working_directory(&self, _working_directory_id: &str) -> RuntimeWorkingDirectoryResult { RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![embedded_workdir_unsupported_diagnostic()], } @@ -2549,7 +2572,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { _working_directory_id: &str, ) -> RuntimeWorkingDirectoryResult { RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![embedded_workdir_unsupported_diagnostic()], } @@ -2566,7 +2589,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { { diagnostics.push(embedded_workdir_unsupported_diagnostic()); return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics, @@ -2578,11 +2601,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { ) { diagnostics.push(diagnostic( "embedded_runtime_no_socket", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Embedded backend Runtime is transportless; use run_accepted/create acceptance for backend-internal Workers".to_string(), )); return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics, @@ -2591,7 +2614,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { if request.requested_worker_name.is_some() { diagnostics.push(diagnostic( "embedded_worker_name_display_only", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "requested_worker_name is used only as display_name; Worker identity is allocated by Workspace authority".to_string(), )); } @@ -2599,7 +2622,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { { diagnostics.push(diagnostic( "embedded_runtime_acceptance_projection", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "Embedded Runtime accepts creation through a runtime execution backend; provider segment counts are observed after execution, not faked at create time".to_string(), )); } @@ -2610,11 +2633,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { Err(error) => { diagnostics.push(diagnostic( "embedded_profile_source_archive_invalid", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error, )); return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics, @@ -2626,7 +2649,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { Err(diagnostic) => { diagnostics.push(diagnostic); return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics, @@ -2648,7 +2671,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .create_worker_scoped(&workspace_scope, create_request) { Ok(detail) => WorkerSpawnResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: Some(self.map_worker_detail(detail)), acceptance_evidence: vec![ WorkerSpawnAcceptanceEvidence { @@ -2668,7 +2691,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { Err(err) => { diagnostics.push(embedded_runtime_diagnostic(&err)); WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics, @@ -2689,12 +2712,12 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { fn sync_config_bundle(&self, bundle: ConfigBundle) -> ConfigBundleSyncResult { match self.runtime.store_config_bundle(bundle) { Ok(availability) => ConfigBundleSyncResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, availability: Some(availability), diagnostics: Vec::new(), }, Err(error) => ConfigBundleSyncResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, availability: None, diagnostics: vec![embedded_runtime_diagnostic(&error)], }, @@ -2704,12 +2727,12 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { fn check_config_bundle(&self, reference: ConfigBundleRef) -> ConfigBundleCheckResult { match self.runtime.check_config_bundle(&reference) { Ok(availability) => ConfigBundleCheckResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, availability: Some(availability), diagnostics: Vec::new(), }, Err(error) => ConfigBundleCheckResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, availability: None, diagnostics: vec![embedded_runtime_diagnostic(&error)], }, @@ -2740,7 +2763,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { worker_id, diagnostic( "embedded_worker_execution_unavailable", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!("worker stop for '{worker_id}' requires an embedded execution backend"), ), ); @@ -2751,14 +2774,14 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { worker_id, diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot be resolved".to_string(), ), ); }; match self.runtime.stop_worker(&worker_ref, request.reason) { Ok(_) => WorkerLifecycleResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), diagnostics: Vec::new(), }, @@ -2781,7 +2804,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { worker_id, diagnostic( "embedded_worker_execution_unavailable", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "worker cancel for '{worker_id}' requires an embedded execution backend" ), @@ -2794,14 +2817,14 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { worker_id, diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot be resolved".to_string(), ), ); }; match self.runtime.cancel_worker(&worker_ref, request.reason) { Ok(_) => WorkerLifecycleResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), diagnostics: Vec::new(), }, @@ -2816,19 +2839,19 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult { let Some(worker_ref) = self.worker_ref(worker_id) else { return WorkerDeleteResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), deleted: false, diagnostics: vec![diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot be resolved".to_string(), )], }; }; match self.runtime.delete_worker(&worker_ref) { Ok(result) => WorkerDeleteResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new( self.runtime_id.clone(), result.worker_id.to_string(), @@ -2837,7 +2860,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { diagnostics: Vec::new(), }, Err(error) => WorkerDeleteResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), deleted: false, diagnostics: vec![embedded_runtime_diagnostic(&error)], @@ -2932,7 +2955,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { worker_id, diagnostic( "embedded_worker_execution_unavailable", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "worker input for '{worker_id}' requires an embedded execution backend" ), @@ -2945,7 +2968,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { worker_id, diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot be resolved".to_string(), ), ); @@ -2964,7 +2987,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { }; match self.runtime.send_input(&worker_ref, input) { Ok(_) => WorkerInputResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), diagnostics: Vec::new(), }, @@ -3040,7 +3063,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { entries: Vec::new(), diagnostics: vec![diagnostic( "embedded_worker_execution_unavailable", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "worker completions for '{worker_id}' require an embedded execution backend" ), @@ -3055,7 +3078,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { entries: Vec::new(), diagnostics: vec![diagnostic( "embedded_worker_id_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Worker id was empty and cannot be resolved".to_string(), )], }; @@ -3151,7 +3174,7 @@ impl WorkspaceRuntimeAuthorization { .map_err(|_| { diagnostic( "workspace_runtime_authorization_unavailable", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Workspace Runtime authorization is unavailable".to_string(), ) })? @@ -3159,7 +3182,7 @@ impl WorkspaceRuntimeAuthorization { .ok_or_else(|| { diagnostic( "workspace_runtime_verification_required", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Workspace Runtime binding is not verified".to_string(), ) })?; @@ -3172,14 +3195,14 @@ impl WorkspaceRuntimeAuthorization { .map_err(|error| { diagnostic( "workspace_runtime_authorization_unavailable", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error.to_string(), ) })? { return Err(diagnostic( "workspace_runtime_authorization_stale", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Workspace Runtime binding changed or was revoked".to_string(), )); } @@ -3189,21 +3212,21 @@ impl WorkspaceRuntimeAuthorization { .map_err(|error| { diagnostic( "workspace_runtime_authorization_unavailable", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error.to_string(), ) })?; let workspace_key_id = binding.workspace_key_id.as_deref().ok_or_else(|| { diagnostic( "workspace_runtime_authorization_invalid", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Workspace Runtime binding is missing its Workspace key".to_string(), ) })?; let trust_generation = binding.workspace_key_generation.ok_or_else(|| { diagnostic( "workspace_runtime_authorization_invalid", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Workspace Runtime binding is missing its trust generation".to_string(), ) })?; @@ -3220,14 +3243,14 @@ impl WorkspaceRuntimeAuthorization { .map_err(|error| { diagnostic( "workspace_runtime_authorization_unavailable", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error.to_string(), ) })? { return Err(diagnostic( "workspace_runtime_authorization_stale", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Workspace signing identity no longer matches the verified binding".to_string(), )); } @@ -3254,7 +3277,7 @@ impl WorkspaceRuntimeAuthorization { .map_err(|error| { diagnostic( "workspace_runtime_authorization_sign_failed", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error.to_string(), ) }) @@ -3739,7 +3762,7 @@ impl RemoteWorkerRuntime { .map_err(|error| { diagnostic( "remote_runtime_client_build_failed", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error.to_string(), ) })? @@ -3755,7 +3778,7 @@ impl RemoteWorkerRuntime { .map_err(|error| { diagnostic( "remote_runtime_client_build_failed", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error.to_string(), ) }) @@ -3889,7 +3912,7 @@ impl RemoteWorkerRuntime { let body = serde_json::to_vec(body).map_err(|error| { diagnostic( "remote_runtime_request_encode_failed", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error.to_string(), ) })?; @@ -3971,7 +3994,7 @@ impl RemoteWorkerRuntime { .map_err(|_| { diagnostic( "remote_runtime_response_read_failed", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, format!( "Remote Runtime response could not be read for '{}'", runtime_id @@ -3981,7 +4004,7 @@ impl RemoteWorkerRuntime { if body.len() > MAX_REMOTE_RUNTIME_RESPONSE_BYTES { return Err(diagnostic( "remote_runtime_response_too_large", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, format!( "Remote Runtime response exceeded the allowed size for '{}'", runtime_id @@ -3991,7 +4014,7 @@ impl RemoteWorkerRuntime { serde_json::from_slice::(&body).map_err(|_| { diagnostic( "remote_runtime_malformed_response", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, format!( "Remote Runtime returned malformed JSON for '{}'", runtime_id @@ -4004,7 +4027,10 @@ impl RemoteWorkerRuntime { }) } - fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary { + fn map_worker_summary( + &self, + summary: worker_runtime::catalog::WorkerSummary, + ) -> InternalWorkerSummary { let worker_id = summary.worker_ref.worker_id.to_string(); let profile = embedded_profile_label(&summary.profile); let display = worker_display_metadata( @@ -4013,7 +4039,7 @@ impl RemoteWorkerRuntime { summary.display_name.as_deref(), false, ); - WorkerSummary { + InternalWorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), host_id: self.host_id.clone(), display_name: display.display_name.clone(), @@ -4021,7 +4047,7 @@ impl RemoteWorkerRuntime { profile, singleton_key: display.singleton_key, tags: display.tags, - workspace: WorkerWorkspaceSummary { + workspace: InternalWorkerWorkspaceSummary { visibility: "remote_runtime".to_string(), identity: "runtime_registry_worker".to_string(), workspace_id: summary.workspace_id.clone(), @@ -4035,11 +4061,11 @@ impl RemoteWorkerRuntime { last_seen_at: None, pinned: false, retention_state: "transient".to_string(), - implementation: WorkerImplementationSummary { + implementation: InternalWorkerImplementationSummary { kind: "remote_worker_runtime".to_string(), display_hint: "Backend-proxied remote worker-runtime Worker".to_string(), }, - capabilities: WorkerCapabilitySummary { + capabilities: InternalWorkerCapabilitySummary { can_stop: summary.execution_metadata_available && runtime_worker_can_stop(true, summary.status), can_spawn_followup: false, @@ -4049,7 +4075,7 @@ impl RemoteWorkerRuntime { } } - fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> WorkerSummary { + fn map_worker_detail(&self, detail: EmbeddedWorkerDetail) -> InternalWorkerSummary { let worker_id = detail.worker_id.to_string(); let profile = embedded_profile_label(&detail.profile); let display = worker_display_metadata( @@ -4058,7 +4084,7 @@ impl RemoteWorkerRuntime { detail.display_name.as_deref(), false, ); - WorkerSummary { + InternalWorkerSummary { worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()), host_id: self.host_id.clone(), display_name: display.display_name.clone(), @@ -4066,7 +4092,7 @@ impl RemoteWorkerRuntime { profile, singleton_key: display.singleton_key, tags: display.tags, - workspace: WorkerWorkspaceSummary { + workspace: InternalWorkerWorkspaceSummary { visibility: "remote_runtime".to_string(), identity: "runtime_registry_worker".to_string(), workspace_id: detail.workspace_id.clone(), @@ -4077,11 +4103,11 @@ impl RemoteWorkerRuntime { last_seen_at: None, pinned: false, retention_state: "transient".to_string(), - implementation: WorkerImplementationSummary { + implementation: InternalWorkerImplementationSummary { kind: "remote_worker_runtime".to_string(), display_hint: "Backend-proxied remote worker-runtime Worker".to_string(), }, - capabilities: WorkerCapabilitySummary { + capabilities: InternalWorkerCapabilitySummary { can_stop: detail.execution_metadata_available && runtime_worker_can_stop(true, detail.status), can_spawn_followup: false, @@ -4097,11 +4123,11 @@ impl RemoteWorkerRuntime { response: worker_runtime::catalog::WorkerLifecycleAck, ) -> WorkerLifecycleResult { WorkerLifecycleResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), diagnostics: vec![diagnostic( "remote_runtime_lifecycle_accepted", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, format!( "Remote Runtime acknowledged lifecycle operation for '{worker_id}' with status {}", embedded_worker_status_label(response.status) @@ -4116,7 +4142,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { &self.runtime_id } - fn runtime_summary(&self, limit: usize) -> RuntimeSummary { + fn runtime_summary(&self, limit: usize) -> InternalRuntimeSummary { match self .run_runtime_api( self.request_timeout, @@ -4128,14 +4154,14 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { value.runtime, ) }) { - Ok(response) => RuntimeSummary { + Ok(response) => InternalRuntimeSummary { runtime_id: self.runtime_id.clone(), label: response .display_name .unwrap_or_else(|| self.display_name.clone()), kind: "remote_worker_runtime".to_string(), status: embedded_runtime_status_label(response.status).to_string(), - source: RuntimeSourceSummary::remote_http(), + source: InternalRuntimeSourceSummary::remote_http(), host_ids: if limit == 0 { Vec::new() } else { @@ -4146,12 +4172,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { arch: response.arch, diagnostics: Vec::new(), }, - Err(diagnostic) => RuntimeSummary { + Err(diagnostic) => InternalRuntimeSummary { runtime_id: self.runtime_id.clone(), label: self.display_name.clone(), kind: "remote_worker_runtime".to_string(), status: self.cached_status.clone(), - source: RuntimeSourceSummary::remote_http(), + source: InternalRuntimeSourceSummary::remote_http(), host_ids: if limit == 0 { Vec::new() } else { @@ -4242,12 +4268,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { ) } - fn list_hosts(&self, limit: usize) -> RuntimeList { + fn list_hosts(&self, limit: usize) -> RuntimeList { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); } RuntimeList::new( - vec![HostSummary { + vec![InternalHostSummary { runtime_id: self.runtime_id.clone(), host_id: self.host_id.clone(), label: self.display_name.clone(), @@ -4263,7 +4289,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { ) } - fn list_workers(&self, limit: usize) -> RuntimeList { + fn list_workers(&self, limit: usize) -> RuntimeList { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); } @@ -4294,7 +4320,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } } - fn list_stopped_workers(&self, limit: usize) -> RuntimeList { + fn list_stopped_workers(&self, limit: usize) -> RuntimeList { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); } @@ -4353,7 +4379,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } } - fn restore_worker(&self, worker_id: &str) -> WorkerRestoreResult { + fn restore_worker(&self, worker_id: &str) -> InternalWorkerRestoreResult { let worker_id_owned = worker_id.to_string(); match self .run_runtime_api( @@ -4375,11 +4401,11 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { Ok((state, worker, reason_code, message)) => { let diagnostics = match (reason_code, message) { (Some(code), Some(message)) => { - vec![diagnostic(code, DiagnosticSeverity::Warning, message)] + vec![diagnostic(code, HostDiagnosticSeverity::Warning, message)] } _ => Vec::new(), }; - WorkerRestoreResult { + InternalWorkerRestoreResult { state: match state { runtime_api::WorkerRestoreState::Accepted => { server_api::WorkerRestoreState::Accepted @@ -4398,7 +4424,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { diagnostics, } } - Err(diagnostic) => WorkerRestoreResult { + Err(diagnostic) => InternalWorkerRestoreResult { // A transport/protocol failure cannot prove that the Runtime // rejected before side effects. Preserve uncertainty. state: server_api::WorkerRestoreState::ReconciliationRequired, @@ -4417,7 +4443,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { Ok(workspace_api) => runtime_api::WorkerWorkspaceApiRequest { workspace_api }, Err(diagnostic) => { return WorkerWorkspaceApiResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, diagnostics: vec![diagnostic], }; @@ -4438,12 +4464,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { runtime_contract_convert::<_, worker_runtime::catalog::WorkerDetail>(value.worker) }) { Ok(response) => WorkerWorkspaceApiResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: Some(self.map_worker_detail(response)), diagnostics: Vec::new(), }, Err(diagnostic) => WorkerWorkspaceApiResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, diagnostics: vec![diagnostic], }, @@ -4459,7 +4485,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { Ok(attachments) => attachments, Err(diagnostic) => { return WorkerWorkspaceApiResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, diagnostics: vec![diagnostic], }; @@ -4483,12 +4509,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { runtime_contract_convert::<_, worker_runtime::catalog::WorkerDetail>(value.worker) }) { Ok(response) => WorkerWorkspaceApiResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: Some(self.map_worker_detail(response)), diagnostics: Vec::new(), }, Err(diagnostic) => WorkerWorkspaceApiResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, diagnostics: vec![diagnostic], }, @@ -4505,12 +4531,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { Some(REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT), ) { Ok(response) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, working_directory: Some(response.working_directory), diagnostics: Vec::new(), }, Err(diagnostic) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![diagnostic], }, @@ -4565,12 +4591,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { "/v1/working-directories/{working_directory_id}" )) { Ok(response) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, working_directory: Some(response.working_directory), diagnostics: Vec::new(), }, Err(diagnostic) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![diagnostic], }, @@ -4603,12 +4629,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { "/v1/working-directories/{working_directory_id}" )) { Ok(response) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, working_directory: Some(response.working_directory), diagnostics: Vec::new(), }, Err(diagnostic) => RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![diagnostic], }, @@ -4625,12 +4651,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { WorkerSpawnAcceptanceRequirement::SocketReady ) { return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics: vec![diagnostic( "remote_runtime_no_socket_ready_acceptance", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Remote Runtime v0 exposes backend-proxied REST/WS control, not direct socket readiness".to_string(), )], }; @@ -4640,12 +4666,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { Ok(archive) => archive, Err(error) => { return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics: vec![diagnostic( "remote_workspace_config_invalid", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error, )], }; @@ -4658,7 +4684,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { Ok(workspace_api) => workspace_api, Err(diagnostic) => { return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics: vec![diagnostic], @@ -4677,7 +4703,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { Ok(create) => create, Err(diagnostic) => { return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics: vec![diagnostic], @@ -4694,7 +4720,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { runtime_contract_convert::<_, worker_runtime::catalog::WorkerDetail>(value.worker) }) { Ok(response) => WorkerSpawnResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: Some(self.map_worker_detail(response)), acceptance_evidence: vec![WorkerSpawnAcceptanceEvidence { kind: "remote_runtime_worker_created".to_string(), @@ -4703,7 +4729,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { diagnostics: Vec::new(), }, Err(diagnostic) => WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics: vec![diagnostic], @@ -4730,12 +4756,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { &request, ) { Ok(response) => ConfigBundleSyncResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, availability: Some(response.availability), diagnostics: Vec::new(), }, Err(diagnostic) => ConfigBundleSyncResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, availability: None, diagnostics: vec![diagnostic], }, @@ -4746,12 +4772,12 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { let path = Self::bundle_availability_path(&reference); match self.get_json::(&path) { Ok(response) => ConfigBundleCheckResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, availability: Some(response.availability), diagnostics: Vec::new(), }, Err(diagnostic) => ConfigBundleCheckResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, availability: None, diagnostics: vec![diagnostic], }, @@ -4822,7 +4848,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { ) }) { Ok(response) => WorkerDeleteResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new( self.runtime_id.clone(), response.worker_id.to_string(), @@ -4831,7 +4857,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { diagnostics: Vec::new(), }, Err(diagnostic) => WorkerDeleteResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), deleted: false, diagnostics: vec![diagnostic], @@ -4923,7 +4949,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { Ok(input) => input, Err(diagnostic) => { return WorkerInputResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), diagnostics: vec![diagnostic], }; @@ -4936,7 +4962,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { move |client| async move { client.send_worker_input(worker_id_owned, input).await }, ) { Ok(_) => WorkerInputResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), diagnostics: Vec::new(), }, @@ -5069,7 +5095,7 @@ fn execution_metadata_diagnostic(execution_metadata_available: bool) -> Option Vec { let mut diagnostics = vec![diagnostic( "embedded_runtime_projection", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "Worker identity is projected only as runtime_id plus worker_id; embedded runtime internals remain backend-private".to_string(), )]; diagnostics.extend(execution_metadata_diagnostic(execution_metadata_available)); @@ -5093,7 +5119,7 @@ fn remote_worker_projection_diagnostics( ) -> Vec { let mut diagnostics = vec![diagnostic( "remote_runtime_projection", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "Remote Worker identity is projected only as runtime_id plus worker_id; endpoint and credentials remain backend-private".to_string(), )]; diagnostics.extend(execution_metadata_diagnostic(execution_metadata_available)); @@ -5328,7 +5354,7 @@ fn embedded_input_rejected( diagnostic: RuntimeDiagnostic, ) -> WorkerInputResult { WorkerInputResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()), diagnostics: vec![diagnostic], } @@ -5340,7 +5366,7 @@ fn remote_input_rejected( diagnostic: RuntimeDiagnostic, ) -> WorkerInputResult { WorkerInputResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()), diagnostics: vec![diagnostic], } @@ -5352,7 +5378,7 @@ fn embedded_lifecycle_rejected( diagnostic: RuntimeDiagnostic, ) -> WorkerLifecycleResult { WorkerLifecycleResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()), diagnostics: vec![diagnostic], } @@ -5364,7 +5390,7 @@ fn remote_lifecycle_rejected( diagnostic: RuntimeDiagnostic, ) -> WorkerLifecycleResult { WorkerLifecycleResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: RuntimeWorkerRef::new(runtime_id.to_string(), worker_id.to_string()), diagnostics: vec![diagnostic], } @@ -5373,7 +5399,7 @@ fn remote_lifecycle_rejected( fn embedded_workdir_unsupported_diagnostic() -> RuntimeDiagnostic { diagnostic( "embedded_worker_workdir_unsupported", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Embedded Runtime is no-workdir only; choose a non-embedded Runtime for workspace-file Workers".to_string(), ) } @@ -5435,18 +5461,18 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti match error { EmbeddedRuntimeError::RuntimeStopped => diagnostic( "embedded_runtime_stopped", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Embedded Runtime is stopped".to_string(), ), EmbeddedRuntimeError::WorkerNotFound { .. } => diagnostic( "embedded_worker_not_found", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Embedded Runtime worker was not found".to_string(), ), EmbeddedRuntimeError::WorkerExecutionUnavailable { .. } | EmbeddedRuntimeError::ExecutionBackendUnavailable { .. } => diagnostic( "embedded_worker_execution_unavailable", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Embedded Worker has no execution backend attached".to_string(), ), EmbeddedRuntimeError::WorkerExecutionRejected { @@ -5456,22 +5482,22 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti .. } => diagnostic( "embedded_worker_execution_rejected", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, sanitize_embedded_execution_message(message, operation, outcome), ), EmbeddedRuntimeError::LimitTooLarge { requested, max } => diagnostic( "embedded_runtime_limit_too_large", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, format!("Requested limit {requested} exceeds embedded Runtime maximum {max}"), ), EmbeddedRuntimeError::InvalidInitialInputKind { .. } => diagnostic( "embedded_worker_initial_input_kind_invalid", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, error.to_string(), ), EmbeddedRuntimeError::WorkingDirectory(workdir_diagnostic) => diagnostic( workdir_diagnostic.code.clone(), - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, workdir_diagnostic.message.clone(), ), EmbeddedRuntimeError::InvalidRequest(_) @@ -5481,17 +5507,17 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti | EmbeddedRuntimeError::InvalidProfileSelector { .. } | EmbeddedRuntimeError::UnsupportedConfigDeclaration { .. } => diagnostic( "embedded_runtime_invalid_request", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Embedded Runtime rejected the request".to_string(), ), EmbeddedRuntimeError::RuntimeStoreAlreadyOpen { .. } => diagnostic( "embedded_runtime_store_already_open", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Embedded Runtime store is already owned by another Runtime process".to_string(), ), EmbeddedRuntimeError::WorkerDeletePersistenceFailed { .. } => diagnostic( "worker_delete_persistence_failed", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Worker metadata deletion failed; the persisted Worker identity was retained for retry" .to_string(), ), @@ -5499,12 +5525,12 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti | EmbeddedRuntimeError::StoreMissing { .. } | EmbeddedRuntimeError::StoreCorrupt { .. } => diagnostic( "embedded_runtime_store_error", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Embedded Runtime storage operation failed; internal paths are not exposed".to_string(), ), EmbeddedRuntimeError::StatePoisoned => diagnostic( "embedded_runtime_state_unavailable", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Embedded Runtime state is unavailable".to_string(), ), } @@ -5546,19 +5572,19 @@ fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDi if err.is_timeout() { diagnostic( "remote_runtime_timeout", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, format!("Timed out while contacting remote Runtime '{runtime_id}'"), ) } else if err.is_connect() || err.is_request() { diagnostic( "remote_runtime_network_error", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, format!("Failed to contact remote Runtime '{runtime_id}'"), ) } else { diagnostic( "remote_runtime_client_error", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, format!("Remote Runtime client error for '{runtime_id}'"), ) } @@ -5574,7 +5600,7 @@ where .map_err(|error| { diagnostic( "remote_runtime_contract_conversion_failed", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, error.to_string(), ) }) @@ -5591,14 +5617,14 @@ fn runtime_api_diagnostic( let (code, severity) = match status { StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ( "remote_runtime_auth_failed".to_string(), - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, ), _ => ( remote_code, if status.is_server_error() { - DiagnosticSeverity::Error + HostDiagnosticSeverity::Error } else { - DiagnosticSeverity::Warning + HostDiagnosticSeverity::Warning }, ), }; @@ -5635,7 +5661,7 @@ fn runtime_api_diagnostic( }; diagnostic( code, - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, format!("Remote Runtime client error for '{runtime_id}': {failure}"), ) } @@ -5672,15 +5698,18 @@ fn remote_http_status_diagnostic( .unwrap_or_else(|| "remote Runtime did not return a typed error body".to_string()); let (code, severity) = match status { StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { - ("remote_runtime_auth_failed", DiagnosticSeverity::Error) + ("remote_runtime_auth_failed", HostDiagnosticSeverity::Error) } - _ if error.is_some() => (remote_code, DiagnosticSeverity::Warning), - StatusCode::NOT_FOUND => ("remote_runtime_not_found", DiagnosticSeverity::Warning), - StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED => { - ("remote_runtime_unsupported", DiagnosticSeverity::Warning) + _ if error.is_some() => (remote_code, HostDiagnosticSeverity::Warning), + StatusCode::NOT_FOUND => ("remote_runtime_not_found", HostDiagnosticSeverity::Warning), + StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED => ( + "remote_runtime_unsupported", + HostDiagnosticSeverity::Warning, + ), + _ if status.is_server_error() => { + ("remote_runtime_http_error", HostDiagnosticSeverity::Error) } - _ if status.is_server_error() => ("remote_runtime_http_error", DiagnosticSeverity::Error), - _ => (remote_code, DiagnosticSeverity::Warning), + _ => (remote_code, HostDiagnosticSeverity::Warning), }; diagnostic( code, @@ -5691,7 +5720,7 @@ fn remote_http_status_diagnostic( fn diagnostic( code: impl Into, - severity: DiagnosticSeverity, + severity: HostDiagnosticSeverity, message: impl Into, ) -> RuntimeDiagnostic { RuntimeDiagnostic { @@ -5708,7 +5737,7 @@ fn operation_failed_or_unknown_worker( ) -> RuntimeRegistryError { diagnostics .into_iter() - .find(|diagnostic| matches!(diagnostic.severity, DiagnosticSeverity::Error)) + .find(|diagnostic| matches!(diagnostic.severity, HostDiagnosticSeverity::Error)) .map(|diagnostic| RuntimeRegistryError::RuntimeOperationFailed { runtime_id: runtime_id.to_string(), code: diagnostic.code, @@ -5819,9 +5848,9 @@ fn worker_spawn_intent_label(intent: &WorkerSpawnIntent) -> &'static str { } } -pub fn placeholder_worker(host_id: impl Into) -> WorkerSummary { +pub fn placeholder_worker(host_id: impl Into) -> InternalWorkerSummary { let host_id = host_id.into(); - WorkerSummary { + InternalWorkerSummary { worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"), host_id, display_name: "Worker runtime actions are not implemented".to_string(), @@ -5829,7 +5858,7 @@ pub fn placeholder_worker(host_id: impl Into) -> WorkerSummary { profile: None, singleton_key: None, tags: Vec::new(), - workspace: WorkerWorkspaceSummary { + workspace: InternalWorkerWorkspaceSummary { visibility: "none".to_string(), identity: "unsupported".to_string(), workspace_id: None, @@ -5839,18 +5868,18 @@ pub fn placeholder_worker(host_id: impl Into) -> WorkerSummary { last_seen_at: None, pinned: false, retention_state: "transient".to_string(), - implementation: WorkerImplementationSummary { + implementation: InternalWorkerImplementationSummary { kind: "placeholder".to_string(), display_hint: "unsupported".to_string(), }, - capabilities: WorkerCapabilitySummary { + capabilities: InternalWorkerCapabilitySummary { can_stop: false, can_spawn_followup: false, }, workdir_attachments: Vec::new(), diagnostics: vec![diagnostic( "runtime_capability_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "worker control is outside this overview-only registry surface".to_string(), )], } @@ -5858,12 +5887,12 @@ pub fn placeholder_worker(host_id: impl Into) -> WorkerSummary { pub fn placeholder_spawn_response(host_id: impl Into) -> WorkerSpawnResult { WorkerSpawnResult { - state: WorkerOperationState::Unsupported, + state: InternalWorkerOperationState::Unsupported, worker: Some(placeholder_worker(host_id)), acceptance_evidence: Vec::new(), diagnostics: vec![diagnostic( "worker_spawn_unsupported", - DiagnosticSeverity::Info, + HostDiagnosticSeverity::Info, "Workspace worker runtime control is not implemented yet".to_string(), )], } @@ -5920,7 +5949,7 @@ mod tests { }); assert_eq!(diagnostic.code, "worker_delete_persistence_failed"); - assert_eq!(diagnostic.severity, DiagnosticSeverity::Error); + assert_eq!(diagnostic.severity, HostDiagnosticSeverity::Error); assert!(diagnostic.message.len() <= 256); assert!(!diagnostic.message.contains("/private/runtime")); } @@ -6038,7 +6067,7 @@ mod tests { assert!(value.get("worker").is_none()); let lifecycle = WorkerLifecycleResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new("arcadia", "30"), diagnostics: Vec::new(), }; @@ -6454,7 +6483,7 @@ mod tests { struct FixtureRuntime { runtime_id: String, host_id: String, - workers: Vec, + workers: Vec, observed_prompt_revisions: Arc>>, } @@ -6463,7 +6492,7 @@ mod tests { Self { runtime_id: runtime_id.to_string(), host_id: host_id.to_string(), - workers: vec![WorkerSummary { + workers: vec![InternalWorkerSummary { worker: RuntimeWorkerRef::new(runtime_id, worker_id), host_id: host_id.to_string(), display_name: label.to_string(), @@ -6471,7 +6500,7 @@ mod tests { profile: None, singleton_key: None, tags: Vec::new(), - workspace: WorkerWorkspaceSummary { + workspace: InternalWorkerWorkspaceSummary { visibility: "opaque".to_string(), identity: host_id.to_string(), workspace_id: None, @@ -6481,11 +6510,11 @@ mod tests { last_seen_at: None, pinned: false, retention_state: "transient".to_string(), - implementation: WorkerImplementationSummary { + implementation: InternalWorkerImplementationSummary { kind: "fixture".to_string(), display_hint: "test fixture".to_string(), }, - capabilities: WorkerCapabilitySummary { + capabilities: InternalWorkerCapabilitySummary { can_stop: false, can_spawn_followup: false, }, @@ -6513,13 +6542,13 @@ mod tests { Ok(()) } - fn runtime_summary(&self, _limit: usize) -> RuntimeSummary { - RuntimeSummary { + fn runtime_summary(&self, _limit: usize) -> InternalRuntimeSummary { + InternalRuntimeSummary { runtime_id: self.runtime_id.clone(), label: self.runtime_id.clone(), kind: "fixture".to_string(), status: "available".to_string(), - source: RuntimeSourceSummary::embedded_worker_runtime_reserved(), + source: InternalRuntimeSourceSummary::embedded_worker_runtime_reserved(), host_ids: vec![self.host_id.clone()], worker_creation_available: false, os: "test".to_string(), @@ -6528,9 +6557,9 @@ mod tests { } } - fn list_hosts(&self, _limit: usize) -> RuntimeList { + fn list_hosts(&self, _limit: usize) -> RuntimeList { RuntimeList::new( - vec![HostSummary { + vec![InternalHostSummary { runtime_id: self.runtime_id.clone(), host_id: self.host_id.clone(), label: "fixture host".to_string(), @@ -6546,7 +6575,7 @@ mod tests { ) } - fn list_workers(&self, limit: usize) -> RuntimeList { + fn list_workers(&self, limit: usize) -> RuntimeList { RuntimeList::new( self.workers.iter().take(limit).cloned().collect(), Vec::new(), @@ -6828,6 +6857,7 @@ mod tests { alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(), working_directory_id: "workdir-1".to_string(), relative_cwd: Some("crates/yoi".to_string()), + capabilities: workdir::WorkdirSessionCapabilities::ALL, }]; let profile_source = profile_source_archive_source(&request, &request.profile).expect("profile source"); @@ -6876,7 +6906,7 @@ mod tests { let result = registry .spawn_worker("embedded-worker-runtime", binding.clone(), request) .expect("spawn request"); - assert_eq!(result.state, WorkerOperationState::Accepted); + assert_eq!(result.state, InternalWorkerOperationState::Accepted); assert_eq!( result.worker.as_ref().unwrap().worker.worker_id, binding.worker_id.to_string() @@ -6884,7 +6914,7 @@ mod tests { let check = registry .check_config_bundle("embedded-worker-runtime", bundle_ref) .expect("bundle check"); - assert_eq!(check.state, WorkerOperationState::Accepted); + assert_eq!(check.state, InternalWorkerOperationState::Accepted); assert!(check.availability.is_some()); } @@ -6900,7 +6930,7 @@ mod tests { let spawned = runtime.spawn_worker(test_create_binding(), request); - assert_eq!(spawned.state, WorkerOperationState::Rejected); + assert_eq!(spawned.state, InternalWorkerOperationState::Rejected); assert!( spawned .diagnostics @@ -6917,7 +6947,7 @@ mod tests { ) .expect("test backend should connect"); let spawned = runtime.spawn_worker(test_create_binding(), embedded_spawn_request()); - assert_eq!(spawned.state, WorkerOperationState::Rejected); + assert_eq!(spawned.state, InternalWorkerOperationState::Rejected); assert!(spawned.acceptance_evidence.is_empty()); assert!(spawned.diagnostics.iter().any(|diagnostic| { diagnostic.code == "embedded_worker_execution_rejected" @@ -6984,7 +7014,7 @@ mod tests { ) .expect("test backend should connect"); let spawned = runtime.spawn_worker(test_create_binding(), embedded_spawn_request()); - assert_eq!(spawned.state, WorkerOperationState::Accepted); + assert_eq!(spawned.state, InternalWorkerOperationState::Accepted); let worker = spawned.worker.expect("created embedded worker"); assert!(worker.capabilities.can_stop); @@ -6996,7 +7026,7 @@ mod tests { segments: None, }, ); - assert_eq!(input.state, WorkerOperationState::Accepted); + assert_eq!(input.state, InternalWorkerOperationState::Accepted); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); loop { @@ -7033,9 +7063,12 @@ mod tests { .expect("embedded runtime summary"); assert_eq!( embedded_summary.source.kind, - RuntimeSourceKind::EmbeddedWorkerRuntime + InternalRuntimeSourceKind::EmbeddedWorkerRuntime + ); + assert_eq!( + embedded_summary.source.status, + InternalRuntimeSourceStatus::Active ); - assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active); assert!(embedded_summary.worker_creation_available); let spawned = registry @@ -7066,7 +7099,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(spawned.state, WorkerOperationState::Accepted); + assert_eq!(spawned.state, InternalWorkerOperationState::Accepted); assert!( spawned .acceptance_evidence @@ -7089,7 +7122,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(input.state, WorkerOperationState::Accepted); + assert_eq!(input.state, InternalWorkerOperationState::Accepted); assert_eq!(input.worker.runtime_id, EMBEDDED_RUNTIME_ID); assert_eq!(input.worker.worker_id, worker.worker.worker_id); @@ -7128,7 +7161,7 @@ mod tests { let sync = registry .sync_config_bundle(EMBEDDED_RUNTIME_ID, bundle.clone()) .unwrap(); - assert_eq!(sync.state, WorkerOperationState::Accepted); + assert_eq!(sync.state, InternalWorkerOperationState::Accepted); let reference = sync.availability.expect("bundle availability").reference; assert_eq!(reference.id, bundle.metadata.id); assert_eq!(reference.digest, bundle.metadata.digest); @@ -7136,7 +7169,7 @@ mod tests { let check = registry .check_config_bundle(EMBEDDED_RUNTIME_ID, reference.clone()) .unwrap(); - assert_eq!(check.state, WorkerOperationState::Accepted); + assert_eq!(check.state, InternalWorkerOperationState::Accepted); let spawned = registry .spawn_worker( @@ -7166,7 +7199,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(spawned.state, WorkerOperationState::Accepted); + assert_eq!(spawned.state, InternalWorkerOperationState::Accepted); assert_eq!( spawned.worker.unwrap().profile.as_deref(), Some("builtin:coder") @@ -7205,7 +7238,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(result.state, WorkerOperationState::Rejected); + assert_eq!(result.state, InternalWorkerOperationState::Rejected); assert!(result.worker.is_none()); assert!( result @@ -7361,7 +7394,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(input.state, WorkerOperationState::Accepted); + assert_eq!(input.state, InternalWorkerOperationState::Accepted); server.join().expect("mock remote server finished"); let browser_payload = serde_json::to_string(&(workers, input)).unwrap(); @@ -7511,7 +7544,7 @@ mod tests { let sync = registry .sync_config_bundle("remote:primary", test_config_bundle()) .unwrap(); - assert_eq!(sync.state, WorkerOperationState::Rejected); + assert_eq!(sync.state, InternalWorkerOperationState::Rejected); let sync_payload = serde_json::to_string(&sync).unwrap(); assert!(!sync_payload.contains(leaked_store_path), "{sync_payload}"); @@ -7524,7 +7557,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(check.state, WorkerOperationState::Rejected); + assert_eq!(check.state, InternalWorkerOperationState::Rejected); let check_payload = serde_json::to_string(&check).unwrap(); assert!( !check_payload.contains(leaked_session_path), diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index efab8ecbb..cda247556 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -16,8 +16,6 @@ pub mod observation; pub mod profile_settings; pub mod prompt_settings; pub mod records; -#[cfg(feature = "typescript")] -pub use records::ticket_api_typescript; pub mod repositories; pub mod repository_access; pub mod repository_source; @@ -38,7 +36,7 @@ pub mod workspace_signing_identity; mod workspace_subscription; pub use authority::{ - MemoryAuthority, MemoryDocument, MemoryStagingEntry, MemoryStagingResolution, + InternalMemoryStagingEntry, MemoryAuthority, MemoryDocument, MemoryStagingResolution, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, WorkspaceAuthority, }; diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 8071de4f7..d062b4301 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -1,19 +1,29 @@ -use serde::{Deserialize, Serialize}; pub use server_api::{ - ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary, - ObjectiveSummary, QueryPage, + InvalidProjectRecord, MergeRequestListItem, MergeRequestListResponse, + MergeRequestRefDiagnostic, ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, + ObjectiveLinkedTicketSummary, ObjectiveQueryItem, ObjectiveQueryRequest, + ObjectiveQueryResponse, ObjectiveResourceSummary, ObjectiveShowRequest, ObjectiveSummary, + QueryPage, TicketActionEligibility, + TicketAssignmentPrincipal as TicketAssignmentPrincipalSummary, TicketAssignmentSummary, + TicketDetail, TicketDetailDerivedRelation as DerivedTicketRelation, + TicketDetailRelation as TicketRelation, TicketDetailRelationBlocker as TicketRelationBlocker, + TicketDetailRelationNotice as TicketRelationNotice, + TicketDetailRelationView as TicketRelationView, TicketEventDetail, TicketEvidenceEvent, + TicketEvidenceSummary, TicketListItemSummary as TicketSummary, TicketListResponse, + TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse, + TicketRoleAssignmentSummary, TicketShowRequest, }; const SUMMARY_BODY_LIMIT: usize = 240; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectRecordList { pub items: Vec, pub invalid_records: Vec, pub record_authority: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketSummaryPage { pub items: Vec, pub page: QueryPage, @@ -21,515 +31,64 @@ pub struct TicketSummaryPage { pub record_authority: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct InvalidProjectRecord { - pub label: String, - pub reason: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketSummary { - pub id: String, - pub resource_key: String, - pub title: String, - pub state: String, - pub priority: String, - pub updated_at: Option, - pub queued_by: Option, - pub queued_at: Option, - pub workspace_action_priority: String, - pub record_source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -pub struct TicketListPageRequest { - #[serde(default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TicketListProjectionRequest { pub states: Vec, - #[serde(default)] - pub limit: Option, - #[serde(default)] - pub cursor: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketListResponse { - pub workspace_id: String, - pub limit: usize, - pub items: Vec, - pub page: QueryPage, - pub invalid_records: Vec, - pub record_authority: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketDetail { - pub id: String, - pub resource_key: String, - pub title: String, - pub state: String, - pub readiness: Option, - pub priority: String, - pub created_at: Option, - pub updated_at: Option, - pub item_revision: String, - pub queued_by: Option, - pub queued_at: Option, - pub repository_key: Option, - pub ref_selector: Option, - pub risk_flags: Vec, - pub body: String, - pub body_truncated: bool, - pub event_count: usize, - pub events: Vec, - pub event_page: QueryPage, - pub artifact_count: usize, - pub artifacts: Vec, - pub relations: TicketRelationView, - pub linked_objectives: Vec, - pub implementation_reports: Vec, - pub assignments: Vec, - pub current_coder: Option, - pub assignment_diagnostics: Vec, - pub action_eligibility: TicketActionEligibility, - pub merge_request: Option, - pub evidence: TicketEvidenceSummary, - pub resolution: Option, - pub record_source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketEventDetail { - pub sequence: usize, - pub event_ref: String, - pub kind: String, - pub author: Option, - pub at: Option, - pub status: Option, - pub from: Option, - pub to: Option, - pub reason: Option, - pub state_field: Option, - pub heading: Option, - pub body: Option, - pub attributes: std::collections::BTreeMap, - pub references: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketRelation { - pub ticket_id: String, - pub kind: String, - pub target: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_resource_key: Option, - pub note: Option, - pub author: String, - pub at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct DerivedTicketRelation { - pub source_ticket: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_resource_key: Option, - pub inverse_kind: String, - pub forward_kind: String, - pub note: Option, - pub author: String, - pub at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketRelationBlocker { - pub blocking_ticket: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub blocking_resource_key: Option, - pub reason_kind: String, - pub relation_kind: String, - pub note: Option, - pub blocking_state: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketRelationNotice { - pub related_ticket: String, - pub kind: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketRelationView { - pub outgoing: Vec, - pub incoming: Vec, - pub blockers: Vec, - pub notices: Vec, -} - -impl From for TicketRelationView { - fn from(value: ticket::TicketRelationView) -> Self { - Self { - outgoing: value - .outgoing - .into_iter() - .map(|relation| TicketRelation { - ticket_id: relation.ticket_id, - kind: relation.kind.as_str().to_string(), - target: relation.target, - target_resource_key: None, - note: relation.note, - author: relation.author, - at: relation.at, - }) - .collect(), - incoming: value - .incoming - .into_iter() - .map(|relation| DerivedTicketRelation { - source_ticket: relation.source_ticket, - source_resource_key: None, - inverse_kind: relation.inverse_kind, - forward_kind: relation.forward_kind.as_str().to_string(), - note: relation.note, - author: relation.author, - at: relation.at, - }) - .collect(), - blockers: value - .blockers - .into_iter() - .map(|blocker| TicketRelationBlocker { - blocking_ticket: blocker.blocking_ticket, - blocking_resource_key: None, - reason_kind: blocker.reason_kind, - relation_kind: blocker.relation_kind.as_str().to_string(), - note: blocker.note, - blocking_state: blocker.blocking_state.as_str().to_string(), - }) - .collect(), - notices: value - .notices - .into_iter() - .map(|notice| TicketRelationNotice { - related_ticket: notice.related_ticket, - kind: notice.kind.as_str().to_string(), - message: notice.message, - }) - .collect(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct ObjectiveLinkSummary { - pub id: String, - pub resource_key: String, - pub title: String, - pub state: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketEvidenceEvent { - pub event_ref: String, - pub sequence: usize, - pub kind: String, - pub at: Option, - pub author: Option, - pub excerpt: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketAssignmentSummary { - pub assignment_id: String, - pub runtime_id: String, - pub worker_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub worker_resource_key: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketRoleAssignmentSummary { - pub assignment_id: String, - pub role: String, - pub principal: TicketAssignmentPrincipalSummary, - pub assigned_by: String, - pub assigned_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))] -pub enum TicketAssignmentPrincipalSummary { - User { - account_id: String, - }, - Worker { - runtime_id: String, - worker_id: String, - }, - WorkspaceAgent { - agent_key: String, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketActionEligibility { - pub can_assign_orchestrator: bool, - pub can_unassign_orchestrator: bool, - pub can_queue: bool, - pub can_start_manual_coder: bool, - pub queue_tickets: Vec, - pub blockers: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketMergeRequestSummary { - pub merge_request_id: String, - pub repository_key: String, - pub state: String, - pub review_status: String, - pub selector_from: Option, - pub selector_to: String, - pub updated_at: String, - pub current_subject_ref: Option, - pub review_subject_ref: Option, - pub review_requested_at: Option, - pub review_submitted_at: Option, - pub review_excerpt: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct MergeRequestRefDiagnostic { - pub code: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct MergeRequestListItem { - pub summary: TicketMergeRequestSummary, - pub ticket_ids: Vec, - pub thread_event_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ref_diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct MergeRequestListResponse { - pub items: Vec, - pub next_cursor: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketEvidenceSummary { - pub has_merge_request: bool, - pub has_current_subject_ref: bool, - pub has_review_request: bool, - pub has_commit: bool, - pub review_status: Option, - pub approved_current_subject: bool, - pub review_after_rescope: bool, - pub unresolved_request_changes: bool, - pub complete_for_integration: bool, - pub missing: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketQueryRequest { - pub query: Option, - #[serde(default)] - pub states: Vec, - #[serde(default)] - pub event_kinds: Vec, - #[serde(default)] - pub evidence: Vec, - pub review_status: Option, - #[serde(default)] - pub attention: Vec, - pub related_ticket_id: Option, - pub relation_kind: Option, - pub linked_objective_id: Option, - pub updated_after: Option, - pub updated_before: Option, - pub sort: Option, pub limit: Option, pub cursor: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketQueryItem { - pub id: String, - pub resource_key: String, - pub title: String, - pub state: String, - pub readiness: Option, - pub priority: String, - pub created_at: Option, - pub updated_at: Option, - pub item_revision: String, - pub workspace_action_priority: String, - pub matched_fields: Vec, - pub snippet: Option, - pub matching_event: Option, - pub linked_objective_ids: Vec, - #[ts(skip)] - pub linked_objective_keys: Vec, - pub relation_count: usize, - pub blocker_count: usize, - pub unresolved_blocker_count: usize, - pub unresolved_review_count: usize, - pub evidence: TicketEvidenceSummary, - pub merge_request: Option, - #[ts(skip)] - pub current_coder: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketQueryResponse { - pub items: Vec, - pub page: QueryPage, - pub record_authority: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct TicketShowRequest { - pub event_limit: Option, - pub event_cursor: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -pub struct ObjectiveQueryRequest { - pub query: Option, - #[serde(default)] - pub states: Vec, - pub linked_ticket_id: Option, - pub updated_after: Option, - pub updated_before: Option, - pub sort: Option, - pub limit: Option, - pub cursor: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ObjectiveQueryItem { - pub id: String, - pub resource_key: String, - pub title: String, - pub state: String, - pub created_at: Option, - pub updated_at: Option, - pub matched_fields: Vec, - pub snippet: Option, - pub linked_ticket_count: usize, - pub linked_tickets: Vec, - pub linked_ticket_keys: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ObjectiveQueryResponse { - pub items: Vec, - pub page: QueryPage, - pub record_authority: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -pub struct ObjectiveShowRequest { - pub event_limit: Option, - pub event_cursor: Option, -} - -#[cfg(feature = "typescript")] -pub fn ticket_api_typescript() -> String { - use ts_rs::TS; - - let config = ts_rs::Config::default(); - let declarations = [ - InvalidProjectRecord::decl(&config), - TicketSummary::decl(&config), - TicketListResponse::decl(&config), - QueryPage::decl(&config), - TicketEventDetail::decl(&config), - ObjectiveLinkSummary::decl(&config), - TicketEvidenceEvent::decl(&config), - TicketAssignmentSummary::decl(&config), - TicketRoleAssignmentSummary::decl(&config), - TicketAssignmentPrincipalSummary::decl(&config), - TicketActionEligibility::decl(&config), - TicketMergeRequestSummary::decl(&config), - MergeRequestRefDiagnostic::decl(&config), - MergeRequestListItem::decl(&config), - MergeRequestListResponse::decl(&config), - TicketEvidenceSummary::decl(&config), - TicketQueryRequest::decl(&config), - TicketQueryItem::decl(&config), - TicketQueryResponse::decl(&config), - TicketShowRequest::decl(&config), - TicketRelation::decl(&config), - DerivedTicketRelation::decl(&config), - TicketRelationBlocker::decl(&config), - TicketRelationNotice::decl(&config), - TicketRelationView::decl(&config), - TicketDetail::decl(&config), - ]; - format!( - "// Generated from yoi-workspace-server. Do not edit by hand.\n// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts\n\n{}\n", - declarations +pub(crate) fn ticket_relation_view_from_domain( + value: ticket::TicketRelationView, +) -> TicketRelationView { + TicketRelationView { + outgoing: value + .outgoing .into_iter() - .map(|declaration| format!("export {declaration}")) - .collect::>() - .join("\n\n") - ) -} - -#[cfg(all(test, feature = "typescript"))] -mod typescript_tests { - #[test] - fn generated_ticket_api_contract_is_current() { - let expected = super::ticket_api_typescript(); - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../web/workspace/src/lib/generated/ticket-api.ts"); - let actual = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); - assert_eq!( - normalize(&actual), - normalize(&expected), - "regenerate Ticket API TypeScript types with `cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts` and format the generated file", - ); - } - - fn normalize(value: &str) -> String { - value - .chars() - .filter_map(|character| match character { - character if character.is_whitespace() => None, - ',' => Some(';'), - character => Some(character), + .map(|relation| TicketRelation { + ticket_id: relation.ticket_id, + kind: relation.kind.as_str().to_string(), + target: relation.target, + target_resource_key: None, + note: relation.note, + author: relation.author, + at: relation.at, }) - .collect::() - .replace(";}", "}") + .collect(), + incoming: value + .incoming + .into_iter() + .map(|relation| DerivedTicketRelation { + source_ticket: relation.source_ticket, + source_resource_key: None, + inverse_kind: relation.inverse_kind, + forward_kind: relation.forward_kind.as_str().to_string(), + note: relation.note, + author: relation.author, + at: relation.at, + }) + .collect(), + blockers: value + .blockers + .into_iter() + .map(|blocker| TicketRelationBlocker { + blocking_ticket: blocker.blocking_ticket, + blocking_resource_key: None, + reason_kind: blocker.reason_kind, + relation_kind: blocker.relation_kind.as_str().to_string(), + note: blocker.note, + blocking_state: blocker.blocking_state.as_str().to_string(), + }) + .collect(), + notices: value + .notices + .into_iter() + .map(|notice| TicketRelationNotice { + related_ticket: notice.related_ticket, + kind: notice.kind.as_str().to_string(), + message: notice.message, + }) + .collect(), } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 95af16ff7..74f890c14 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5,16 +5,17 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; use async_trait::async_trait; -use axum::body::Bytes; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State}; -use axum::http::header::{ - CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, ORIGIN, SET_COOKIE, -}; -use axum::http::{HeaderMap, Method, StatusCode, Uri}; +#[cfg(test)] +use axum::http::header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH}; +use axum::http::header::{CONTENT_TYPE, LOCATION, ORIGIN}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; -use axum::routing::{delete, get, patch, post, put}; +use axum::routing::get; +#[cfg(test)] +use axum::routing::post; use axum::{Json, Router}; use chrono::{Duration, SecondsFormat, Utc}; use config_source::ConfigTreeSnapshot; @@ -30,14 +31,16 @@ use serde::{Deserialize, Serialize}; use server_api::{ ActorAuthMethod, AuthBootstrapUserRequest, AuthPublicConfig, AuthUserResponse, AuthenticatedUser, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, - ConfirmRepositorySshHostTrustRequest, CreateRemoteRuntimeRequest, - CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest, - CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest, - CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest, - DeleteRepositorySshHostTrustRequest, DeviceAccessTokenType, DeviceLoginApprovalStatus, - DeviceLoginApproveRequest, DeviceLoginApproveResponse, DeviceLoginPollRequest, - DeviceLoginPollResponse, DeviceLoginPollStatus, DeviceLoginStartRequest, - DeviceLoginStartResponse, ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse, + CleanupTargetKind, CleanupWorkdirCandidate, CleanupWorkdirCleanliness, + CleanupWorkdirFileStatus, CleanupWorkerCandidate, ConfirmRepositorySshHostTrustRequest, + CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest, + CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, + CreateWorkspaceWorkerRequest, CreateWorkspaceWorkerTicketAssignmentRequest, + DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, + DeviceAccessTokenType, DeviceLoginApprovalStatus, DeviceLoginApproveRequest, + DeviceLoginApproveResponse, DeviceLoginPollRequest, DeviceLoginPollResponse, + DeviceLoginPollStatus, DeviceLoginStartRequest, DeviceLoginStartResponse, + ExecuteRuntimeCleanupRequest, ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse, GenerateRepositorySshCredentialRequest, LogoutResponse, LogoutStatus, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, PasskeyLoginCompleteRequest, @@ -47,16 +50,16 @@ use server_api::{ RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse, RepositorySshConnectionProbeRequest, RepositorySshConnectionProbeResponse, RepositorySshConnectionTrustState, RepositorySshCredential, RepositorySshHostKeyCandidate, - RepositorySshHostTrust, RepositorySshPublicKey, RequestActor, RevokeRuntimeTrustKeyRequest, - RotateRepositorySshCredentialRequest, RuntimeConnectionDisplayState, + RepositorySshHostTrust, RepositorySshPublicKey, RequestActor, + RotateRepositorySshCredentialRequest, RuntimeCleanupExecutionResponse, + RuntimeCleanupExecutionResult, RuntimeCleanupPlanResponse, RuntimeConnectionDisplayState, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeRemovalOperationResponse, RuntimeRemovalOperationState as ApiRuntimeRemovalOperationState, RuntimeTrustAuditAction, - RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse, - RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus, - TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, UpdateRemoteRuntimeRequest, - UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, - WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, + RuntimeTrustAuditEntry, RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, + RuntimeTrustKeyStatus, UpdateRemoteRuntimeRequest, UpdateWorkspaceMetadataRequest, + WhoamiResponse, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, + WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkerRetentionResponse, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, @@ -74,10 +77,18 @@ use server_api::{ WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; +use server_api::{ + AttachmentUploadCancelResponse, AttachmentUploadGrantResponse, RestoreTicketAssignmentQuery, + RuntimeWorkersQuery, RuntimeWorkersStatusFilter, WorkerFileDeleteResponse, + WorkerFileUploadQuery, WorkerFileUploadResponse, +}; +#[cfg(test)] +use server_api::{TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH}; use sha2::{Digest, Sha256}; use ticket::{ MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind, - TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetEdit, TicketWorkflowState, + TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetAccess, TicketTargetsEdit, + TicketWorkflowState, }; use ticket::{ SqliteTicketBackend, TicketBackendOperation, TicketBackendOperationResult, @@ -108,12 +119,13 @@ use workdir::workspace::{ 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}; +#[cfg(test)] +use worker_runtime::resource::BackendResourceError; +use worker_runtime::resource::BackendResourceFetchRequest; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::workspace_issuer::{ WORKSPACE_VERIFICATION_ACK_PATH, WORKSPACE_VERIFICATION_CHALLENGE_PATH, @@ -137,17 +149,18 @@ use crate::companion::{ }; use crate::config_source::ConfigCommitRequest; use crate::hosts::{ - ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID, - EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, - RuntimeDiagnostic, RuntimePingFailureKind, RuntimeRegistry, RuntimeRegistryError, - RuntimeRegistryUnregisterResult, TicketWorkerRole, WorkerCapabilitySummary, - WorkerCompletionsRequest, WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding, - WorkerImplementationSummary, WorkerInputKind, WorkerInputRequest, WorkerInputResult, - WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, WorkerRestoreResult, - WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, - WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest, - WorkerWorkspaceSummary, WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address, - is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary, + EMBEDDED_RUNTIME_ID, EmbeddedWorkerRuntime, HostDiagnosticSeverity, InternalHostSummary, + InternalWorkerCapabilitySummary, InternalWorkerImplementationSummary, + InternalWorkerOperationState, InternalWorkerRestoreResult, InternalWorkerSummary, + InternalWorkerWorkspaceSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, + RuntimePingFailureKind, RuntimeRegistry, RuntimeRegistryError, RuntimeRegistryUnregisterResult, + TicketWorkerRole, WorkerCompletionsRequest, WorkerCompletionsResult, WorkerControlOperation, + WorkerCreateBinding, WorkerInputKind, WorkerInputRequest, WorkerInputResult, + WorkerLifecycleRequest, WorkerLifecycleResult, WorkerSpawnAcceptanceRequirement, + WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, + WorkerTicketAssignmentRequest, WorkspaceRuntimeAuthorization, + is_disallowed_remote_runtime_address, is_loopback_runtime_origin, + worker_spawn_create_fingerprint, workspace_worker_summary, }; use crate::memory_backend::execute_memory_backend_operation_with_authority; use crate::memory_staging::{ @@ -1132,6 +1145,50 @@ impl From<&WorkspaceApi> for ServerAuthApi { } } +#[derive(Clone)] +enum ServerApiContractService { + Server(WorkspaceServerApi), + Workspace(WorkspaceApi), +} + +impl ServerApiContractService { + fn auth_api(&self) -> ServerAuthApi { + match self { + Self::Server(api) => ServerAuthApi { + config: api.template.as_ref().clone(), + store: api.store.clone(), + }, + Self::Workspace(api) => ServerAuthApi::from(api), + } + } + + fn server_api( + &self, + ) -> std::result::Result<&WorkspaceServerApi, server_api::RepositoryApiError> { + match self { + Self::Server(api) => Ok(api), + Self::Workspace(_) => Err(server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "Not Found", + "Server catalog operation is unavailable on a Workspace-local router", + Vec::new(), + )), + } + } + + fn workspace_api(&self) -> std::result::Result<&WorkspaceApi, server_api::RepositoryApiError> { + match self { + Self::Workspace(api) => Ok(api), + Self::Server(_) => Err(server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "Not Found", + "Workspace-local operation is unavailable on the Server catalog router", + Vec::new(), + )), + } + } +} + #[derive(Clone)] struct WorkerRemovalService { workspace_id: String, @@ -1221,7 +1278,7 @@ impl WorkerRemovalService { let stop = runtime.stop_worker(worker_ref, lifecycle_request); let stop_accepted = stop .as_ref() - .is_ok_and(|result| result.state == WorkerOperationState::Accepted); + .is_ok_and(|result| result.state == InternalWorkerOperationState::Accepted); let termination_detail = (!stop_accepted).then(|| { let cancellation = lifecycle_failure_detail("cancel", &cancellation); let stop = lifecycle_failure_detail("stop", &stop); @@ -1229,7 +1286,11 @@ impl WorkerRemovalService { }); let runtime_deleted = match runtime.delete_worker(worker_ref) { - Ok(result) if result.state == WorkerOperationState::Accepted && result.deleted => true, + Ok(result) + if result.state == InternalWorkerOperationState::Accepted && result.deleted => + { + true + } Ok(result) => { let mut message = format!( "Runtime did not delete Worker {}:{}: state={:?}, deleted={}", @@ -1990,173 +2051,6 @@ fn forbidden_server_response(message: &str) -> Response { .into_response() } -#[derive(Debug, Deserialize)] -struct WorkspaceListQuery { - limit: Option, -} - -async fn server_health() -> Json { - Json(serde_json::json!({ "status": "ok" })) -} - -async fn list_server_workspaces( - State(api): State, - headers: HeaderMap, - Query(query): Query, -) -> Response { - let owner_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => match api.catalog.is_empty() { - Ok(true) => { - return Json(WorkspaceCatalogListResponse(Vec::new())).into_response(); - } - Ok(false) => return StatusCode::UNAUTHORIZED.into_response(), - Err(error) => return server_error_response(error), - }, - Err(error) => return server_error_response(error), - }; - match api - .catalog - .list(&owner_account_id, query.limit.unwrap_or(100)) - { - Ok(workspaces) => Json(WorkspaceCatalogListResponse( - workspaces.into_iter().map(workspace_summary).collect(), - )) - .into_response(), - Err(error) => server_error_response(error), - } -} - -async fn create_server_workspace( - State(api): State, - headers: HeaderMap, - Json(request): Json, -) -> Response { - let owner_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => { - return forbidden_server_response("Workspace creation requires an authenticated owner"); - } - Err(error) => return server_error_response(error), - }; - let created = match api.catalog.create(request, owner_account_id) { - Ok(created) => created, - Err(error) => return server_error_response(error), - }; - if let Err(error) = api - .router_for_workspace(&created.workspace.workspace_id) - .await - { - return server_error_response(error); - } - let status = if created.replayed { - StatusCode::OK - } else { - StatusCode::CREATED - }; - (status, Json(workspace_create_response(created))).into_response() -} - -async fn preflight_server_workspace_deletion( - State(api): State, - AxumPath(workspace_id): AxumPath, - headers: HeaderMap, -) -> Response { - let actor_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => return forbidden_server_response("Workspace deletion requires its owner"), - Err(error) => return server_error_response(error), - }; - match api - .workspace_deletion_preflight(&actor_account_id, &workspace_id) - .await - { - Ok(preflight) => Json(preflight).into_response(), - Err(error) => server_error_response(error), - } -} - -async fn start_server_workspace_deletion( - State(api): State, - AxumPath(workspace_id): AxumPath, - headers: HeaderMap, - Json(request): Json, -) -> Response { - let actor_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => return forbidden_server_response("Workspace deletion requires its owner"), - Err(error) => return server_error_response(error), - }; - let mutation_lock = api.mutation_lock(&workspace_id).await; - let _mutation_guard = mutation_lock.lock().await; - let existing = match api - .store - .workspace_deletion_operation(&actor_account_id, &request.operation_id) - { - Ok(existing) => existing, - Err(error) => return server_error_response(error), - }; - if existing.is_none() { - let preflight = match api - .workspace_deletion_preflight(&actor_account_id, &workspace_id) - .await - { - Ok(preflight) => preflight, - Err(error) => return server_error_response(error), - }; - if !preflight.can_delete { - return (StatusCode::CONFLICT, Json(preflight)).into_response(); - } - } - let reservation = - match api - .store - .reserve_workspace_deletion(&actor_account_id, &workspace_id, &request) - { - Ok(reservation) => reservation, - Err(error) => return server_error_response(error), - }; - if let Some(handle) = api.hook_handles.lock().await.remove(&workspace_id) { - handle.abort(); - } - let operation = reservation.operation; - if operation.state != WorkspaceDeletionState::Succeeded { - api.schedule_workspace_deletion(request.operation_id).await; - } - let status = if operation.state == WorkspaceDeletionState::Succeeded { - StatusCode::OK - } else { - StatusCode::ACCEPTED - }; - (status, Json(operation)).into_response() -} - -async fn get_server_workspace_deletion( - State(api): State, - AxumPath(operation_id): AxumPath, - headers: HeaderMap, -) -> Response { - let actor_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => actor.account_id, - Ok(None) => { - return forbidden_server_response("Workspace deletion status requires its owner"); - } - Err(error) => return server_error_response(error), - }; - match api - .store - .workspace_deletion_operation(&actor_account_id, &operation_id) - { - Ok(Some(operation)) => Json(operation).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - "Workspace deletion operation not found", - ) - .into_response(), - Err(error) => server_error_response(error), - } -} - fn workspace_summary(record: WorkspaceRecord) -> WorkspaceSummary { WorkspaceSummary { workspace_id: record.workspace_id, @@ -2659,35 +2553,24 @@ pub async fn build_workspace_server_router( template: ServerConfig, store: Arc, ) -> Result { - let auth = build_server_auth_router(ServerAuthApi { - config: template.clone(), - store: store.clone(), - }); let api = WorkspaceServerApi::new(template, store); api.preload().await?; api.recover_workspace_deletions().await?; + let contract_service = ServerApiContractService::Server(api.clone()); let catalog = Router::new() - .route("/health", get(server_health)) - .route( - "/api/workspaces", - get(list_server_workspaces).post(create_server_workspace), - ) - .route( - "/api/workspaces/{workspace_id}/deletion", - get(preflight_server_workspace_deletion).post(start_server_workspace_deletion), - ) - .route( - "/api/workspace-deletions/{operation_id}", - get(get_server_workspace_deletion), - ) .fallback(dispatch_workspace_request) - .with_state(api.clone()); - Ok(auth - .merge(catalog) - .layer(axum::middleware::from_fn_with_state( - api, - enforce_server_cookie_mutation_origin, + .with_state(api.clone()) + .merge(server_api::server_api_axum::health(Arc::new( + contract_service.clone(), ))) + .merge(generated_auth_contract_router(contract_service.clone())) + .merge(generated_workspace_catalog_contract_router( + contract_service, + )); + Ok(catalog.layer(axum::middleware::from_fn_with_state( + api, + enforce_server_cookie_mutation_origin, + ))) } #[cfg(test)] @@ -3155,7 +3038,7 @@ impl WorkspaceApi { runtime_id: &str, mut request: WorkerSpawnRequest, ) -> ApiResult { - self.validate_worker_spawn_repository_scope(&request)?; + self.validate_worker_spawn_repository_scope(runtime_id, &mut request)?; let workspace_api = self.workspace_api_ref(runtime_id); request.resolved_workspace_api = Some(workspace_api.clone()); for access in take_new_workdir_repository_access(&mut request)? { @@ -3188,6 +3071,7 @@ impl WorkspaceApi { attachment.alias.to_string(), attachment.working_directory_id.clone(), Uuid::new_v4().to_string(), + attachment.capabilities, ) }) .collect::>(); @@ -3232,8 +3116,13 @@ impl WorkspaceApi { assignment: None, spawned_workdir_ids: &spawned_workdir_ids, }; - let mut reserved_attachments: Vec<(String, String, String)> = Vec::new(); - for (alias, workdir_id, reservation_id) in &attachment_reservations { + let mut reserved_attachments: Vec<( + String, + String, + String, + workdir::WorkdirSessionCapabilities, + )> = Vec::new(); + for (alias, workdir_id, reservation_id, capabilities) in &attachment_reservations { if let Err(error) = self.store.reserve_worker_workdir_attachment( &self.config.workspace_id, workdir_id, @@ -3265,7 +3154,12 @@ impl WorkspaceApi { ); return Err(ApiError::with_diagnostics(error, diagnostics)); } - reserved_attachments.push((alias.clone(), workdir_id.clone(), reservation_id.clone())); + reserved_attachments.push(( + alias.clone(), + workdir_id.clone(), + reservation_id.clone(), + *capabilities, + )); } let mut result = match self .runtime @@ -3325,7 +3219,7 @@ impl WorkspaceApi { diagnostics, )); } - if result.state != WorkerOperationState::Accepted { + if result.state != InternalWorkerOperationState::Accepted { let diagnostics = compensate_failed_workspace_worker_create( self, runtime_id, @@ -3358,7 +3252,7 @@ impl WorkspaceApi { return Err(ApiError::with_diagnostics(error.into_error(), diagnostics)); } }; - if replacement.state != WorkerOperationState::Accepted { + if replacement.state != InternalWorkerOperationState::Accepted { let diagnostics = compensate_failed_workspace_worker_create( self, runtime_id, @@ -3425,12 +3319,13 @@ impl WorkspaceApi { return Err(api_error_with_additional_diagnostics(error, diagnostics)); } - for (alias, workdir_id, reservation_id) in &reserved_attachments { + for (alias, workdir_id, reservation_id, capabilities) in &reserved_attachments { let attachment = WorkerWorkdirLinkRecord { workspace_id: self.config.workspace_id.clone(), worker: worker_ref.clone(), workdir_id: workdir_id.clone(), alias: alias.clone(), + capabilities: *capabilities, linked_at: now_registry_timestamp(), unlinked_at: None, }; @@ -3483,7 +3378,7 @@ impl WorkspaceApi { fn restore_workspace_worker( &self, worker: &RuntimeWorkerRef, - ) -> ApiResult { + ) -> ApiResult { for link in self .store .list_worker_workdir_links(&self.config.workspace_id, worker)? @@ -3551,8 +3446,8 @@ impl WorkspaceApi { .runtime .replace_worker_workspace_api(worker, self.workspace_api_ref(&worker.runtime_id)) .map_err(|error| error.into_error())?; - if binding.state != WorkerOperationState::Accepted { - return Ok(WorkerRestoreResult { + if binding.state != InternalWorkerOperationState::Accepted { + return Ok(InternalWorkerRestoreResult { state: server_api::WorkerRestoreState::Rejected, worker: binding.worker, diagnostics: binding.diagnostics, @@ -3585,13 +3480,8 @@ impl WorkspaceApi { &self, repository_id: &str, ) -> ApiResult { - self.require_workspace_repository(repository_id)?; - self.config - .repositories - .iter() - .find(|repository| repository.id == repository_id) - .cloned() - .ok_or_else(|| ApiError::from(Error::UnknownRepository(repository_id.to_string()))) + configured_repository_from_record(self.require_workspace_repository(repository_id)?) + .map_err(ApiError::from) } fn require_configured_workspace_repository_by_key( @@ -3612,23 +3502,55 @@ impl WorkspaceApi { fn validate_worker_spawn_repository_scope( &self, - request: &WorkerSpawnRequest, + runtime_id: &str, + request: &mut WorkerSpawnRequest, ) -> ApiResult<()> { - let mut selected_repositories = Vec::new(); for attachment in &request.resolved_workdir_attachment_requests { - let working_directory = &attachment.working_directory; - let repository_id = working_directory.repository.id.as_str(); - self.require_workspace_repository(repository_id)?; - selected_repositories.push(( - repository_id.to_string(), - working_directory - .repository - .selector - .as_deref() - .map(str::to_owned), - )); + self.require_workspace_repository(&attachment.working_directory.repository.id)?; } - for claim in &request.resolved_workdir_attachments { + let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent else { + for claim in &mut request.resolved_workdir_attachments { + let workdir = self + .store + .get_workdir_registry(&self.config.workspace_id, &claim.working_directory_id)? + .ok_or_else(|| { + ApiError::from(Error::Config(format!( + "unknown working directory `{}` in this Workspace", + claim.working_directory_id + ))) + })?; + claim.capabilities = workdir_source_capabilities(&workdir); + } + return Ok(()); + }; + + if !request.resolved_workdir_attachment_requests.is_empty() { + return Err(Error::InvalidInput( + "Ticket-role spawn requires existing Workspace Workdir attachments so Backend authority can bind each persisted target capability before Runtime side effects" + .to_string(), + ) + .into()); + } + + let targets = validated_ticket_implementation_targets(self, ticket_id)?; + let mut aliases = HashSet::new(); + let mut workdir_ids = HashSet::new(); + let mut covered_repositories = HashSet::new(); + for claim in &mut request.resolved_workdir_attachments { + if !aliases.insert(claim.alias.as_str().to_string()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` Worker attachment alias `{}` appears more than once", + claim.alias + )) + .into()); + } + if !workdir_ids.insert(claim.working_directory_id.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` Workdir `{}` appears more than once", + claim.working_directory_id + )) + .into()); + } let workdir = self .store .get_workdir_registry(&self.config.workspace_id, &claim.working_directory_id)? @@ -3638,58 +3560,397 @@ impl WorkspaceApi { claim.working_directory_id ))) })?; - if let WorkdirRegistrySource::Repository { repository_id, .. } = workdir.source { - self.require_workspace_repository(&repository_id)?; - selected_repositories.push((repository_id, workdir.creation_selector)); - } - } - - if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent { - let ticket = self.authority.ticket(ticket_id)?; - // Workdir-less Ticket Workers cannot execute repository implementation. - // Preserve that control-plane launch while still validating any persisted - // target (including its Workspace ownership) when one exists. - if selected_repositories.is_empty() && ticket.repository_key.is_none() { - return Ok(()); - } - let repository_key = ticket.repository_key.as_deref().ok_or_else(|| { - ApiError::from(Error::Config( - "Ticket implementation target must be validated and persisted before spawning a Ticket Worker".to_owned(), + let (workdir_runtime_id, repository_id) = match &workdir.source { + WorkdirRegistrySource::Repository { + runtime_id, + repository_id, + } => (runtime_id, repository_id), + WorkdirRegistrySource::ExternalGrant { .. } => { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target", + claim.alias + )) + .into()); + } + }; + if workdir_runtime_id != runtime_id { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` belongs to Runtime `{workdir_runtime_id}`, not requested Runtime `{runtime_id}`", + claim.alias )) - })?; - let repository = self.require_configured_workspace_repository_by_key(repository_key)?; - let repository_id = repository.id.as_str(); - let ref_selector = ticket.ref_selector.as_deref().ok_or_else(|| { - ApiError::from(Error::Config( - "Ticket implementation target selector must be validated and persisted before spawning a Ticket Worker".to_owned(), - )) - })?; - self.require_workspace_repository(repository_id)?; - self.repository_reader() - .observe_merge_target(repository_id, Some(ref_selector)) - .map_err(|error| { - ApiError::from(Error::Config(format!( - "Ticket implementation target is no longer resolvable: {error:?}" + .into()); + } + let target = targets + .iter() + .find(|target| target.repository_id == *repository_id) + .ok_or_else(|| { + ApiError::from(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` resolves undeclared Repository `{repository_id}`", + claim.alias ))) })?; - for (selected_repository_id, selected_ref_selector) in &selected_repositories { - if selected_repository_id != repository_id { - return Err(ApiError::from(Error::Config(format!( - "Ticket `{ticket_id}` targets Repository `{repository_key}`, but a Worker attachment resolves a different Repository" - )))); - } - if selected_ref_selector.as_deref() != Some(ref_selector) { - return Err(ApiError::from(Error::Config(format!( - "Ticket `{ticket_id}` targets selector `{ref_selector}`, but a Worker attachment resolves `{}`", - selected_ref_selector.as_deref().unwrap_or("none") - )))); - } + if workdir.creation_selector.as_deref() != Some(target.ref_selector.as_str()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target selector `{}` does not match attachment `{}` selector `{}`", + target.ref_selector, + claim.alias, + workdir.creation_selector.as_deref().unwrap_or("none") + )) + .into()); } + if !covered_repositories.insert(repository_id.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{}` has more than one Worker attachment", + target.repository_key + )) + .into()); + } + claim.capabilities = target + .capabilities + .intersection(workdir_source_capabilities(&workdir)); + } + + let missing = targets + .iter() + .filter(|target| !covered_repositories.contains(&target.repository_id)) + .map(|target| target.repository_key.as_str()) + .collect::>(); + if !missing.is_empty() { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` is missing one-to-one Workdir attachment coverage for target(s): {}", + missing.join(", ") + )) + .into()); + } + if request.resolved_workdir_attachments.len() != targets.len() { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` requires exactly {} Workdir attachment(s), found {}", + targets.len(), + request.resolved_workdir_attachments.len() + )) + .into()); } Ok(()) } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ValidatedTicketImplementationTarget { + repository_id: String, + repository_key: String, + ref_selector: String, + access: TicketTargetAccess, + capabilities: workdir::WorkdirSessionCapabilities, +} + +fn workdir_source_capabilities( + workdir: &WorkdirRegistryRecord, +) -> workdir::WorkdirSessionCapabilities { + match &workdir.source { + WorkdirRegistrySource::Repository { .. } => workdir::WorkdirSessionCapabilities::ALL, + WorkdirRegistrySource::ExternalGrant { .. } => { + workdir::WorkdirSessionCapabilities::READ_ONLY + } + } +} + +fn validated_ticket_implementation_targets( + api: &WorkspaceApi, + ticket_id: &str, +) -> ApiResult> { + let ticket = browser_ticket_backend(api)? + .show(TicketIdOrSlug::Id(ticket_id.to_string())) + .map_err(Error::from)?; + if ticket.meta.targets.is_empty() { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` has no persisted implementation targets" + )) + .into()); + } + let read_write_count = ticket + .meta + .targets + .iter() + .filter(|target| target.access == TicketTargetAccess::ReadWrite) + .count(); + if read_write_count != 1 { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` requires exactly one read_write target, found {read_write_count}" + )) + .into()); + } + + let mut repository_ids = HashSet::new(); + ticket + .meta + .targets + .into_iter() + .map(|target| { + let repository_key = target.repository_key; + let repository = api + .store + .get_repository_by_key(&api.config.workspace_id, &repository_key)? + .ok_or_else(|| ApiError::from(Error::UnknownRepository(repository_key.clone())))?; + let repository_id = repository.repository_id.clone(); + if !repository_ids.insert(repository_id.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{repository_key}` appears more than once" + )) + .into()); + } + let ref_selector = target + .ref_selector + .filter(|selector| !selector.trim().is_empty()) + .ok_or_else(|| { + ApiError::from(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{}` has no persisted selector", + repository.repository_key + ))) + })?; + api.live_repository_reader()? + .observe_merge_target(&repository_id, Some(&ref_selector)) + .map_err(|error| { + ApiError::from(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{}` selector `{ref_selector}` is not resolvable: {error:?}", + repository.repository_key + ))) + })?; + let capabilities = match target.access { + TicketTargetAccess::ReadOnly => { + workdir::WorkdirSessionCapabilities::READ_ONLY + } + TicketTargetAccess::ReadWrite => workdir::WorkdirSessionCapabilities::ALL, + }; + Ok(ValidatedTicketImplementationTarget { + repository_id, + repository_key: repository.repository_key, + ref_selector, + access: target.access, + capabilities, + }) + }) + .collect() +} + +fn require_ticket_read_write_target( + api: &WorkspaceApi, + ticket_id: &str, + repository_id: &str, + selector: &str, +) -> ApiResult { + let write_target = validated_ticket_implementation_targets(api, ticket_id)? + .into_iter() + .find(|target| target.access == TicketTargetAccess::ReadWrite) + .expect("validated Ticket target collection has exactly one read_write target"); + if write_target.repository_id != repository_id || write_target.ref_selector != selector { + return Err(Error::InvalidInput( + "selectors must match the authoritative Ticket read_write repository target".into(), + ) + .into()); + } + Ok(write_target) +} + +fn ticket_target_capabilities_for_workdir( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + workdir: &WorkdirRegistryRecord, +) -> Result { + let source_capabilities = workdir_source_capabilities(workdir); + let Some(assignment) = api + .store + .get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)? + .filter(|assignment| assignment.role == TicketAssignmentRole::Coder) + else { + return Ok(source_capabilities); + }; + let WorkdirRegistrySource::Repository { repository_id, .. } = &workdir.source else { + return Err(Error::InvalidInput(format!( + "assigned Ticket Coder Workdir `{}` is not a Repository target", + workdir.workdir_id + ))); + }; + let targets = validated_ticket_implementation_targets(api, &assignment.ticket_id) + .map_err(|error| error.error)?; + let target = targets + .into_iter() + .find(|target| target.repository_id == *repository_id) + .ok_or_else(|| { + Error::InvalidInput(format!( + "Workdir `{}` Repository is not declared by assigned Ticket `{}`", + workdir.workdir_id, assignment.ticket_id + )) + })?; + if workdir.creation_selector.as_deref() != Some(target.ref_selector.as_str()) { + return Err(Error::InvalidInput(format!( + "Workdir `{}` selector does not match assigned Ticket `{}` target selector", + workdir.workdir_id, assignment.ticket_id + ))); + } + Ok(target.capabilities.intersection(source_capabilities)) +} + +#[derive(Clone, Debug)] +struct ManualTicketCoderWorkdirBinding { + original_links: Vec, + effective_links: Vec, + runtime_attachments: Vec, +} + +fn validate_manual_ticket_coder_workdir_binding( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + ticket_id: &str, +) -> ApiResult { + let targets = validated_ticket_implementation_targets(api, ticket_id)?; + let original_links = api + .store + .list_worker_workdir_links(&api.config.workspace_id, worker)? + .into_iter() + .filter(|link| link.unlinked_at.is_none()) + .collect::>(); + let mut aliases = HashSet::new(); + let mut workdir_ids = HashSet::new(); + let mut covered_repositories = HashSet::new(); + let mut effective_links = Vec::with_capacity(original_links.len()); + let mut runtime_attachments = Vec::with_capacity(original_links.len()); + + for link in &original_links { + if !aliases.insert(link.alias.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` Worker attachment alias `{}` appears more than once", + link.alias + )) + .into()); + } + if !workdir_ids.insert(link.workdir_id.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` Workdir `{}` appears more than once", + link.workdir_id + )) + .into()); + } + let workdir = api + .store + .get_workdir_registry(&api.config.workspace_id, &link.workdir_id)? + .ok_or_else(|| { + ApiError::from(Error::Config(format!( + "unknown working directory `{}` in this Workspace", + link.workdir_id + ))) + })?; + if workdir.materialization_status != "present" { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` Workdir `{}` is not present", + link.alias, link.workdir_id + )) + .into()); + } + let (workdir_runtime_id, repository_id) = match &workdir.source { + WorkdirRegistrySource::Repository { + runtime_id, + repository_id, + } => (runtime_id, repository_id), + WorkdirRegistrySource::ExternalGrant { .. } => { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target", + link.alias + )) + .into()); + } + }; + if workdir_runtime_id != &worker.runtime_id { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` belongs to Runtime `{workdir_runtime_id}`, not Worker Runtime `{}`", + link.alias, worker.runtime_id + )) + .into()); + } + let target = targets + .iter() + .find(|target| target.repository_id == *repository_id) + .ok_or_else(|| { + ApiError::from(Error::InvalidInput(format!( + "Ticket `{ticket_id}` attachment `{}` resolves undeclared Repository `{repository_id}`", + link.alias + ))) + })?; + if workdir.creation_selector.as_deref() != Some(target.ref_selector.as_str()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target selector `{}` does not match attachment `{}` selector `{}`", + target.ref_selector, + link.alias, + workdir.creation_selector.as_deref().unwrap_or("none") + )) + .into()); + } + if !covered_repositories.insert(repository_id.clone()) { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` target Repository `{}` has more than one Worker attachment", + target.repository_key + )) + .into()); + } + let capabilities = link + .capabilities + .intersection(workdir_source_capabilities(&workdir)) + .intersection(target.capabilities); + effective_links.push(WorkerWorkdirLinkRecord { + capabilities, + ..link.clone() + }); + runtime_attachments.push(LogicalWorkdirAttachment { + alias: workdir::WorkdirAttachmentAlias::new(link.alias.clone()) + .map_err(|error| Error::InvalidInput(error.to_string()))?, + working_directory_id: link.workdir_id.clone(), + capabilities, + }); + } + + let missing = targets + .iter() + .filter(|target| !covered_repositories.contains(&target.repository_id)) + .map(|target| target.repository_key.as_str()) + .collect::>(); + if !missing.is_empty() { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` is missing one-to-one Workdir attachment coverage for target(s): {}", + missing.join(", ") + )) + .into()); + } + if original_links.len() != targets.len() { + return Err(Error::InvalidInput(format!( + "Ticket `{ticket_id}` requires exactly {} Workdir attachment(s), found {}", + targets.len(), + original_links.len() + )) + .into()); + } + + Ok(ManualTicketCoderWorkdirBinding { + original_links, + effective_links, + runtime_attachments, + }) +} + +fn logical_attachments_from_links( + links: &[WorkerWorkdirLinkRecord], +) -> Result> { + links + .iter() + .map(|link| { + Ok(LogicalWorkdirAttachment { + alias: workdir::WorkdirAttachmentAlias::new(link.alias.clone()) + .map_err(|error| Error::InvalidInput(error.to_string()))?, + working_directory_id: link.workdir_id.clone(), + capabilities: link.capabilities, + }) + }) + .collect() +} + fn import_configured_repositories( store: &dyn ControlPlaneStore, config: &ServerConfig, @@ -3750,41 +4011,1869 @@ fn configured_repository_from_record(record: RepositoryRecord) -> Result Router { - Router::new() - .route("/api/auth/config", get(get_auth_config)) - .route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user)) - .route( - "/api/auth/passkeys/registration/options", - post(post_passkey_registration_options), - ) - .route( - "/api/auth/passkeys/registration/complete", - post(post_passkey_registration_complete), - ) - .route( - "/api/auth/passkeys/login/options", - post(post_passkey_login_options), - ) - .route( - "/api/auth/passkeys/login/complete", - post(post_passkey_login_complete), - ) - .route("/api/auth/logout", post(post_auth_logout)) - .route( - "/api/auth/device-login/start", - post(post_device_login_start), - ) - .route( - "/api/auth/device-login/approve", - post(post_device_login_approve), - ) - .route("/api/auth/device-login/poll", post(post_device_login_poll)) - .route("/api/auth/whoami", get(get_auth_whoami)) - .with_state(api) +fn server_request_context( + actor: Option, + worker_source: Option, + runtime_source: Option, + origin: Option, + headers: &HeaderMap, +) -> server_api::ServerRequestContext { + server_api::ServerRequestContext { + actor, + worker_source, + runtime_source, + origin, + transport_headers: headers + .iter() + .map(|(name, value)| (name.as_str().to_string(), value.as_bytes().to_vec())) + .collect(), + } } -impl server_api::ServerApi for WorkspaceApi { +fn contract_request_headers( + context: &server_api::ServerRequestContext, +) -> std::result::Result { + let mut headers = HeaderMap::new(); + for (name, value) in &context.transport_headers { + let name = HeaderName::from_bytes(name.as_bytes()).map_err(|error| { + server_api::RepositoryApiError::new( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "Internal Server Error", + format!("generated route retained an invalid request header name: {error}"), + Vec::new(), + ) + })?; + let value = HeaderValue::from_bytes(value).map_err(|error| { + server_api::RepositoryApiError::new( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "Internal Server Error", + format!("generated route retained an invalid request header value: {error}"), + Vec::new(), + ) + })?; + headers.append(name, value); + } + Ok(headers) +} + +async fn attach_server_origin_context(mut request: Request, next: Next) -> Response { + let actor = request.extensions().get::().cloned(); + let origin = request_origin(request.headers()); + let context = server_request_context(actor, None, None, origin, request.headers()); + request.extensions_mut().insert(context); + next.run(request).await +} + +async fn attach_server_request_context( + State(service): State, + mut request: Request, + next: Next, +) -> Response { + let actor = if let Some(actor) = request.extensions().get::().cloned() { + Some(actor) + } else { + let auth = service.auth_api(); + match resolve_request_actor( + auth.store.as_ref(), + request.headers(), + &auth_public_config(&auth.config).cookie_name, + ) + .await + { + Ok(actor) => actor, + Err(error) => return server_error_response(error), + } + }; + let worker_source = request + .extensions() + .get::() + .map(|source| server_api::ServerWorkerSource { + runtime_id: source.runtime_id.clone(), + worker_id: source.worker_id.clone(), + }); + let runtime_source = request + .extensions() + .get::() + .map(|source| server_api::ServerRuntimeSource { + runtime_id: source.runtime_id.clone(), + worker_id: source.worker_id.clone(), + }); + let origin = request_origin(request.headers()); + let context = server_request_context( + actor, + worker_source, + runtime_source, + origin, + request.headers(), + ); + request.extensions_mut().insert(context); + next.run(request).await +} + +fn generated_auth_contract_router(service: ServerApiContractService) -> Router { + let service = Arc::new(service); + let origin_context = Router::new() + .merge(server_api::server_api_axum::auth_passkey_registration_options(service.clone())) + .merge(server_api::server_api_axum::auth_passkey_login_options( + service.clone(), + )) + .layer(middleware::from_fn(attach_server_origin_context)); + let actor_context = Router::new() + .merge(server_api::server_api_axum::auth_device_login_approve( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_whoami(service.clone())) + .layer(middleware::from_fn_with_state( + service.as_ref().clone(), + attach_server_request_context, + )); + Router::new() + .merge(server_api::server_api_axum::auth_config(service.clone())) + .merge(server_api::server_api_axum::auth_bootstrap_user( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_passkey_registration_complete(service.clone())) + .merge(server_api::server_api_axum::auth_passkey_login_complete( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_logout(service.clone())) + .merge(server_api::server_api_axum::auth_device_login_start( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_device_login_poll(service)) + .merge(origin_context) + .merge(actor_context) +} + +fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router { + let service = Arc::new(service); + Router::new() + .merge(server_api::server_api_axum::workspace_catalog_list( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_catalog_create( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_deletion_preflight( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_deletion_start( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_deletion_get( + service.clone(), + )) + .layer(middleware::from_fn_with_state( + service.as_ref().clone(), + attach_server_request_context, + )) +} + +fn generated_workspace_contract_router(service: ServerApiContractService) -> Router { + let service = Arc::new(service); + Router::new() + .merge(server_api::server_api_axum::workspace_current( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_scoped( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_runtime_config( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_metadata_settings( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_metadata_settings_update(service.clone())) + .merge(server_api::server_api_axum::workspace_signing_identity( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_signing_identity_provision(service.clone())) + .merge(server_api::server_api_axum::workspace_memory_settings( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_memory_settings_update(service.clone())) + .merge(server_api::server_api_axum::repository_access_projection( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_ssh_credential_list( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_ssh_credential_create(service.clone())) + .merge(server_api::server_api_axum::repository_ssh_credential_generate(service.clone())) + .merge(server_api::server_api_axum::repository_ssh_credential_get( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_ssh_credential_delete(service.clone())) + .merge(server_api::server_api_axum::repository_ssh_credential_public_key(service.clone())) + .merge(server_api::server_api_axum::repository_ssh_credential_rotate(service.clone())) + .merge(server_api::server_api_axum::repository_ssh_host_trust_list( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_ssh_host_trust_put( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_ssh_host_trust_get( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_ssh_host_trust_delete(service.clone())) + .merge(server_api::server_api_axum::workspace_config_tree( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_prompt_projection( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_config_tree_commit( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_config_revision( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_config_entry( + service.clone(), + )) + .merge(server_api::server_api_axum::profile_settings( + service.clone(), + )) + .merge(server_api::server_api_axum::flow_list(service.clone())) + .merge(server_api::server_api_axum::flow_put(service.clone())) + .merge(server_api::server_api_axum::flow_resolve(service.clone())) + .merge(server_api::server_api_axum::flow_get(service.clone())) + .merge(server_api::server_api_axum::memory_document( + service.clone(), + )) + .merge(server_api::server_api_axum::memory_staging_list( + service.clone(), + )) + .merge(server_api::server_api_axum::memory_backend(service.clone())) + .merge(server_api::server_api_axum::memory_consolidation( + service.clone(), + )) + .merge(server_api::server_api_axum::skill_list(service.clone())) + .merge(server_api::server_api_axum::skill_lint(service.clone())) + .merge(server_api::server_api_axum::skill_get(service.clone())) + .merge(server_api::server_api_axum::skill_activate(service.clone())) + .merge(server_api::server_api_axum::worker_session(service.clone())) + .merge(server_api::server_api_axum::repository_list( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_detail( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_detail_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_create( + service.clone(), + )) + .merge(server_api::server_api_axum::repository_ssh_connection_probe(service.clone())) + .merge(server_api::server_api_axum::repository_ssh_host_trust_confirm(service.clone())) + .merge(server_api::server_api_axum::repository_log(service.clone())) + .merge(server_api::server_api_axum::repository_log_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::host_list(service.clone())) + .merge(server_api::server_api_axum::host_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::host_worker_list( + service.clone(), + )) + .merge(server_api::server_api_axum::host_worker_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_workdir_list( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_workdir_create( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_workdir_detail( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_workdir_cleanup( + service.clone(), + )) + .merge(server_api::server_api_axum::current_worker_workdir_attach( + service.clone(), + )) + .merge(server_api::server_api_axum::current_worker_workdir_detach( + service.clone(), + )) + .merge(server_api::server_api_axum::current_worker_workdir_operation(service.clone())) + .merge(server_api::server_api_axum::external_workdir_grant_create( + service.clone(), + )) + .merge(server_api::server_api_axum::external_workdir_grant_get( + service.clone(), + )) + .merge(server_api::server_api_axum::external_workdir_grant_revoke( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_workdir_list( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_workdir_create( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_workdir_detail( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_workdir_cleanup( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_list(service.clone())) + .merge(server_api::server_api_axum::ticket_create_record( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_query(service.clone())) + .merge(server_api::server_api_axum::ticket_get_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_default_intake_ready_body(service.clone())) + .merge(server_api::server_api_axum::ticket_summary_search( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_doctor(service.clone())) + .merge(server_api::server_api_axum::ticket_relation_query( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_orchestration_plan_query(service.clone())) + .merge(server_api::server_api_axum::ticket_record_get( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_record_item_edit( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_dependency_check( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_thread_event_add( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_state_change_add( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_intake_summary_add( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_state_field_set( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_workflow_state_set( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_mark_ready_record( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_queue_record( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_list( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_show( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_open( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_readiness( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_thread( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_selector_repair( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_reviewer_child_register(service.clone())) + .merge( + server_api::server_api_axum::merge_request_review_capability_register(service.clone()), + ) + .merge(server_api::server_api_axum::merge_request_review_submit( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_review_revoke( + service.clone(), + )) + .merge(server_api::server_api_axum::merge_request_complete( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_close_record( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_relation_view( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_relation_record( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_relation_remove( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_orchestration_plan_record(service.clone())) + .merge(server_api::server_api_axum::ticket_get(service.clone())) + .merge(server_api::server_api_axum::ticket_edit(service.clone())) + .merge(server_api::server_api_axum::ticket_show(service.clone())) + .merge(server_api::server_api_axum::ticket_assignment_list( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignment_set( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignment_clear( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_implementation_cancel( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_state_transition( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_ready(service.clone())) + .merge(server_api::server_api_axum::ticket_event_append( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_queue(service.clone())) + .merge(server_api::server_api_axum::ticket_close(service.clone())) + .merge(server_api::server_api_axum::workspace_worker_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_worker_create_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_orchestrator_status( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_orchestrator_start( + service.clone(), + )) + .merge(server_api::server_api_axum::worker_control_list( + service.clone(), + )) + .merge(server_api::server_api_axum::worker_control_spawn( + service.clone(), + )) + .merge(server_api::server_api_axum::worker_control_input( + service.clone(), + )) + .merge(server_api::server_api_axum::worker_control_cancel( + service.clone(), + )) + .merge(server_api::server_api_axum::worker_control_stop( + service.clone(), + )) + .merge(server_api::server_api_axum::worker_control_restore( + service.clone(), + )) + .merge(server_api::server_api_axum::worker_observation_sessions( + service.clone(), + )) + .merge(server_api::server_api_axum::worker_observation_capture( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_worker_discovery( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_worker_list( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_worker_create( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_worker_detail( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_worker_launch_options_alias(service.clone())) + .merge(server_api::server_api_axum::workspace_worker_launch_options(service.clone())) + .merge(server_api::server_api_axum::runtime_resource_fetch( + service.clone(), + )) + .merge(server_api::server_api_axum::companion_status_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::companion_status( + service.clone(), + )) + .merge(server_api::server_api_axum::companion_transcript_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::companion_transcript( + service.clone(), + )) + .merge(server_api::server_api_axum::companion_message_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::companion_message( + service.clone(), + )) + .merge(server_api::server_api_axum::companion_cancel_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::companion_cancel( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_worker_remove( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_list(service.clone())) + .merge(server_api::server_api_axum::runtime_create(service.clone())) + .merge(server_api::server_api_axum::runtime_detail(service.clone())) + .merge(server_api::server_api_axum::runtime_update(service.clone())) + .merge(server_api::server_api_axum::runtime_remove(service.clone())) + .merge(server_api::server_api_axum::runtime_trust_key_reveal( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_trust_key_revoke( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_connection_test( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_create_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_list( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_create( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_config_bundle_sync_alias(service.clone())) + .merge(server_api::server_api_axum::runtime_config_bundle_sync( + service.clone(), + )) + .merge( + server_api::server_api_axum::runtime_config_bundle_availability_alias(service.clone()), + ) + .merge(server_api::server_api_axum::runtime_config_bundle_availability(service.clone())) + .merge(server_api::server_api_axum::runtime_worker_detail_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_detail( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_restore_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_restore( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_pin( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_unpin( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_cleanup_plan( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_cleanup_execute( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_input_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_input( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_attachment_upload_grant(service.clone())) + .merge( + server_api::server_api_axum::runtime_worker_attachment_upload(service.clone()) + .layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)), + ) + .merge( + server_api::server_api_axum::runtime_worker_attachment_upload_cancel(service.clone()), + ) + .merge(server_api::server_api_axum::runtime_worker_attachment_delete(service.clone())) + .merge(server_api::server_api_axum::runtime_worker_completions_alias(service.clone())) + .merge(server_api::server_api_axum::runtime_worker_completions( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_stop_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_stop( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_cancel_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::runtime_worker_cancel( + service.clone(), + )) + .merge(server_api::server_api_axum::objective_list_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::objective_list(service.clone())) + .merge(server_api::server_api_axum::objective_create( + service.clone(), + )) + .merge(server_api::server_api_axum::objective_query( + service.clone(), + )) + .merge(server_api::server_api_axum::objective_get_alias( + service.clone(), + )) + .merge(server_api::server_api_axum::objective_get(service.clone())) + .merge(server_api::server_api_axum::objective_edit(service.clone())) + .merge(server_api::server_api_axum::objective_show(service.clone())) + .merge(server_api::server_api_axum::objective_state_set( + service.clone(), + )) + .merge(server_api::server_api_axum::objective_ticket_link( + service.clone(), + )) + .merge(server_api::server_api_axum::objective_ticket_unlink( + service.clone(), + )) + .layer(middleware::from_fn_with_state( + service.as_ref().clone(), + attach_server_request_context, + )) +} + +fn invalid_contract_value(context: &str, error: impl std::fmt::Display) -> Error { + Error::InvalidInput(format!("invalid {context}: {error}")) +} + +fn worker_ticket_assignment_from_api( + assignment: server_api::RuntimeWorkerTicketAssignmentRequest, +) -> WorkerTicketAssignmentRequest { + WorkerTicketAssignmentRequest { + ticket_id: assignment.ticket_id, + operation_id: assignment.operation_id, + } +} + +fn worker_spawn_request_from_api( + request: server_api::RuntimeWorkerSpawnRequest, +) -> Result { + let intent = match request.intent { + server_api::RuntimeWorkerSpawnIntent::WorkspaceCompanion => { + WorkerSpawnIntent::WorkspaceCompanion + } + server_api::RuntimeWorkerSpawnIntent::WorkspaceOrchestrator => { + WorkerSpawnIntent::WorkspaceOrchestrator + } + server_api::RuntimeWorkerSpawnIntent::WorkspaceCoding => WorkerSpawnIntent::WorkspaceCoding, + server_api::RuntimeWorkerSpawnIntent::TicketRole { ticket_id, role } => { + WorkerSpawnIntent::TicketRole { + ticket_id, + role: match role { + server_api::RuntimeTicketWorkerRole::Intake => TicketWorkerRole::Intake, + server_api::RuntimeTicketWorkerRole::Orchestrator => { + TicketWorkerRole::Orchestrator + } + server_api::RuntimeTicketWorkerRole::Coder => TicketWorkerRole::Coder, + server_api::RuntimeTicketWorkerRole::Reviewer => TicketWorkerRole::Reviewer, + }, + } + } + }; + let acceptance = match request.acceptance { + server_api::RuntimeWorkerSpawnAcceptanceRequirement::SocketReady => { + WorkerSpawnAcceptanceRequirement::SocketReady + } + server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments } => { + WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments } + } + }; + let profile = match request.profile { + server_api::RuntimeProfileSelector::Builtin(name) => ProfileSelector::Builtin(name), + server_api::RuntimeProfileSelector::Named(name) => ProfileSelector::Named(name), + }; + let initial_submit = request + .initial_submit + .into_iter() + .map(|segment| { + serde_json::from_value::(segment) + .map_err(|error| invalid_contract_value("Worker initial Submit segment", error)) + }) + .collect::>>()?; + let workdir_attachment_requests = request + .workdir_attachment_requests + .into_iter() + .map(|attachment| { + Ok(crate::hosts::WorkerSpawnWorkingDirectoryAttachmentRequest { + alias: workdir::WorkdirAttachmentAlias::new(attachment.alias).map_err(|error| { + invalid_contract_value("Worker Workdir attachment alias", error) + })?, + working_directory: WorkerSpawnWorkingDirectoryRequest { + repository_key: attachment.working_directory.repository_key, + selector: attachment.working_directory.selector, + }, + }) + }) + .collect::>>()?; + Ok(WorkerSpawnRequest { + intent, + requested_worker_name: request.requested_worker_name, + acceptance, + profile, + ticket_assignment: request + .ticket_assignment + .map(worker_ticket_assignment_from_api), + initial_submit, + workdir_attachment_requests, + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: Vec::new(), + resolved_config_bundle: None, + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_control_operation: None, + }) +} + +fn worker_input_request_from_api( + request: server_api::RuntimeWorkerInputRequest, +) -> Result { + let kind = match request.kind.as_deref().unwrap_or("user") { + "user" => WorkerInputKind::User, + "notify" => WorkerInputKind::Notify, + "compact" => WorkerInputKind::Compact, + "list_rewind_targets" => WorkerInputKind::ListRewindTargets, + "register_peer" => WorkerInputKind::RegisterPeer, + value => { + return Err(Error::InvalidInput(format!( + "invalid Worker input kind `{value}`" + ))); + } + }; + let segments = request + .segments + .map(|segments| { + segments + .into_iter() + .map(|segment| { + serde_json::from_value::(segment) + .map_err(|error| invalid_contract_value("Worker input segment", error)) + }) + .collect::>>() + }) + .transpose()?; + Ok(WorkerInputRequest { + kind, + content: request.content, + segments, + }) +} + +fn worker_lifecycle_request_from_api( + request: server_api::RuntimeWorkerLifecycleRequest, +) -> WorkerLifecycleRequest { + WorkerLifecycleRequest { + reason: request.reason, + ticket_assignment: request + .ticket_assignment + .map(worker_ticket_assignment_from_api), + } +} + +fn worker_completions_request_from_api( + request: server_api::RuntimeWorkerCompletionsRequest, +) -> Result { + Ok(WorkerCompletionsRequest { + kind: serde_json::from_value(request.kind) + .map_err(|error| invalid_contract_value("Worker completion kind", error))?, + prefix: request.prefix, + }) +} + +fn runtime_worker_summary_to_api( + worker: InternalWorkerSummary, +) -> Result { + Ok(server_api::RuntimeWorkerSummary { + runtime_id: worker.worker.runtime_id, + worker_id: worker.worker.worker_id, + host_id: worker.host_id, + display_name: worker.display_name, + label: worker.label, + profile: worker.profile, + singleton_key: worker.singleton_key, + tags: worker.tags, + workspace: server_api::WorkerWorkspaceSummary { + visibility: worker.workspace.visibility, + identity: worker.workspace.identity, + workspace_id: worker.workspace.workspace_id, + }, + state: worker.state, + worker_state: worker + .worker_state + .map(serde_json::to_value) + .transpose() + .map_err(|error| invalid_contract_value("Runtime Worker state", error))?, + last_seen_at: worker.last_seen_at, + pinned: worker.pinned, + retention_state: worker.retention_state, + implementation: server_api::WorkerImplementationSummary { + kind: worker.implementation.kind, + display_hint: worker.implementation.display_hint, + }, + capabilities: server_api::WorkerCapabilitySummary { + can_stop: worker.capabilities.can_stop, + can_spawn_followup: worker.capabilities.can_spawn_followup, + }, + workdir_attachments: worker + .workdir_attachments + .into_iter() + .map(|attachment| { + serde_json::to_value(attachment).map_err(|error| { + invalid_contract_value("Runtime Worker Workdir attachment", error) + }) + }) + .collect::>>()?, + diagnostics: worker.diagnostics.into_iter().map(Into::into).collect(), + }) +} + +fn worker_spawn_result_to_api( + result: WorkerSpawnResult, +) -> Result { + Ok(server_api::RuntimeWorkerSpawnResponse { + state: result.state.into(), + worker: result + .worker + .map(runtime_worker_summary_to_api) + .transpose()?, + acceptance_evidence: result + .acceptance_evidence + .into_iter() + .map( + |evidence| server_api::RuntimeWorkerSpawnAcceptanceEvidence { + kind: evidence.kind, + detail: evidence.detail, + }, + ) + .collect(), + diagnostics: result.diagnostics.into_iter().map(Into::into).collect(), + }) +} + +fn worker_input_result_to_api(result: WorkerInputResult) -> server_api::RuntimeWorkerInputResult { + server_api::RuntimeWorkerInputResult { + state: result.state.into(), + runtime_id: result.worker.runtime_id, + worker_id: result.worker.worker_id, + diagnostics: result.diagnostics.into_iter().map(Into::into).collect(), + } +} + +fn worker_lifecycle_result_to_api( + result: WorkerLifecycleResult, +) -> server_api::RuntimeWorkerLifecycleResult { + server_api::RuntimeWorkerLifecycleResult { + state: result.state.into(), + runtime_id: result.worker.runtime_id, + worker_id: result.worker.worker_id, + diagnostics: result.diagnostics.into_iter().map(Into::into).collect(), + } +} + +fn worker_completions_result_to_api( + result: WorkerCompletionsResult, +) -> Result { + Ok(server_api::RuntimeWorkerCompletionsResult { + runtime_id: result.worker.runtime_id, + worker_id: result.worker.worker_id, + kind: serde_json::to_value(result.kind) + .map_err(|error| invalid_contract_value("Worker completion kind", error))?, + prefix: result.prefix, + entries: result + .entries + .into_iter() + .map(|entry| { + serde_json::to_value(entry) + .map_err(|error| invalid_contract_value("Worker completion entry", error)) + }) + .collect::>>()?, + diagnostics: result.diagnostics.into_iter().map(Into::into).collect(), + }) +} + +fn runtime_config_bundle_result_to_api( + state: InternalWorkerOperationState, + availability: Option, + diagnostics: Vec, +) -> Result { + Ok(server_api::RuntimeConfigBundleResult { + state: state.into(), + availability: availability + .map(serde_json::to_value) + .transpose() + .map_err(|error| invalid_contract_value("Runtime ConfigBundle availability", error))?, + diagnostics: diagnostics.into_iter().map(Into::into).collect(), + }) +} + +impl server_api::ServerApi for ServerApiContractService { + async fn health( + &self, + ) -> std::result::Result { + Ok(server_api::HealthResponse { + status: "ok".to_string(), + }) + } + + async fn auth_config( + &self, + ) -> std::result::Result { + Ok(auth_public_config(&self.auth_api().config)) + } + + async fn auth_bootstrap_user( + &self, + request: AuthBootstrapUserRequest, + ) -> std::result::Result { + post_auth_bootstrap_user(State(self.auth_api()), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_passkey_registration_options( + &self, + context: server_api::ServerRequestContext, + mut request: PasskeyRegistrationOptionsRequest, + ) -> std::result::Result + { + if request.browser_origin.is_none() { + request.browser_origin = context.origin; + } + post_passkey_registration_options(State(self.auth_api()), HeaderMap::new(), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_passkey_registration_complete( + &self, + request: PasskeyRegistrationCompleteRequest, + ) -> std::result::Result< + server_api::server_api_responses::AuthPasskeyRegistrationComplete, + server_api::RepositoryApiError, + > { + let (body, header_set_cookie) = complete_passkey_registration(&self.auth_api(), request) + .map_err(ApiError::into_repository_api_error)?; + Ok( + server_api::server_api_responses::AuthPasskeyRegistrationComplete::Status200 { + body, + header_set_cookie, + }, + ) + } + + async fn auth_passkey_login_options( + &self, + context: server_api::ServerRequestContext, + mut request: PasskeyLoginOptionsRequest, + ) -> std::result::Result { + if request.browser_origin.is_none() { + request.browser_origin = context.origin; + } + post_passkey_login_options(State(self.auth_api()), HeaderMap::new(), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_passkey_login_complete( + &self, + request: PasskeyLoginCompleteRequest, + ) -> std::result::Result< + server_api::server_api_responses::AuthPasskeyLoginComplete, + server_api::RepositoryApiError, + > { + let (body, header_set_cookie) = complete_passkey_login(&self.auth_api(), request) + .map_err(ApiError::into_repository_api_error)?; + Ok( + server_api::server_api_responses::AuthPasskeyLoginComplete::Status200 { + body, + header_set_cookie, + }, + ) + } + + async fn auth_device_login_start( + &self, + request: DeviceLoginStartRequest, + ) -> std::result::Result { + post_device_login_start(State(self.auth_api()), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_device_login_approve( + &self, + context: server_api::ServerRequestContext, + request: DeviceLoginApproveRequest, + ) -> std::result::Result { + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::UNAUTHORIZED.as_u16(), + "Unauthorized", + "request requires a browser session or Bearer API token", + Vec::new(), + ) + })?; + approve_device_login(&self.auth_api(), actor, request) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_device_login_poll( + &self, + request: DeviceLoginPollRequest, + ) -> std::result::Result { + post_device_login_poll(State(self.auth_api()), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_whoami( + &self, + context: server_api::ServerRequestContext, + ) -> std::result::Result { + Ok(WhoamiResponse { + actor: context.actor, + }) + } + + async fn auth_logout( + &self, + cookie: Option, + ) -> std::result::Result< + server_api::server_api_responses::AuthLogout, + server_api::RepositoryApiError, + > { + let (body, header_set_cookie) = logout_browser_session(&self.auth_api(), cookie) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::server_api_responses::AuthLogout::Status200 { + body, + header_set_cookie, + }) + } + + async fn workspace_catalog_list( + &self, + context: server_api::ServerRequestContext, + query: server_api::WorkspaceListQuery, + ) -> std::result::Result { + let api = self.server_api()?; + let Some(actor) = context.actor else { + return if api + .catalog + .is_empty() + .map_err(|error| ApiError::from(error).into_repository_api_error())? + { + Ok(WorkspaceCatalogListResponse(Vec::new())) + } else { + Err(server_api::RepositoryApiError::new( + StatusCode::UNAUTHORIZED.as_u16(), + "Unauthorized", + "authentication required", + Vec::new(), + )) + }; + }; + let workspaces = api + .catalog + .list( + &actor.account_id, + query.limit.unwrap_or(100).try_into().unwrap_or(200), + ) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + Ok(WorkspaceCatalogListResponse( + workspaces.into_iter().map(workspace_summary).collect(), + )) + } + + async fn workspace_catalog_create( + &self, + context: server_api::ServerRequestContext, + request: WorkspaceCreateRequest, + ) -> std::result::Result { + let api = self.server_api()?; + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "Forbidden", + "Workspace creation requires an authenticated owner", + Vec::new(), + ) + })?; + let created = api + .catalog + .create(request, actor.account_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + api.router_for_workspace(&created.workspace.workspace_id) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + Ok(workspace_create_response(created)) + } + + async fn workspace_deletion_preflight( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result + { + let api = self.server_api()?; + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "Forbidden", + "Workspace deletion requires its owner", + Vec::new(), + ) + })?; + api.workspace_deletion_preflight(&actor.account_id, &workspace_id) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error()) + } + + async fn workspace_deletion_start( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: WorkspaceDeletionRequest, + ) -> std::result::Result + { + let api = self.server_api()?; + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "Forbidden", + "Workspace deletion requires its owner", + Vec::new(), + ) + })?; + let mutation_lock = api.mutation_lock(&workspace_id).await; + let _mutation_guard = mutation_lock.lock().await; + let existing = api + .store + .workspace_deletion_operation(&actor.account_id, &request.operation_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + if existing.is_none() { + let preflight = api + .workspace_deletion_preflight(&actor.account_id, &workspace_id) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + if !preflight.can_delete { + return Err(server_api::RepositoryApiError::new( + StatusCode::CONFLICT.as_u16(), + "Conflict", + "Workspace deletion preflight is blocked", + Vec::new(), + )); + } + } + let reservation = api + .store + .reserve_workspace_deletion(&actor.account_id, &workspace_id, &request) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + if let Some(handle) = api.hook_handles.lock().await.remove(&workspace_id) { + handle.abort(); + } + let operation = reservation.operation; + if operation.state != WorkspaceDeletionState::Succeeded { + api.schedule_workspace_deletion(request.operation_id).await; + } + Ok(operation) + } + + async fn workspace_deletion_get( + &self, + context: server_api::ServerRequestContext, + operation_id: String, + ) -> std::result::Result + { + let api = self.server_api()?; + let actor = context.actor.ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "Forbidden", + "Workspace deletion status requires its owner", + Vec::new(), + ) + })?; + api.store + .workspace_deletion_operation(&actor.account_id, &operation_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())? + .ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "Not Found", + "Workspace deletion operation not found", + Vec::new(), + ) + }) + } + + async fn workspace_current( + &self, + context: server_api::ServerRequestContext, + ) -> std::result::Result { + workspace_response(self.workspace_api()?, context.actor) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_scoped( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result { + let api = self.workspace_api()?; + validate_workspace_scope(api, &workspace_id) + .map_err(ApiError::into_repository_api_error)?; + workspace_response(api, context.actor) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_runtime_config( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::WorkspaceRuntimeConfigQuery, + if_none_match: Option, + ) -> std::result::Result< + server_api::server_api_responses::WorkspaceRuntimeConfig, + server_api::RepositoryApiError, + > { + workspace_runtime_config_result( + self.workspace_api()?, + context, + &workspace_id, + query, + if_none_match, + ) + } + + async fn workspace_metadata_settings( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_get_workspace_settings( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_metadata_settings_update( + &self, + workspace_id: String, + request: UpdateWorkspaceMetadataRequest, + ) -> std::result::Result + { + scoped_update_workspace_settings( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_signing_identity( + &self, + actor: RequestActor, + workspace_id: String, + ) -> std::result::Result { + scoped_get_workspace_signing_identity( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_signing_identity_provision( + &self, + actor: RequestActor, + workspace_id: String, + ) -> std::result::Result { + scoped_provision_workspace_signing_identity( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_memory_settings( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_get_workspace_memory_settings( + State(self.workspace_api()?.clone()), + AxumPath(workspace_id), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_memory_settings_update( + &self, + workspace_id: String, + request: server_api::UpdateWorkspaceMemorySettingsRequest, + ) -> std::result::Result + { + scoped_update_workspace_memory_settings( + State(self.workspace_api()?.clone()), + AxumPath(workspace_id), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_access_projection( + &self, + actor: RequestActor, + workspace_id: String, + ) -> std::result::Result { + scoped_get_repository_access_projection( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_credential_list( + &self, + actor: RequestActor, + workspace_id: String, + ) -> std::result::Result< + server_api::RepositorySshCredentialListResponse, + server_api::RepositoryApiError, + > { + scoped_list_repository_ssh_credentials( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + ) + .await + .map(|Json(response)| server_api::RepositorySshCredentialListResponse(response)) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_credential_create( + &self, + actor: RequestActor, + workspace_id: String, + request: CreateRepositorySshCredentialRequest, + ) -> std::result::Result { + scoped_create_repository_ssh_credential( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map(|(_status, Json(response))| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_credential_generate( + &self, + actor: RequestActor, + workspace_id: String, + request: GenerateRepositorySshCredentialRequest, + ) -> std::result::Result { + scoped_generate_repository_ssh_credential( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map(|(_status, Json(response))| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_credential_get( + &self, + actor: RequestActor, + workspace_id: String, + credential_id: String, + ) -> std::result::Result { + scoped_get_repository_ssh_credential( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryCredentialPath { + workspace_id, + credential_id, + }), + Extension(actor), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_credential_delete( + &self, + actor: RequestActor, + workspace_id: String, + credential_id: String, + request: DeleteRepositorySshCredentialRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_delete_repository_ssh_credential( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryCredentialPath { + workspace_id, + credential_id, + }), + Extension(actor), + Json(request), + ) + .await + .map(|_| ()) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_credential_public_key( + &self, + actor: RequestActor, + workspace_id: String, + credential_id: String, + ) -> std::result::Result { + scoped_get_repository_ssh_public_key( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryCredentialPath { + workspace_id, + credential_id, + }), + Extension(actor), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_credential_rotate( + &self, + actor: RequestActor, + workspace_id: String, + credential_id: String, + request: RotateRepositorySshCredentialRequest, + ) -> std::result::Result { + scoped_rotate_repository_ssh_credential( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryCredentialPath { + workspace_id, + credential_id, + }), + Extension(actor), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_host_trust_list( + &self, + actor: RequestActor, + workspace_id: String, + ) -> std::result::Result< + server_api::RepositorySshHostTrustListResponse, + server_api::RepositoryApiError, + > { + scoped_list_repository_ssh_host_trusts( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + ) + .await + .map(|Json(response)| server_api::RepositorySshHostTrustListResponse(response)) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_host_trust_put( + &self, + actor: RequestActor, + workspace_id: String, + request: PutRepositorySshHostTrustRequest, + ) -> std::result::Result< + server_api::RepositorySshHostTrustMutationResponse, + server_api::RepositoryApiError, + > { + let created = request.expected_revision.is_none(); + scoped_put_repository_ssh_host_trust( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map( + |(_status, Json(host_trust))| server_api::RepositorySshHostTrustMutationResponse { + host_trust, + created, + }, + ) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_host_trust_get( + &self, + actor: RequestActor, + workspace_id: String, + host_trust_id: String, + ) -> std::result::Result { + scoped_get_repository_ssh_host_trust( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryHostTrustPath { + workspace_id, + host_trust_id, + }), + Extension(actor), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_ssh_host_trust_delete( + &self, + actor: RequestActor, + workspace_id: String, + host_trust_id: String, + request: DeleteRepositorySshHostTrustRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_delete_repository_ssh_host_trust( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryHostTrustPath { + workspace_id, + host_trust_id, + }), + Extension(actor), + Json(request), + ) + .await + .map(|_| ()) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_config_tree( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_get_workspace_config_tree( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_prompt_projection( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_get_prompt_projection( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| prompt_projection_to_api(response)) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_config_tree_commit( + &self, + workspace_id: String, + request: server_api::ConfigCommitRequest, + ) -> std::result::Result + { + scoped_commit_workspace_config_tree( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map(|(_status, Json(response))| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_config_revision( + &self, + workspace_id: String, + revision: String, + ) -> std::result::Result { + let revision = revision.parse::().map_err(|_| { + ApiError::from(Error::InvalidInput( + "config revision must be an unsigned integer".into(), + )) + .into_repository_api_error() + })?; + scoped_get_workspace_config_revision( + State(self.workspace_api()?.clone()), + AxumPath(WorkspaceConfigRevisionPath { + workspace_id, + revision, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_config_entry( + &self, + workspace_id: String, + path: String, + ) -> std::result::Result { + scoped_get_workspace_config_entry( + State(self.workspace_api()?.clone()), + AxumPath(WorkspaceConfigEntryPath { workspace_id, path }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn profile_settings( + &self, + workspace_id: String, + ) -> std::result::Result { + scoped_get_profile_settings( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn flow_list( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_list_flows( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| { + server_api::FlowSourceListResponse( + response + .into_iter() + .map(flow_source_record_to_api) + .collect(), + ) + }) + .map_err(ApiError::into_repository_api_error) + } + + async fn flow_put( + &self, + workspace_id: String, + request: server_api::PutFlowRequest, + ) -> std::result::Result { + scoped_put_flow( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map(|Json(response)| flow_source_record_to_api(response)) + .map_err(ApiError::into_repository_api_error) + } + + async fn flow_resolve( + &self, + workspace_id: String, + request: server_api::FlowSourceResolveRequest, + ) -> std::result::Result { + let request = flow_source_resolve_request_from_api(request) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + scoped_resolve_flow_source( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map(|Json(response)| resolved_flow_source_to_api(response)) + .map_err(ApiError::into_repository_api_error) + } + + async fn flow_get( + &self, + workspace_id: String, + flow_id: String, + ) -> std::result::Result { + scoped_get_flow( + State(self.workspace_api()?.clone()), + AxumPath(ScopedFlowPath { + workspace_id, + flow_id, + }), + ) + .await + .map(|Json(response)| flow_source_record_to_api(response)) + .map_err(ApiError::into_repository_api_error) + } + + async fn memory_document( + &self, + workspace_id: String, + ) -> std::result::Result { + scoped_get_memory_document( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn memory_staging_list( + &self, + workspace_id: String, + query: server_api::MemoryStagingQuery, + ) -> std::result::Result { + scoped_list_memory_staging( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Query(query), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn memory_backend( + &self, + workspace_id: String, + request: server_api::MemoryBackendRequest, + ) -> std::result::Result + { + scoped_memory_backend_operation( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request.0), + ) + .await + .map(|Json(response)| server_api::MemoryBackendResponse(response)) + .map_err(ApiError::into_repository_api_error) + } + + async fn memory_consolidation( + &self, + workspace_id: String, + request: server_api::MemoryConsolidateStagingRequest, + ) -> std::result::Result + { + scoped_memory_consolidation( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(MemoryConsolidateStagingOperation { + force: request.force, + }), + ) + .await + .map(|Json(response)| server_api::MemoryConsolidationResponse { + status: response.status, + summary: response.summary, + candidate_count: response.candidate_count, + total_bytes: response.total_bytes, + }) + .map_err(ApiError::into_repository_api_error) + } + + async fn skill_list( + &self, + workspace_id: String, + ) -> std::result::Result { + scoped_list_skills( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn skill_lint( + &self, + workspace_id: String, + ) -> std::result::Result { + scoped_lint_skills( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn skill_get( + &self, + workspace_id: String, + name: String, + ) -> std::result::Result { + scoped_get_skill( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSkillPath { workspace_id, name }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn skill_activate( + &self, + workspace_id: String, + name: String, + ) -> std::result::Result + { + scoped_activate_skill( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSkillPath { workspace_id, name }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn worker_session( &self, workspace_id: String, @@ -3792,7 +5881,14 @@ impl server_api::ServerApi for WorkspaceApi { worker_id: String, ) -> std::result::Result { - if workspace_id != self.config.workspace_id { + let api = self.workspace_api().map_err(|_| { + runtime_api::RuntimeApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "workspace_not_found", + "Workspace was not found", + ) + })?; + if workspace_id != api.config.workspace_id { return Err(runtime_api::RuntimeApiError::new( StatusCode::NOT_FOUND.as_u16(), "workspace_not_found", @@ -3800,7 +5896,7 @@ impl server_api::ServerApi for WorkspaceApi { )); } let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); - let observation = self + let observation = api .runtime .worker_session(&workspace_id, &worker) .map_err(|_error| { @@ -3819,13 +5915,40 @@ impl server_api::ServerApi for WorkspaceApi { }) } - async fn repository_list( + async fn workspace_worker_list_alias( + &self, + ) -> std::result::Result { + let Json(response) = list_workers(State(self.workspace_api()?.clone())) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn workspace_worker_create_alias( + &self, + context: server_api::ServerRequestContext, + request: server_api::CreateWorkspaceWorkerRequest, + ) -> std::result::Result + { + create_workspace_worker( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_orchestrator_status( &self, workspace_id: String, - ) -> std::result::Result - { - scoped_list_repositories( - State(self.clone()), + ) -> std::result::Result< + server_api::BrowserWorkspaceOrchestratorResponse, + server_api::RepositoryApiError, + > { + scoped_workspace_orchestrator_status( + State(self.workspace_api()?.clone()), AxumPath(ScopedWorkspacePath { workspace_id }), ) .await @@ -3833,11 +5956,1234 @@ impl server_api::ServerApi for WorkspaceApi { .map_err(ApiError::into_repository_api_error) } + async fn workspace_orchestrator_start( + &self, + workspace_id: String, + ) -> std::result::Result< + server_api::BrowserWorkspaceOrchestratorResponse, + server_api::RepositoryApiError, + > { + scoped_start_workspace_orchestrator( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn worker_control_list( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result + { + let Json(response) = list_known_workers( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn worker_control_spawn( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::CreateWorkspaceWorkerRequest, + ) -> std::result::Result + { + spawn_known_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn worker_control_input( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerInputRequest, + ) -> std::result::Result + { + let Json(response) = send_known_worker_input( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn worker_control_cancel( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerLifecycleRequest, + ) -> std::result::Result + { + let Json(response) = cancel_known_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn worker_control_stop( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerLifecycleRequest, + ) -> std::result::Result + { + let Json(response) = stop_known_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn worker_control_restore( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + runtime_id: String, + worker_id: String, + ) -> std::result::Result + { + restore_known_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + contract_request_headers(&context)?, + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn worker_observation_sessions( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result< + server_api::WorkerObservationSessionsResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_list_worker_observation_sessions( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn worker_observation_capture( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkerObservationSubjectRef, + ) -> std::result::Result< + server_api::WorkerObservationCaptureResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_capture_worker_observation_session( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn workspace_worker_discovery( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::WorkspaceWorkerDiscoveryQuery, + ) -> std::result::Result + { + discover_workspace_workers( + self.workspace_api()?.clone(), + workspace_id, + query, + context.runtime_source.is_some(), + ) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_worker_list( + &self, + workspace_id: String, + ) -> std::result::Result { + let Json(response) = scoped_list_workers( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn workspace_worker_create( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::CreateWorkspaceWorkerRequest, + ) -> std::result::Result + { + scoped_create_workspace_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_worker_detail( + &self, + workspace_id: String, + worker_ref: String, + ) -> std::result::Result { + scoped_get_workspace_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspaceWorkerReferencePath { + workspace_id, + worker_ref, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_worker_launch_options_alias( + &self, + ) -> std::result::Result + { + get_worker_launch_options(State(self.workspace_api()?.clone())) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_worker_launch_options( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_get_worker_launch_options( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_resource_fetch( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::RuntimeResourceFetchRequest, + ) -> std::result::Result< + server_api::RuntimeResourceFetchResponse, + server_api::RuntimeResourceFetchApiError, + > { + runtime_resource_fetch_contract( + self.workspace_api() + .map_err(|_| server_api::RuntimeResourceFetchApiError::MissingResource)?, + context, + workspace_id, + request, + ) + .await + } + + async fn companion_status_alias( + &self, + ) -> std::result::Result + { + get_companion_status(State(self.workspace_api()?.clone())) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn companion_status( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_get_companion_status( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn companion_transcript_alias( + &self, + query: server_api::CompanionTranscriptQuery, + ) -> std::result::Result< + server_api::CompanionTranscriptProjection, + server_api::RepositoryApiError, + > { + get_companion_transcript( + State(self.workspace_api()?.clone()), + Query(TranscriptQuery { + start: query.start, + limit: query.limit, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn companion_transcript( + &self, + workspace_id: String, + query: server_api::CompanionTranscriptQuery, + ) -> std::result::Result< + server_api::CompanionTranscriptProjection, + server_api::RepositoryApiError, + > { + scoped_get_companion_transcript( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Query(TranscriptQuery { + start: query.start, + limit: query.limit, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn companion_message_alias( + &self, + request: server_api::CompanionMessageRequest, + ) -> std::result::Result + { + post_companion_message(State(self.workspace_api()?.clone()), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn companion_message( + &self, + workspace_id: String, + request: server_api::CompanionMessageRequest, + ) -> std::result::Result + { + scoped_post_companion_message( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn companion_cancel_alias( + &self, + request: server_api::CompanionCancelRequest, + ) -> std::result::Result + { + post_companion_cancel(State(self.workspace_api()?.clone()), Json(request)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn companion_cancel( + &self, + workspace_id: String, + request: server_api::CompanionCancelRequest, + ) -> std::result::Result + { + scoped_post_companion_cancel( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn workspace_worker_remove( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkerRemoveRequest, + ) -> std::result::Result + { + worker_remove_contract( + self.workspace_api() + .map_err(|error| worker_remove_repository_error(error))?, + context, + workspace_id, + request, + ) + .await + } + + async fn runtime_list_alias( + &self, + ) -> std::result::Result { + let Json(response) = list_runtimes(State(self.workspace_api()?.clone())) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_list( + &self, + workspace_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_list_runtimes( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_create( + &self, + actor: RequestActor, + workspace_id: String, + request: server_api::CreateRemoteRuntimeRequest, + ) -> std::result::Result + { + let (status, Json(mut response)) = scoped_create_remote_runtime( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + response.http_status = status.as_u16(); + Ok(response) + } + + async fn runtime_detail( + &self, + workspace_id: String, + runtime_id: String, + ) -> std::result::Result + { + scoped_get_runtime_detail( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_update( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + request: server_api::UpdateRemoteRuntimeRequest, + ) -> std::result::Result + { + scoped_update_remote_runtime( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Extension(actor), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_remove( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + request: server_api::RemoveRuntimeRequest, + ) -> std::result::Result< + server_api::RuntimeRemovalOperationResponse, + server_api::RepositoryApiError, + > { + scoped_remove_remote_runtime( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Extension(actor), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_trust_key_reveal( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + ) -> std::result::Result< + server_api::RuntimeTrustKeyRevealResponse, + server_api::RepositoryApiError, + > { + scoped_reveal_runtime_trust_key( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Extension(actor), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_trust_key_revoke( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + request: server_api::RevokeRuntimeTrustKeyRequest, + ) -> std::result::Result< + server_api::WorkspaceRuntimeDetail, + server_api::RuntimeManagementApiError, + > { + let api = self + .workspace_api() + .map_err(server_api::RuntimeManagementApiError::from)? + .clone(); + let map_error = |error: ApiError| { + server_api::RuntimeManagementApiError::from(error.into_repository_api_error()) + }; + validate_workspace_scope(&api, &workspace_id).map_err(map_error)?; + require_workspace_owner(&api, &workspace_id, &actor, "Runtime trust changes") + .await + .map_err(map_error)?; + if runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(map_error(settings_bad_request( + "embedded_runtime_trust_managed_internally", + "the embedded Runtime trust key is managed by the embedded Runtime authority", + ))); + } + if request.expected_revision == 0 { + return Err(map_error(settings_bad_request( + "invalid_runtime_binding_revision", + "expected_revision must be greater than zero", + ))); + } + let now = Utc::now().to_rfc3339(); + if let Err(error) = api + .store + .revoke_workspace_runtime_binding_key( + &workspace_id, + &runtime_id, + request.expected_revision, + &actor.account_id, + &now, + ) + .await + { + let kind = match error { + Error::RuntimeBindingRevisionConflict { .. } => { + Some(server_api::RuntimeTrustConflictKind::StaleRevision) + } + Error::RuntimeBindingFingerprintConflict { .. } => { + Some(server_api::RuntimeTrustConflictKind::FingerprintInUse) + } + _ => None, + }; + if let Some(kind) = kind { + let current = api + .store + .get_workspace_runtime_binding(&workspace_id, &runtime_id) + .await + .map_err(|error| map_error(ApiError::from(error)))?; + return Err(server_api::RuntimeManagementApiError::trust_conflict( + server_api::RuntimeTrustConflictResponse { + error: kind, + message: match kind { + server_api::RuntimeTrustConflictKind::StaleRevision => { + "the Runtime trust binding changed; reload before retrying".to_string() + } + server_api::RuntimeTrustConflictKind::FingerprintInUse => { + "the public key is already bound to another Runtime in this Workspace".to_string() + } + }, + current_revision: current.as_ref().map(|binding| binding.binding_revision), + current_fingerprint: current + .as_ref() + .map(|binding| binding.public_key_fingerprint.clone()), + }, + )); + } + return Err(map_error(ApiError::from(error))); + } + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(workspace_id.clone(), runtime_id.clone())); + api.runtime_subscription_broker + .unregister_runtime(&runtime_id); + workspace_runtime_detail(&api, &workspace_id, &runtime_id) + .await + .map_err(map_error) + } + + async fn runtime_connection_test( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + ) -> std::result::Result< + server_api::RuntimeConnectionTestResponse, + server_api::RepositoryApiError, + > { + scoped_test_runtime_connection( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Extension(actor), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_worker_list_alias( + &self, + runtime_id: String, + query: server_api::RuntimeWorkersQuery, + ) -> std::result::Result + { + let Json(response) = list_runtime_workers( + State(self.workspace_api()?.clone()), + AxumPath(runtime_id), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_create_alias( + &self, + runtime_id: String, + request: server_api::RuntimeWorkerSpawnRequest, + ) -> std::result::Result + { + let Json(response) = create_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath(runtime_id), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_list( + &self, + workspace_id: String, + runtime_id: String, + query: server_api::RuntimeWorkersQuery, + ) -> std::result::Result + { + let Json(response) = scoped_list_runtime_workers( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_create( + &self, + workspace_id: String, + runtime_id: String, + request: server_api::RuntimeWorkerSpawnRequest, + ) -> std::result::Result + { + let Json(response) = scoped_create_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_config_bundle_sync_alias( + &self, + runtime_id: String, + request: server_api::RuntimeConfigBundleSyncRequest, + ) -> std::result::Result + { + let Json(response) = sync_runtime_config_bundle( + State(self.workspace_api()?.clone()), + AxumPath(runtime_id), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_config_bundle_sync( + &self, + workspace_id: String, + runtime_id: String, + request: server_api::RuntimeConfigBundleSyncRequest, + ) -> std::result::Result + { + let Json(response) = scoped_sync_runtime_config_bundle( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_config_bundle_availability_alias( + &self, + runtime_id: String, + bundle_id: String, + query: server_api::RuntimeConfigBundleAvailabilityQuery, + ) -> std::result::Result + { + let Json(response) = check_runtime_config_bundle( + State(self.workspace_api()?.clone()), + AxumPath((runtime_id, bundle_id)), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_config_bundle_availability( + &self, + workspace_id: String, + runtime_id: String, + bundle_id: String, + query: server_api::RuntimeConfigBundleAvailabilityQuery, + ) -> std::result::Result + { + let Json(response) = scoped_check_runtime_config_bundle( + State(self.workspace_api()?.clone()), + AxumPath(ScopedConfigBundlePath { + workspace_id, + runtime_id, + bundle_id, + }), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_detail_alias( + &self, + runtime_id: String, + worker_id: String, + ) -> std::result::Result + { + let Json(response) = get_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath((runtime_id, worker_id)), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_detail( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_get_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_restore_alias( + &self, + runtime_id: String, + worker_id: String, + ) -> std::result::Result + { + restore_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath((runtime_id, worker_id)), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_worker_restore( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + query: server_api::RestoreTicketAssignmentQuery, + ) -> std::result::Result + { + scoped_restore_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + Query(query), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_worker_pin( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_pin_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_unpin( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_unpin_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_cleanup_plan( + &self, + workspace_id: String, + runtime_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_runtime_cleanup_plan( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_cleanup_execute( + &self, + workspace_id: String, + runtime_id: String, + request: server_api::ExecuteRuntimeCleanupRequest, + ) -> std::result::Result< + server_api::RuntimeCleanupExecutionResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_execute_runtime_cleanup( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_input_alias( + &self, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerInputRequest, + ) -> std::result::Result + { + let Json(response) = send_runtime_worker_input( + State(self.workspace_api()?.clone()), + AxumPath((runtime_id, worker_id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_input( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerInputRequest, + ) -> std::result::Result + { + let Json(response) = scoped_send_runtime_worker_input( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_attachment_upload_grant( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + worker_id: String, + query: server_api::WorkerFileUploadQuery, + ) -> std::result::Result< + server_api::AttachmentUploadGrantResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_create_attachment_upload_grant( + State(self.workspace_api()?.clone()), + Extension(actor), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_attachment_upload( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + worker_id: String, + upload_id: String, + body: server_api::BinaryBody, + ) -> std::result::Result { + upload_runtime_worker_file_contract( + self.workspace_api()?.clone(), + actor, + ScopedAttachmentUploadPath { + workspace_id, + runtime_id, + worker_id, + upload_id, + }, + body.as_ref(), + ) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn runtime_worker_attachment_upload_cancel( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + worker_id: String, + upload_id: String, + ) -> std::result::Result< + server_api::AttachmentUploadCancelResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_cancel_attachment_upload( + State(self.workspace_api()?.clone()), + Extension(actor), + AxumPath(ScopedAttachmentUploadPath { + workspace_id, + runtime_id, + worker_id, + upload_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_attachment_delete( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + artifact_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_delete_runtime_worker_uploaded_file( + State(self.workspace_api()?.clone()), + AxumPath(( + ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }, + artifact_id, + )), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_completions_alias( + &self, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerCompletionsRequest, + ) -> std::result::Result< + server_api::RuntimeWorkerCompletionsResult, + server_api::RepositoryApiError, + > { + let Json(response) = runtime_worker_completions( + State(self.workspace_api()?.clone()), + AxumPath((runtime_id, worker_id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_completions( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerCompletionsRequest, + ) -> std::result::Result< + server_api::RuntimeWorkerCompletionsResult, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_runtime_worker_completions( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_stop_alias( + &self, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerLifecycleRequest, + ) -> std::result::Result + { + let Json(response) = stop_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath((runtime_id, worker_id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_stop( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerLifecycleRequest, + ) -> std::result::Result + { + let Json(response) = scoped_stop_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_cancel_alias( + &self, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerLifecycleRequest, + ) -> std::result::Result + { + let Json(response) = cancel_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath((runtime_id, worker_id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_worker_cancel( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + request: server_api::RuntimeWorkerLifecycleRequest, + ) -> std::result::Result + { + let Json(response) = scoped_cancel_runtime_worker( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkerPath { + workspace_id, + worker: RuntimeWorkerRef::new(runtime_id, worker_id), + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn repository_list( + &self, + workspace_id: String, + ) -> std::result::Result + { + let api = self.workspace_api()?.clone(); + scoped_list_repositories(State(api), 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())) + list_repositories(State(self.workspace_api()?.clone())) .await .map(|Json(response)| response) .map_err(ApiError::into_repository_api_error) @@ -3849,8 +7195,9 @@ impl server_api::ServerApi for WorkspaceApi { repository_key: String, ) -> std::result::Result { + let api = self.workspace_api()?.clone(); scoped_repository_detail( - State(self.clone()), + State(api), AxumPath(ScopedRepositoryPath { workspace_id, repository_key, @@ -3866,10 +7213,13 @@ impl server_api::ServerApi for WorkspaceApi { 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) + repository_detail( + State(self.workspace_api()?.clone()), + AxumPath(repository_key), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) } async fn repository_create( @@ -3880,7 +7230,7 @@ impl server_api::ServerApi for WorkspaceApi { ) -> std::result::Result { scoped_create_repository( - State(self.clone()), + State(self.workspace_api()?.clone()), AxumPath(ScopedWorkspacePath { workspace_id }), Extension(actor), Json(request), @@ -3889,611 +7239,1485 @@ impl server_api::ServerApi for WorkspaceApi { .map(|(_status, Json(response))| response) .map_err(ApiError::into_repository_api_error) } + + async fn ticket_list_alias( + &self, + query: server_api::TicketListHttpQuery, + ) -> std::result::Result { + let query = TicketListQuery { + states: query.states, + limit: query.limit, + cursor: query.cursor, + }; + let Json(response) = list_tickets(State(self.workspace_api()?.clone()), Query(query)) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_list( + &self, + workspace_id: String, + query: server_api::TicketListHttpQuery, + ) -> std::result::Result { + let query = TicketListQuery { + states: query.states, + limit: query.limit, + cursor: query.cursor, + }; + let Json(response) = scoped_list_tickets( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_create_record( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::CreateTicketRecordRequest, + ) -> std::result::Result { + let Json(response) = scoped_create_ticket_record( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRecordRef(response)) + } + + async fn ticket_query( + &self, + workspace_id: String, + request: server_api::TicketQueryRequest, + ) -> std::result::Result { + let Json(response) = scoped_query_tickets( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_get_alias( + &self, + id: String, + ) -> std::result::Result { + let Json(response) = get_ticket(State(self.workspace_api()?.clone()), AxumPath(id)) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_default_intake_ready_body( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::DefaultIntakeReadyBodyRequest, + ) -> std::result::Result { + let Json(response) = scoped_default_intake_ready_body( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Json(InternalDefaultIntakeReadyBodyRequest { from: request.from }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TextResponse(response)) + } + + async fn ticket_summary_search( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::TicketSummarySearchQuery, + ) -> std::result::Result + { + let Json(response) = scoped_list_ticket_summaries( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRecordSummaryList(response)) + } + + async fn ticket_doctor( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result { + let Json(response) = scoped_ticket_doctor( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketDoctorResponse(response)) + } + + async fn ticket_relation_query( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::TicketRelationSearchRequest, + ) -> std::result::Result + { + let Json(response) = scoped_query_ticket_relations( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRelationRecordList(response)) + } + + async fn ticket_orchestration_plan_query( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::TicketOrchestrationPlanSearchRequest, + ) -> std::result::Result< + server_api::TicketOrchestrationPlanRecordList, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_query_ticket_orchestration_plans( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketOrchestrationPlanRecordList(response)) + } + + async fn ticket_record_get( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + ) -> std::result::Result { + let Json(response) = scoped_get_ticket_record( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRecord(response)) + } + + async fn ticket_record_item_edit( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::EditTicketRecordItemRequest, + ) -> std::result::Result { + let Json(response) = scoped_edit_ticket_record_item( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRecord(response)) + } + + async fn ticket_dependency_check( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + ) -> std::result::Result< + server_api::TicketDependencyCheckResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_ticket_dependency_check( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketDependencyCheckResponse(response)) + } + + async fn ticket_thread_event_add( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::TicketThreadEventRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_add_ticket_thread_event( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(()) + } + + async fn ticket_state_change_add( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::TicketStateChangeRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_add_ticket_state_change( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(()) + } + + async fn ticket_intake_summary_add( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::TicketIntakeSummaryRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_add_ticket_intake_summary( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(()) + } + + async fn ticket_state_field_set( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + field: String, + request: server_api::TicketStateChangeRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_set_ticket_state_field( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id, field)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(()) + } + + async fn ticket_workflow_state_set( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::TicketStateChangeRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_set_ticket_workflow_state( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(()) + } + + async fn ticket_mark_ready_record( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::TicketMarkReadyRequest, + ) -> std::result::Result { + let Json(response) = scoped_mark_ticket_ready( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(InternalTicketMarkReadyRequest { + operation_key: request.operation_key, + reason: request.reason, + intake_summary: request.intake_summary, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRecord(response)) + } + + async fn ticket_queue_record( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + ) -> std::result::Result { + let Json(response) = scoped_queue_ticket_record( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketQueueResponse(response)) + } + + async fn merge_request_list( + &self, + workspace_id: String, + query: server_api::MergeRequestListQuery, + ) -> std::result::Result + { + let Json(response) = scoped_list_merge_requests( + State(self.workspace_api()?.clone()), + AxumPath(workspace_id), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn merge_request_show( + &self, + workspace_id: String, + merge_request_id: String, + query: server_api::MergeRequestThreadQuery, + ) -> std::result::Result + { + let Json(response) = scoped_show_merge_request( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, merge_request_id)), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn merge_request_open( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::OpenMergeRequestRequest, + ) -> std::result::Result { + let Json(response) = scoped_open_merge_request( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + AxumPath((workspace_id, id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn merge_request_readiness( + &self, + workspace_id: String, + id: String, + ) -> std::result::Result< + server_api::MergeRequestReadinessResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_merge_request_readiness( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn merge_request_thread( + &self, + workspace_id: String, + id: String, + query: server_api::MergeRequestThreadQuery, + ) -> std::result::Result + { + let Json(response) = scoped_merge_request_thread( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::MergeRequestThreadResponse(response)) + } + + async fn merge_request_selector_repair( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::RepairMergeRequestSelectorRequest, + ) -> std::result::Result { + let Json(response) = scoped_repair_merge_request_selector( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + AxumPath((workspace_id, id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn merge_request_reviewer_child_register( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::RegisterReviewerChildSessionRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_register_reviewer_child_session( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + AxumPath(workspace_id), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(()) + } + + async fn merge_request_review_capability_register( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::RegisterMergeRequestReviewCapabilityRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_register_merge_request_review_capability( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + AxumPath((workspace_id, id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(()) + } + + async fn merge_request_review_submit( + &self, + workspace_id: String, + id: String, + request: server_api::SubmitMergeRequestReviewRequest, + ) -> std::result::Result { + let Json(response) = scoped_submit_merge_request_review( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn merge_request_review_revoke( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::RevokeMergeRequestReviewRequest, + ) -> std::result::Result { + let Json(response) = scoped_revoke_merge_request_review( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + AxumPath((workspace_id, id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn merge_request_complete( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::CompleteMergeRequestRequest, + ) -> std::result::Result { + let Json(response) = scoped_complete_merge_request( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + AxumPath((workspace_id, id)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_close_record( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::TicketCloseRecordRequest, + ) -> std::result::Result<(), server_api::RepositoryApiError> { + scoped_close_ticket_record( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(()) + } + + async fn ticket_relation_view( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + ) -> std::result::Result + { + let Json(response) = scoped_ticket_relation_view( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRelationRecordView(response)) + } + + async fn ticket_relation_record( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::CreateTicketRelationRequest, + ) -> std::result::Result { + let Json(response) = scoped_record_ticket_relation( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRelationRecord(response)) + } + + async fn ticket_relation_remove( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::TicketRelationRemoveRequest, + ) -> std::result::Result { + let Json(response) = scoped_remove_ticket_relation( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketRelationRecord(response)) + } + + async fn ticket_orchestration_plan_record( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::CreateTicketOrchestrationPlanRequest, + ) -> std::result::Result< + server_api::TicketOrchestrationPlanRecord, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_record_ticket_orchestration_plan( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id)), + contract_request_headers(&context)?, + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketOrchestrationPlanRecord(response)) + } + + async fn ticket_get( + &self, + workspace_id: String, + id: String, + ) -> std::result::Result { + let Json(response) = scoped_get_ticket( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_edit( + &self, + workspace_id: String, + id: String, + request: server_api::BrowserEditTicketRequest, + ) -> std::result::Result { + let Json(response) = scoped_edit_ticket_item( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_show( + &self, + workspace_id: String, + id: String, + request: server_api::TicketShowRequest, + ) -> std::result::Result { + let Json(response) = scoped_show_ticket( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_assignment_list( + &self, + workspace_id: String, + id: String, + ) -> std::result::Result< + server_api::TicketRoleAssignmentsResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_list_ticket_assignments( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_assignment_set( + &self, + workspace_id: String, + id: String, + role: String, + request: server_api::SetTicketRoleAssignmentRequest, + ) -> std::result::Result< + server_api::TicketRoleAssignmentMutationResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_set_ticket_assignment( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id, role)), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_assignment_clear( + &self, + workspace_id: String, + id: String, + role: String, + query: server_api::ClearTicketRoleAssignmentQuery, + ) -> std::result::Result< + server_api::TicketRoleAssignmentMutationResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_clear_ticket_assignment( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, id, role)), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_implementation_cancel( + &self, + workspace_id: String, + id: String, + request: server_api::CancelTicketImplementationRequest, + ) -> std::result::Result { + let Json(response) = scoped_cancel_ticket_implementation( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_state_transition( + &self, + workspace_id: String, + id: String, + request: server_api::BrowserTransitionTicketStateRequest, + ) -> std::result::Result { + let Json(response) = scoped_transition_ticket_state( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_ready( + &self, + workspace_id: String, + id: String, + request: server_api::TicketMarkReadyRequest, + ) -> std::result::Result { + let request = InternalTicketMarkReadyRequest { + operation_key: request.operation_key, + reason: request.reason, + intake_summary: request.intake_summary, + }; + let Json(response) = scoped_mark_ticket_ready_from_browser( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_event_append( + &self, + workspace_id: String, + id: String, + request: server_api::BrowserAppendTicketEventRequest, + ) -> std::result::Result { + let Json(response) = scoped_append_ticket_event( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn ticket_queue( + &self, + workspace_id: String, + id: String, + request: server_api::BrowserQueueTicketRequest, + ) -> std::result::Result { + let Json(response) = scoped_queue_ticket( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketQueueResponse(response)) + } + + async fn ticket_close( + &self, + workspace_id: String, + id: String, + request: server_api::BrowserCloseTicketRequest, + ) -> std::result::Result { + let Json(response) = scoped_close_ticket( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRecordPath { workspace_id, id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_list_alias( + &self, + query: server_api::ObjectiveListQuery, + ) -> std::result::Result + { + let Json(response) = list_objectives(State(self.workspace_api()?.clone()), Query(query)) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_list( + &self, + workspace_id: String, + query: server_api::ObjectiveListQuery, + ) -> std::result::Result + { + let Json(response) = scoped_list_objectives( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_create( + &self, + workspace_id: String, + request: server_api::ObjectiveCreateRequest, + ) -> std::result::Result { + let Json(response) = scoped_create_objective( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_query( + &self, + workspace_id: String, + request: server_api::ObjectiveQueryRequest, + ) -> std::result::Result + { + let Json(response) = scoped_query_objectives( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_get_alias( + &self, + id: String, + ) -> std::result::Result { + let Json(response) = get_objective(State(self.workspace_api()?.clone()), AxumPath(id)) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_get( + &self, + workspace_id: String, + objective_id: String, + ) -> std::result::Result { + let Json(response) = scoped_get_objective( + State(self.workspace_api()?.clone()), + AxumPath(ScopedObjectivePath { + workspace_id, + objective_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_edit( + &self, + workspace_id: String, + objective_id: String, + request: server_api::ObjectiveEditRequest, + ) -> std::result::Result { + let Json(response) = scoped_edit_objective( + State(self.workspace_api()?.clone()), + AxumPath(ScopedObjectivePath { + workspace_id, + objective_id, + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_show( + &self, + workspace_id: String, + objective_id: String, + request: server_api::ObjectiveShowRequest, + ) -> std::result::Result { + let Json(response) = scoped_show_objective( + State(self.workspace_api()?.clone()), + AxumPath(ScopedObjectivePath { + workspace_id, + objective_id, + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_state_set( + &self, + workspace_id: String, + objective_id: String, + request: server_api::ObjectiveStateRequest, + ) -> std::result::Result { + let Json(response) = scoped_set_objective_state( + State(self.workspace_api()?.clone()), + AxumPath(ScopedObjectivePath { + workspace_id, + objective_id, + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_ticket_link( + &self, + workspace_id: String, + objective_id: String, + request: server_api::ObjectiveLinkTicketRequest, + ) -> std::result::Result { + let Json(response) = scoped_link_objective_ticket( + State(self.workspace_api()?.clone()), + AxumPath(ScopedObjectivePath { + workspace_id, + objective_id, + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn objective_ticket_unlink( + &self, + workspace_id: String, + objective_id: String, + ticket_id: String, + ) -> std::result::Result { + let Json(response) = scoped_unlink_objective_ticket( + State(self.workspace_api()?.clone()), + AxumPath(ScopedObjectiveTicketPath { + workspace_id, + objective_id, + ticket_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn repository_ssh_connection_probe( + &self, + actor: RequestActor, + workspace_id: String, + repository_key: String, + request: server_api::RepositorySshConnectionProbeRequest, + ) -> std::result::Result< + server_api::RepositorySshConnectionProbeResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_probe_repository_ssh_connection( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryPath { + workspace_id, + repository_key, + }), + Extension(actor), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn repository_ssh_host_trust_confirm( + &self, + actor: RequestActor, + workspace_id: String, + repository_key: String, + request: server_api::ConfirmRepositorySshHostTrustRequest, + ) -> std::result::Result + { + let Json(response) = scoped_confirm_repository_ssh_host_trust( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryPath { + workspace_id, + repository_key, + }), + Extension(actor), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn repository_log( + &self, + workspace_id: String, + repository_key: String, + query: server_api::RepositoryLogQuery, + ) -> std::result::Result + { + let Json(response) = scoped_repository_log( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRepositoryPath { + workspace_id, + repository_key, + }), + Query(LogQuery { limit: query.limit }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn repository_log_alias( + &self, + repository_key: String, + query: server_api::RepositoryLogQuery, + ) -> std::result::Result + { + let Json(response) = repository_log( + State(self.workspace_api()?.clone()), + AxumPath(repository_key), + Query(LogQuery { limit: query.limit }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn host_list( + &self, + workspace_id: String, + ) -> std::result::Result { + let Json(response) = scoped_list_hosts( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::HostListResponse { + workspace_id: response.workspace_id, + limit: response.limit, + items: response.items.into_iter().map(Into::into).collect(), + source: response.source, + diagnostics: response.diagnostics.into_iter().map(Into::into).collect(), + }) + } + + async fn host_list_alias( + &self, + ) -> std::result::Result { + let Json(response) = list_hosts(State(self.workspace_api()?.clone())) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::HostListResponse { + workspace_id: response.workspace_id, + limit: response.limit, + items: response.items.into_iter().map(Into::into).collect(), + source: response.source, + diagnostics: response.diagnostics.into_iter().map(Into::into).collect(), + }) + } + + async fn host_worker_list( + &self, + workspace_id: String, + host_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_list_host_workers( + State(self.workspace_api()?.clone()), + AxumPath(ScopedHostPath { + workspace_id, + host_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn host_worker_list_alias( + &self, + host_id: String, + ) -> std::result::Result + { + let Json(response) = + list_host_workers(State(self.workspace_api()?.clone()), AxumPath(host_id)) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_workdir_list( + &self, + workspace_id: String, + runtime_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_list_runtime_working_directories( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_workdir_create( + &self, + workspace_id: String, + runtime_id: String, + request: server_api::WorkingDirectoryCreateRequest, + ) -> std::result::Result< + server_api::WorkingDirectoryCreateResponse, + server_api::RepositoryApiError, + > { + let (_, Json(response)) = scoped_create_runtime_working_directory( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimePath { + workspace_id, + runtime_id, + }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_workdir_detail( + &self, + workspace_id: String, + runtime_id: String, + working_directory_id: String, + ) -> std::result::Result< + server_api::WorkingDirectoryDetailResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_runtime_working_directory_detail( + State(self.workspace_api()?.clone()), + AxumPath(ScopedRuntimeWorkingDirectoryPath { + workspace_id, + runtime_id, + working_directory_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn runtime_workdir_cleanup( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + runtime_id: String, + working_directory_id: String, + request: server_api::WorkingDirectoryRemovalRequest, + ) -> std::result::Result< + server_api::WorkingDirectoryRemovalResponse, + server_api::RepositoryApiError, + > { + cleanup_runtime_working_directory_contract( + self.workspace_api()?.clone(), + workspace_id, + runtime_id, + working_directory_id, + context, + request, + ) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn current_worker_workdir_attach( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::CurrentWorkerWorkdirAttachRequest, + ) -> std::result::Result< + server_api::CurrentWorkerWorkdirAttachmentResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_attach_current_worker_workdir( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context)?, + Json(AttachCurrentWorkerWorkdirRequest { + alias: request.alias, + working_directory_id: request.working_directory_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn current_worker_workdir_detach( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + alias: String, + ) -> std::result::Result< + server_api::CurrentWorkerWorkdirAttachmentResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_detach_current_worker_workdir( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, alias)), + contract_request_headers(&context)?, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn current_worker_workdir_operation( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::CurrentWorkerWorkdirOperationRequest, + ) -> std::result::Result< + server_api::CurrentWorkerWorkdirOperationResponse, + server_api::WorkdirOperationApiError, + > { + let Json(response) = scoped_execute_current_worker_workdir_operation( + State( + self.workspace_api() + .map_err(server_api::WorkdirOperationApiError::api)? + .clone(), + ), + AxumPath(ScopedWorkspacePath { workspace_id }), + contract_request_headers(&context) + .map_err(server_api::WorkdirOperationApiError::api)?, + Json(WorkspaceWorkdirSessionOperationRequest { + target_workdir: request.target_workdir, + operation: request.operation, + }), + ) + .await + .map_err(|error| match error { + InternalWorkdirOperationError::Api(error) => { + server_api::WorkdirOperationApiError::api(error.into_repository_api_error()) + } + InternalWorkdirOperationError::Provider(error) => { + server_api::WorkdirOperationApiError::provider(error) + } + })?; + Ok(server_api::CurrentWorkerWorkdirOperationResponse(response)) + } + + async fn external_workdir_grant_create( + &self, + actor: RequestActor, + workspace_id: String, + request: server_api::ExternalWorkdirGrantCreateRequest, + ) -> std::result::Result + { + let (_, Json(response)) = scoped_create_external_workdir_grant( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn external_workdir_grant_get( + &self, + actor: RequestActor, + workspace_id: String, + grant_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_get_external_workdir_grant( + State(self.workspace_api()?.clone()), + Extension(actor), + AxumPath((workspace_id, grant_id)), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn external_workdir_grant_revoke( + &self, + actor: RequestActor, + workspace_id: String, + grant_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_revoke_external_workdir_grant( + State(self.workspace_api()?.clone()), + Extension(actor), + AxumPath((workspace_id, grant_id)), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn workspace_workdir_list( + &self, + workspace_id: String, + ) -> std::result::Result + { + let Json(response) = scoped_list_working_directories( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn workspace_workdir_create( + &self, + workspace_id: String, + request: server_api::WorkingDirectoryCreateRequest, + ) -> std::result::Result< + server_api::WorkingDirectoryCreateResponse, + server_api::RepositoryApiError, + > { + let (_, Json(response)) = scoped_create_working_directory( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Json(request), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn workspace_workdir_detail( + &self, + workspace_id: String, + working_directory_id: String, + ) -> std::result::Result< + server_api::WorkingDirectoryDetailResponse, + server_api::RepositoryApiError, + > { + let Json(response) = scoped_working_directory_detail( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkingDirectoryPath { + workspace_id, + working_directory_id, + }), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(response) + } + + async fn workspace_workdir_cleanup( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + working_directory_id: String, + request: server_api::WorkingDirectoryRemovalRequest, + ) -> std::result::Result< + server_api::WorkingDirectoryRemovalResponse, + server_api::RepositoryApiError, + > { + cleanup_working_directory_contract( + self.workspace_api()?.clone(), + workspace_id, + working_directory_id, + context, + request, + ) + .await + .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 = - format!("/api/w/{{workspace_id}}{TICKET_RELATIONS_QUERY_PATH}"); - let scoped_ticket_orchestration_plans_query_path = - format!("/api/w/{{workspace_id}}{TICKET_ORCHESTRATION_PLANS_QUERY_PATH}"); - let workspace = Router::new() - .route("/api/workspace", get(get_workspace)) - .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) - .route( - "/api/w/{workspace_id}/settings", - get(scoped_get_workspace_settings).put(scoped_update_workspace_settings), - ) - .route( - "/api/w/{workspace_id}/settings/signing-identity", - get(scoped_get_workspace_signing_identity), - ) - .route( - "/api/w/{workspace_id}/settings/signing-identity/provision", - post(scoped_provision_workspace_signing_identity), - ) - .route( - "/api/w/{workspace_id}/settings/memory", - get(scoped_get_workspace_memory_settings) - .put(scoped_update_workspace_memory_settings), - ) - .route( - "/api/w/{workspace_id}/settings/repository-access", - get(scoped_get_repository_access_projection), - ) - .route( - "/api/w/{workspace_id}/settings/repository-access/credentials", - get(scoped_list_repository_ssh_credentials) - .post(scoped_create_repository_ssh_credential), - ) - .route( - "/api/w/{workspace_id}/settings/repository-access/credentials/generate", - post(scoped_generate_repository_ssh_credential), - ) - .route( - "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}", - get(scoped_get_repository_ssh_credential) - .delete(scoped_delete_repository_ssh_credential), - ) - .route( - "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/public-key", - get(scoped_get_repository_ssh_public_key), - ) - .route( - "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/rotate", - post(scoped_rotate_repository_ssh_credential), - ) - .route( - "/api/w/{workspace_id}/settings/repository-access/host-trusts", - get(scoped_list_repository_ssh_host_trusts) - .post(scoped_put_repository_ssh_host_trust), - ) - .route( - "/api/w/{workspace_id}/settings/repository-access/host-trusts/{host_trust_id}", - get(scoped_get_repository_ssh_host_trust) - .delete(scoped_delete_repository_ssh_host_trust), - ) - .route( - "/api/w/{workspace_id}/config/source-tree", - get(scoped_get_workspace_config_tree), - ) - .route( - "/api/w/{workspace_id}/config/projections/prompts", - get(scoped_get_prompt_projection), - ) - .route( - "/api/w/{workspace_id}/config/source-tree/commit", - post(scoped_commit_workspace_config_tree), - ) - .route( - "/api/w/{workspace_id}/config/source-tree/revisions/{revision}", - get(scoped_get_workspace_config_revision), - ) - .route( - "/api/w/{workspace_id}/config/source-tree/entries/{*path}", - get(scoped_get_workspace_config_entry), - ) - .route( - "/api/w/{workspace_id}/settings/profiles", - get(scoped_get_profile_settings), - ) - .route( - "/api/w/{workspace_id}/flows", - get(scoped_list_flows).put(scoped_put_flow), - ) - .route( - "/api/w/{workspace_id}/flows/resolve", - post(scoped_resolve_flow_source), - ) - .route( - "/api/w/{workspace_id}/flows/{flow_id}", - get(scoped_get_flow), - ) - .route("/api/tickets", get(list_tickets)) - .route( - "/api/w/{workspace_id}/tickets", - get(scoped_list_tickets).post(scoped_create_ticket_record), - ) - .route( - "/api/w/{workspace_id}/tickets/query", - post(scoped_query_tickets), - ) - .route( - "/api/w/{workspace_id}/memory", - get(scoped_get_memory_document), - ) - .route( - "/api/w/{workspace_id}/memory/staging", - get(scoped_list_memory_staging), - ) - .route( - "/api/w/{workspace_id}/memory/backend", - post(scoped_memory_backend_operation), - ) - .route( - "/api/w/{workspace_id}/memory/consolidation", - post(scoped_memory_consolidation), - ) - .route("/api/tickets/{id}", get(get_ticket)) - .route("/api/w/{workspace_id}/skills", get(scoped_list_skills)) - .route("/api/w/{workspace_id}/skills/lint", get(scoped_lint_skills)) - .route("/api/w/{workspace_id}/skills/{name}", get(scoped_get_skill)) - .route( - "/api/w/{workspace_id}/skills/{name}/activate", - get(scoped_activate_skill), - ) - .route( - "/api/w/{workspace_id}/tickets/default-intake-ready-body", - post(scoped_default_intake_ready_body), - ) - .route( - "/api/w/{workspace_id}/tickets/search", - get(scoped_list_ticket_summaries), - ) - .route( - "/api/w/{workspace_id}/tickets/doctor", - get(scoped_ticket_doctor), - ) - .route( - scoped_ticket_relations_query_path.as_str(), - post(scoped_query_ticket_relations), - ) - .route( - scoped_ticket_orchestration_plans_query_path.as_str(), - post(scoped_query_ticket_orchestration_plans), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/record", - get(scoped_get_ticket_record), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/item", - patch(scoped_edit_ticket_record_item), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/dependency-check", - get(scoped_ticket_dependency_check), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/thread-events", - post(scoped_add_ticket_thread_event), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/state-changes", - post(scoped_add_ticket_state_change), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/intake-summaries", - post(scoped_add_ticket_intake_summary), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", - post(scoped_set_ticket_state_field), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/workflow-state", - post(scoped_set_ticket_workflow_state), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", - post(scoped_mark_ticket_ready), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/workflow/queue", - post(scoped_queue_ticket_record), - ) - .route( - "/api/w/{workspace_id}/merge-requests", - get(scoped_list_merge_requests), - ) - .route( - "/api/w/{workspace_id}/merge-requests/{merge_request_id}", - get(scoped_show_merge_request), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request", - post(scoped_open_merge_request), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/readiness", - get(scoped_merge_request_readiness), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/thread", - get(scoped_merge_request_thread), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source", - post(scoped_repair_merge_request_selector), - ) - .route( - "/api/w/{workspace_id}/internal/reviewer-child-sessions", - post(scoped_register_reviewer_child_session), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/review-capabilities", - post(scoped_register_merge_request_review_capability), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/reviews", - post(scoped_submit_merge_request_review), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/reviews/revoke", - post(scoped_revoke_merge_request_review), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/merge-request/complete", - post(scoped_complete_merge_request), - ) - - .route( - "/api/w/{workspace_id}/tickets/{id}/workflow/close", - post(scoped_close_ticket_record), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/relation-view", - get(scoped_ticket_relation_view), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/relations", - post(scoped_record_ticket_relation).delete(scoped_remove_ticket_relation), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/orchestration-plans", - post(scoped_record_ticket_orchestration_plan), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}", - get(scoped_get_ticket).patch(scoped_edit_ticket_item), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/show", - post(scoped_show_ticket), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/assignments", - get(scoped_list_ticket_assignments), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/assignments/{role}", - put(scoped_set_ticket_assignment).delete(scoped_clear_ticket_assignment), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations", - post(scoped_cancel_ticket_implementation), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/state", - post(scoped_transition_ticket_state), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/ready", - post(scoped_mark_ticket_ready_from_browser), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/events", - post(scoped_append_ticket_event), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/queue", - post(scoped_queue_ticket), - ) - .route( - "/api/w/{workspace_id}/tickets/{id}/close", - post(scoped_close_ticket), - ) - .route("/api/objectives", get(list_objectives)) - .route( - "/api/w/{workspace_id}/objectives", - get(scoped_list_objectives).post(scoped_create_objective), - ) - .route( - "/api/w/{workspace_id}/objectives/query", - post(scoped_query_objectives), - ) - .route("/api/objectives/{id}", get(get_objective)) - .route( - "/api/w/{workspace_id}/objectives/{objective_id}", - get(scoped_get_objective).patch(scoped_edit_objective), - ) - .route( - "/api/w/{workspace_id}/objectives/{objective_id}/show", - post(scoped_show_objective), - ) - .route( - "/api/w/{workspace_id}/objectives/{objective_id}/state", - post(scoped_set_objective_state), - ) - .route( - "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links", - post(scoped_link_objective_ticket), - ) - .route( - "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}", - delete(scoped_unlink_objective_ticket), - ) - .route( - "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", - post(scoped_probe_repository_ssh_connection) - .put(scoped_confirm_repository_ssh_host_trust), - ) - .route("/api/repositories/{repository_key}/log", get(repository_log)) - .route( - "/api/w/{workspace_id}/repositories/{repository_key}/log", - get(scoped_repository_log), - ) - .route("/api/hosts", get(list_hosts)) - .route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts)) - .route( - "/api/w/{workspace_id}/runtime-config", - get(get_latest_workspace_runtime_config), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories", - get(scoped_list_runtime_working_directories).post(scoped_create_runtime_working_directory), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}", - get(scoped_runtime_working_directory_detail).delete(scoped_cleanup_runtime_working_directory), - ) - .route( - "/api/w/{workspace_id}/workers/self/workdir-attachments", - post(scoped_attach_current_worker_workdir), - ) - .route( - "/api/w/{workspace_id}/workers/self/workdir-attachments/{alias}", - delete(scoped_detach_current_worker_workdir), - ) - .route( - "/api/w/{workspace_id}/workers/self/workdir-session/operations", - post(scoped_execute_current_worker_workdir_operation), - ) - .route( - "/api/w/{workspace_id}/external-workdir-grants", - post(scoped_create_external_workdir_grant), - ) - .route( - "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", - get(scoped_get_external_workdir_grant) - .delete(scoped_revoke_external_workdir_grant), - ) - .route( - "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", - get(scoped_external_workdir_provider_ws), - ) - .route( - "/api/w/{workspace_id}/working-directories", - get(scoped_list_working_directories).post(scoped_create_working_directory), - ) - .route( - "/api/w/{workspace_id}/working-directories/{working_directory_id}", - get(scoped_working_directory_detail).delete(scoped_cleanup_working_directory), - ) - .route("/api/runtimes", get(list_runtimes)) - .route( - "/api/w/{workspace_id}/runtimes", - get(scoped_list_runtimes).post(scoped_create_remote_runtime), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}", - get(scoped_get_runtime_detail) - .post(scoped_update_remote_runtime) - .delete(scoped_remove_remote_runtime), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", - get(scoped_reveal_runtime_trust_key).delete(scoped_revoke_runtime_trust_key), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests", - post(scoped_test_runtime_connection), - ) - .route( - "/api/workers", - get(list_workers).post(create_workspace_worker), - ) - .route( - "/api/w/{workspace_id}/orchestrator", - get(scoped_workspace_orchestrator_status) - .post(scoped_start_workspace_orchestrator), - ) - .route( - "/api/w/{workspace_id}/worker-control/workers", - get(list_known_workers).post(spawn_known_worker), - ) - .route( - "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/input", - post(send_known_worker_input), - ) - .route( - "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/cancel", - post(cancel_known_worker), - ) - .route( - "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/stop", - post(stop_known_worker), - ) - .route( - "/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/restore", - post(restore_known_worker), - ) - .route( - "/api/w/{workspace_id}/worker-observation/sessions", - get(scoped_list_worker_observation_sessions), - ) - .route( - "/api/w/{workspace_id}/worker-observation/session", - post(scoped_capture_worker_observation_session), - ) - .route( - "/api/w/{workspace_id}/worker-discovery/workers", - get(scoped_discover_workspace_workers), - ) - .route( - "/api/w/{workspace_id}/workers", - get(scoped_list_workers).post(scoped_create_workspace_worker), - ) - .route( - "/api/w/{workspace_id}/workers/{worker_ref}", - get(scoped_get_workspace_worker), - ) - .route( - "/api/w/{workspace_id}/protocol/ws", - get(scoped_workspace_protocol_ws), - ) - .route( - "/api/workers/launch-options", - get(get_worker_launch_options), - ) - .route( - "/api/w/{workspace_id}/workers/launch-options", - get(scoped_get_worker_launch_options), - ) - .route( - "/api/runtime/v1/workspaces/{workspace_id}/resources/fetch", - post(scoped_post_internal_runtime_resource_fetch), - ) - .route("/api/companion/status", get(get_companion_status)) - .route( - "/api/w/{workspace_id}/companion/status", - get(scoped_get_companion_status), - ) - .route("/api/companion/transcript", get(get_companion_transcript)) - .route( - "/api/w/{workspace_id}/companion/transcript", - get(scoped_get_companion_transcript), - ) - .route("/api/companion/messages", post(post_companion_message)) - .route( - "/api/w/{workspace_id}/companion/messages", - post(scoped_post_companion_message), - ) - .route("/api/companion/cancel", post(post_companion_cancel)) - .route( - "/api/w/{workspace_id}/companion/cancel", - post(scoped_post_companion_cancel), - ) - .route( - "/api/w/{workspace_id}/workers/remove", - post(scoped_worker_remove_source_boundary), - ) - .route( - "/api/runtimes/{runtime_id}/workers", - get(list_runtime_workers).post(create_runtime_worker), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers", - get(scoped_list_runtime_workers).post(scoped_create_runtime_worker), - ) - .route( - "/api/runtimes/{runtime_id}/config-bundles", - post(sync_runtime_config_bundle), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles", - post(scoped_sync_runtime_config_bundle), - ) - .route( - "/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability", - get(check_runtime_config_bundle), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability", - get(scoped_check_runtime_config_bundle), - ) - .route( - "/api/runtimes/{runtime_id}/workers/{worker_id}", - get(get_runtime_worker), - ) - .route( - "/api/runtimes/{runtime_id}/workers/{worker_id}/restore", - post(restore_runtime_worker), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}", - get(scoped_get_runtime_worker), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore", - post(scoped_restore_runtime_worker), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/pin", - put(scoped_pin_runtime_worker).delete(scoped_unpin_runtime_worker), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-plan", - get(scoped_runtime_cleanup_plan), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-executions", - post(scoped_execute_runtime_cleanup), - ) - .route( - "/api/runtimes/{runtime_id}/workers/{worker_id}/input", - post(send_runtime_worker_input), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/input", - post(scoped_send_runtime_worker_input), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-upload-grants", - post(scoped_create_attachment_upload_grant), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", - put(scoped_upload_runtime_worker_file) - .delete(scoped_cancel_attachment_upload) - .layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachments/{artifact_id}", - delete(scoped_delete_runtime_worker_uploaded_file), - ) - .route( - "/api/runtimes/{runtime_id}/workers/{worker_id}/completions", - post(runtime_worker_completions), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/completions", - post(scoped_runtime_worker_completions), - ) - .route( - "/api/runtimes/{runtime_id}/workers/{worker_id}/stop", - post(stop_runtime_worker), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/stop", - post(scoped_stop_runtime_worker), - ) - .route( - "/api/runtimes/{runtime_id}/workers/{worker_id}/cancel", - post(cancel_runtime_worker), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/cancel", - post(scoped_cancel_runtime_worker), - ) - .route( - "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", - get(worker_protocol_ws), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", - get(scoped_worker_protocol_ws), - ) - .route("/api/hosts/{host_id}/workers", get(list_host_workers)) - .route( - "/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); + let contract_service = ServerApiContractService::Workspace(api.clone()); + let auth = generated_auth_contract_router(contract_service.clone()); + let workspace = Router::new(); + let workspace = server_api::server_support::websocket_route::< + server_api::server_api_operations::ExternalWorkdirProviderWebSocket, + _, + _, + _, + >(workspace, scoped_external_workdir_provider_ws); + let workspace = server_api::server_support::websocket_route::< + server_api::server_api_operations::WorkspaceProtocolWebSocket, + _, + _, + _, + >(workspace, scoped_workspace_protocol_ws); + let workspace = server_api::server_support::websocket_route::< + server_api::server_api_operations::WorkerProtocolWebSocketAlias, + _, + _, + _, + >(workspace, worker_protocol_ws); + let workspace = server_api::server_support::websocket_route::< + server_api::server_api_operations::WorkspaceWorkerProtocolWebSocket, + _, + _, + _, + >(workspace, scoped_worker_protocol_ws) + .fallback(get(static_or_spa_fallback)) + .with_state(api) + .merge(generated_workspace_contract_router(contract_service)); auth.merge(workspace) .layer(middleware::from_fn(log_failed_api_response)) } @@ -4617,7 +8841,7 @@ pub async fn serve( } #[derive(Debug, Serialize, Deserialize)] -pub struct ListResponse { +pub struct ProjectListResponse { pub workspace_id: String, pub limit: usize, pub items: Vec, @@ -4626,7 +8850,7 @@ pub struct ListResponse { } #[derive(Debug, Serialize, Deserialize)] -pub struct RuntimeListResponse { +pub struct InternalRuntimeListResponse { pub workspace_id: String, pub limit: usize, pub items: Vec, @@ -4634,184 +8858,57 @@ pub struct RuntimeListResponse { pub diagnostics: Vec, } -#[derive(Debug, Default, Deserialize)] -struct RuntimeWorkersQuery { - status: Option, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "snake_case")] -enum RuntimeWorkersStatusFilter { - Stopped, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum CleanupTargetKind { - WorkerDelete, - WorkdirCleanCleanup, - WorkdirDirtyDiscard, - WorkdirRecordDelete, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum CleanupWorkdirFileStatus { - Pending, - Present, - Active, - CleanupPending, - NotFound, - Corrupted, - Failed, - Unknown, -} - -impl CleanupWorkdirFileStatus { - fn from_registry(value: &str) -> Self { - match value { - "pending" => Self::Pending, - "present" => Self::Present, - "active" => Self::Active, - "cleanup_pending" => Self::CleanupPending, - "not_found" => Self::NotFound, - "corrupted" => Self::Corrupted, - "failed" => Self::Failed, - "unknown" => Self::Unknown, - _ => Self::Unknown, - } - } - - fn from_runtime(value: &WorkingDirectoryStatusKind) -> Self { - match value { - WorkingDirectoryStatusKind::Active => Self::Active, - WorkingDirectoryStatusKind::CleanupPending => Self::CleanupPending, - WorkingDirectoryStatusKind::Corrupted => Self::Corrupted, - WorkingDirectoryStatusKind::NotFound => Self::NotFound, - WorkingDirectoryStatusKind::Unknown => Self::Unknown, - } - } - - fn is_record_only(self) -> bool { - matches!(self, Self::NotFound) - } - - fn is_corrupted(self) -> bool { - matches!(self, Self::Corrupted) +fn cleanup_workdir_file_status_from_registry(value: &str) -> CleanupWorkdirFileStatus { + match value { + "pending" => CleanupWorkdirFileStatus::Pending, + "present" => CleanupWorkdirFileStatus::Present, + "active" => CleanupWorkdirFileStatus::Active, + "cleanup_pending" => CleanupWorkdirFileStatus::CleanupPending, + "not_found" => CleanupWorkdirFileStatus::NotFound, + "corrupted" => CleanupWorkdirFileStatus::Corrupted, + "failed" => CleanupWorkdirFileStatus::Failed, + "unknown" => CleanupWorkdirFileStatus::Unknown, + _ => CleanupWorkdirFileStatus::Unknown, } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum CleanupWorkdirCleanliness { - Clean, - Dirty, - Unknown, -} - -impl CleanupWorkdirCleanliness { - fn from_registry(value: &str) -> Self { - match value { - "clean" => Self::Clean, - "dirty" => Self::Dirty, - "unknown" => Self::Unknown, - _ => Self::Unknown, - } - } - - fn from_runtime(value: Option<&str>) -> Self { - value.map(Self::from_registry).unwrap_or(Self::Unknown) - } - - fn is_clean(self) -> bool { - matches!(self, Self::Clean) +fn cleanup_workdir_file_status_from_runtime( + value: &WorkingDirectoryStatusKind, +) -> CleanupWorkdirFileStatus { + match value { + WorkingDirectoryStatusKind::Active => CleanupWorkdirFileStatus::Active, + WorkingDirectoryStatusKind::CleanupPending => CleanupWorkdirFileStatus::CleanupPending, + WorkingDirectoryStatusKind::Corrupted => CleanupWorkdirFileStatus::Corrupted, + WorkingDirectoryStatusKind::NotFound => CleanupWorkdirFileStatus::NotFound, + WorkingDirectoryStatusKind::Unknown => CleanupWorkdirFileStatus::Unknown, } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CleanupWorkerCandidate { - pub target_id: String, - pub action: CleanupTargetKind, - pub worker_id: String, - pub runtime_worker_id: String, - pub runtime_id: String, - pub reason: String, - pub blocking_reason: Option, - pub pinned: bool, - pub retention_state: String, - pub linked_workdir_ids: Vec, - pub running_linked: bool, - pub estimated_reclaim_bytes: Option, +fn cleanup_workdir_file_status_is_record_only(value: CleanupWorkdirFileStatus) -> bool { + matches!(value, CleanupWorkdirFileStatus::NotFound) } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CleanupWorkdirCandidate { - pub target_id: String, - pub action: CleanupTargetKind, - pub workdir_id: String, - pub runtime_id: String, - pub repository_key: String, - pub reason: String, - pub blocking_reason: Option, - pub linked_worker_ids: Vec, - pub linked_running_worker_ids: Vec, - pub running_linked: bool, - pub pinned_linked: bool, - pub file_status: CleanupWorkdirFileStatus, - pub cleanliness: CleanupWorkdirCleanliness, - pub estimated_reclaim_bytes: Option, +fn cleanup_workdir_file_status_is_corrupted(value: CleanupWorkdirFileStatus) -> bool { + matches!(value, CleanupWorkdirFileStatus::Corrupted) } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCleanupPlanResponse { - pub workspace_id: String, - pub runtime_id: String, - pub generated_at: String, - pub revision: String, - pub digest: String, - pub workers: Vec, - pub workdirs: Vec, - pub diagnostics: Vec, +fn cleanup_workdir_cleanliness_from_registry(value: &str) -> CleanupWorkdirCleanliness { + match value { + "clean" => CleanupWorkdirCleanliness::Clean, + "dirty" => CleanupWorkdirCleanliness::Dirty, + "unknown" => CleanupWorkdirCleanliness::Unknown, + _ => CleanupWorkdirCleanliness::Unknown, + } } -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ExecuteRuntimeCleanupRequest { - pub expected_plan_revision: String, - pub expected_plan_digest: String, - #[serde(default)] - pub worker_target_ids: Vec, - #[serde(default)] - pub workdir_target_ids: Vec, - #[serde(default)] - pub confirm_dirty_discard_target_ids: Vec, +fn cleanup_workdir_cleanliness_from_runtime(value: Option<&str>) -> CleanupWorkdirCleanliness { + value + .map(cleanup_workdir_cleanliness_from_registry) + .unwrap_or(CleanupWorkdirCleanliness::Unknown) } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCleanupExecutionResult { - pub target_id: String, - pub action: CleanupTargetKind, - pub status: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RuntimeCleanupExecutionResponse { - pub workspace_id: String, - pub runtime_id: String, - pub executed_at: String, - pub results: Vec, - pub plan_after: RuntimeCleanupPlanResponse, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WorkerRetentionResponse { - pub workspace_id: String, - #[serde(flatten)] - pub worker_ref: RuntimeWorkerRef, - pub pinned: bool, - pub retention_state: String, +fn cleanup_workdir_cleanliness_is_clean(value: CleanupWorkdirCleanliness) -> bool { + matches!(value, CleanupWorkdirCleanliness::Clean) } #[derive(Debug, Deserialize)] @@ -4819,16 +8916,6 @@ struct LogQuery { limit: Option, } -#[derive(Debug, Deserialize)] -struct MemoryStagingQuery { - limit: Option, -} - -#[derive(Debug, Deserialize)] -struct ObjectiveListQuery { - limit: Option, -} - #[derive(Debug, Deserialize)] struct TicketListQuery { limit: Option, @@ -4867,13 +8954,6 @@ struct ScopedFlowPath { flow_id: String, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PutFlowRequest { - path: String, - content: String, -} - #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct AttachCurrentWorkerWorkdirRequest { @@ -4881,15 +8961,6 @@ struct AttachCurrentWorkerWorkdirRequest { working_directory_id: String, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct CurrentWorkerWorkdirAttachmentResponse { - workspace_id: String, - alias: String, - working_directory_id: String, - capabilities: workdir::WorkdirSessionCapabilities, - attached: bool, -} - #[derive(Debug, Clone, Deserialize)] struct ScopedRecordPath { workspace_id: String, @@ -4984,7 +9055,7 @@ async fn scoped_list_flows( async fn scoped_put_flow( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let definition = flow::compile_flow_source(&request.content).map_err(|error| { @@ -5074,15 +9145,6 @@ async fn scoped_get_flow( Ok(Json(source)) } -async fn scoped_get_workspace( - headers: HeaderMap, - State(api): State, - AxumPath(path): AxumPath, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - get_workspace(headers, State(api)).await -} - async fn scoped_get_workspace_settings( State(api): State, AxumPath(path): AxumPath, @@ -5249,13 +9311,13 @@ struct WorkspaceConfigRevisionPath { async fn scoped_get_workspace_config_revision( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let snapshot = api .config_store .load_workspace_config_revision(&path.workspace_id, path.revision)? .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.revision.to_string())))?; - Ok(Json(snapshot)) + Ok(Json(config_tree_snapshot_to_api(snapshot))) } #[derive(Debug, Deserialize)] @@ -5283,13 +9345,6 @@ async fn scoped_get_prompt_projection( Ok(Json(projection.as_ref().clone())) } -#[derive(Debug, Serialize)] -struct WorkspaceConfigTreeResponse { - snapshot: ConfigTreeSnapshot, - contract: config_source::ToolchainContract, - projection_digest: String, -} - async fn scoped_get_workspace_memory_settings( State(api): State, AxumPath(workspace_id): AxumPath, @@ -5570,10 +9625,276 @@ async fn scoped_delete_repository_ssh_host_trust( Ok(StatusCode::NO_CONTENT) } +fn config_content_type_to_api( + content_type: config_source::ConfigContentType, +) -> server_api::ConfigContentType { + match content_type { + config_source::ConfigContentType::Decodal => server_api::ConfigContentType::Decodal, + config_source::ConfigContentType::Text => server_api::ConfigContentType::Text, + } +} + +fn config_entry_to_api(entry: config_source::ConfigEntry) -> server_api::ConfigEntry { + server_api::ConfigEntry { + path: entry.path.to_string(), + content_type: config_content_type_to_api(entry.content_type), + content: entry.content, + content_digest: entry.content_digest, + } +} + +fn config_tree_snapshot_to_api( + snapshot: config_source::ConfigTreeSnapshot, +) -> server_api::ConfigTreeSnapshot { + server_api::ConfigTreeSnapshot { + revision: snapshot.revision, + digest: snapshot.digest, + entries: snapshot + .entries + .into_iter() + .map(|(path, entry)| (path.to_string(), config_entry_to_api(entry))) + .collect(), + } +} + +fn config_projection_validator_to_api( + validator: config_source::ConfigProjectionValidator, +) -> server_api::ConfigProjectionValidator { + match validator { + config_source::ConfigProjectionValidator::StaticTemplateCatalog { + namespace, + key_aliases, + } => server_api::ConfigProjectionValidator::StaticTemplateCatalog { + namespace, + key_aliases, + }, + } +} + +fn config_schema_bundle_to_api( + bundle: config_source::WorkspaceConfigSchemaBundle, +) -> server_api::WorkspaceConfigSchemaBundle { + server_api::WorkspaceConfigSchemaBundle { + contributions: bundle + .contributions + .into_iter() + .map(|contribution| server_api::ConfigSchemaContribution { + provider_id: contribution.provider_id, + namespace: contribution.namespace, + version: contribution.version, + source: contribution.source, + projection_validator: contribution + .projection_validator + .map(config_projection_validator_to_api), + source_digest: contribution.source_digest, + }) + .collect(), + source: bundle.source, + fingerprint: bundle.fingerprint, + } +} + +fn toolchain_contract_to_api( + contract: config_source::ToolchainContract, +) -> server_api::ToolchainContract { + server_api::ToolchainContract { + contract_version: contract.contract_version, + decodal_version: contract.decodal_version, + schema_version: contract.schema_version, + entrypoints: contract + .entrypoints + .into_iter() + .map(|path| path.to_string()) + .collect(), + import_policy_version: contract.import_policy_version, + schema_bundle: config_schema_bundle_to_api(contract.schema_bundle), + fingerprint: contract.fingerprint, + } +} + +fn workspace_config_state_to_api( + state: crate::config_source::WorkspaceConfigState, +) -> server_api::WorkspaceConfigTreeResponse { + server_api::WorkspaceConfigTreeResponse { + snapshot: config_tree_snapshot_to_api(state.snapshot), + contract: toolchain_contract_to_api(state.contract), + projection_digest: state.projection_digest, + } +} + +fn config_content_type_from_api( + content_type: server_api::ConfigContentType, +) -> config_source::ConfigContentType { + match content_type { + server_api::ConfigContentType::Decodal => config_source::ConfigContentType::Decodal, + server_api::ConfigContentType::Text => config_source::ConfigContentType::Text, + } +} + +fn config_virtual_path(path: String) -> Result { + config_source::VirtualPath::parse(&path) + .map_err(|error| invalid_contract_value("Workspace config path", error)) +} + +fn config_commit_request_from_api( + request: server_api::ConfigCommitRequest, +) -> Result { + let changes = request + .changes + .into_iter() + .map(|change| { + Ok(match change { + server_api::ConfigTreeChange::Create { + path, + content_type, + content, + } => config_source::ConfigTreeChange::Create { + path: config_virtual_path(path)?, + content_type: config_content_type_from_api(content_type), + content, + }, + server_api::ConfigTreeChange::Update { + path, + expected_digest, + content, + } => config_source::ConfigTreeChange::Update { + path: config_virtual_path(path)?, + expected_digest, + content, + }, + server_api::ConfigTreeChange::Rename { + from, + to, + expected_digest, + } => config_source::ConfigTreeChange::Rename { + from: config_virtual_path(from)?, + to: config_virtual_path(to)?, + expected_digest, + }, + server_api::ConfigTreeChange::Delete { + path, + expected_digest, + } => config_source::ConfigTreeChange::Delete { + path: config_virtual_path(path)?, + expected_digest, + }, + }) + }) + .collect::>>()?; + let entrypoints = request + .entrypoints + .into_iter() + .map(config_virtual_path) + .collect::>>()?; + Ok(ConfigCommitRequest { + base_revision: request.base_revision, + base_digest: request.base_digest, + changes, + entrypoints, + }) +} + +fn prompt_projection_to_api( + projection: worker::WorkspacePromptProjection, +) -> server_api::WorkspacePromptProjection { + let catalog = projection.catalog; + server_api::WorkspacePromptProjection { + workspace_id: projection.workspace_id, + config_revision: projection.config_revision, + source_digest: projection.source_digest, + projection_digest: projection.projection_digest, + schema_fingerprint: projection.schema_fingerprint, + toolchain_fingerprint: projection.toolchain_fingerprint, + catalog: server_api::EffectivePromptCatalog { + templates: catalog.templates, + config_revision: catalog.config_revision, + source_digest: catalog.source_digest, + schema_fingerprint: catalog.schema_fingerprint, + toolchain_fingerprint: catalog.toolchain_fingerprint, + catalog_digest: catalog.catalog_digest, + }, + } +} + +fn flow_source_record_to_api(source: FlowSourceRecord) -> server_api::FlowSourceRecord { + server_api::FlowSourceRecord { + workspace_id: source.workspace_id, + flow_id: source.flow_id, + source_kind: match source.source_kind { + FlowSourceKind::Builtin => server_api::FlowSourceKind::Builtin, + FlowSourceKind::Workspace => server_api::FlowSourceKind::Workspace, + }, + name: source.name, + path: source.path, + content: source.content, + content_digest: source.content_digest, + revision: source.revision, + created_at: source.created_at, + updated_at: source.updated_at, + } +} + +fn flow_source_resolve_request_from_api( + request: server_api::FlowSourceResolveRequest, +) -> Result { + Ok(FlowSourceResolveRequest { + selector: request + .selector + .parse() + .map_err(|error| invalid_contract_value("Flow selector", error))?, + }) +} + +fn compiled_flow_definition_to_api( + definition: flow::CompiledFlowDefinition, +) -> server_api::CompiledFlowDefinition { + server_api::CompiledFlowDefinition { + schema_version: definition.schema_version, + name: definition.name, + initial: definition.initial.to_string(), + states: definition + .states + .into_iter() + .map(|(state_id, state)| { + ( + state_id.to_string(), + server_api::CompiledFlowState { + id: state.id.to_string(), + instructions: state.instructions, + terminal: state.terminal, + transitions: state + .transitions + .into_iter() + .map(|transition| server_api::CompiledFlowTransition { + id: transition.id.to_string(), + target: transition.target.to_string(), + condition: transition.condition, + synthetic: transition.synthetic, + }) + .collect(), + }, + ) + }) + .collect(), + content_digest: definition.content_digest, + } +} + +fn resolved_flow_source_to_api(source: ResolvedFlowSource) -> server_api::ResolvedFlowSource { + server_api::ResolvedFlowSource { + selector: source.selector.to_string(), + workspace_id: source.workspace_id, + flow_id: source.flow_id, + revision: source.revision, + content_digest: source.content_digest, + definition: compiled_flow_definition_to_api(source.definition), + } +} + async fn scoped_get_workspace_config_tree( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let state = api .config_store @@ -5587,17 +9908,13 @@ async fn scoped_get_workspace_config_tree( ), projection_digest: config_source::digest_bytes(b"[]"), }); - Ok(Json(WorkspaceConfigTreeResponse { - snapshot: state.snapshot, - contract: state.contract, - projection_digest: state.projection_digest, - })) + Ok(Json(workspace_config_state_to_api(state))) } async fn scoped_get_workspace_config_entry( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let virtual_path = config_source::VirtualPath::parse(&path.path) .map_err(|error| ApiError::from(Error::InvalidInput(error.to_string())))?; @@ -5612,14 +9929,15 @@ async fn scoped_get_workspace_config_entry( .get(&virtual_path) .cloned() .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.path)))?; - Ok(Json(entry)) + Ok(Json(config_entry_to_api(entry))) } async fn scoped_commit_workspace_config_tree( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult<(StatusCode, Json)> { + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { + let request = config_commit_request_from_api(request)?; validate_workspace_scope(&api, &path.workspace_id)?; let candidate = api .config_store @@ -5648,11 +9966,7 @@ async fn scoped_commit_workspace_config_tree( } Ok(( StatusCode::CREATED, - Json(WorkspaceConfigTreeResponse { - snapshot: state.snapshot, - contract: state.contract, - projection_digest: state.projection_digest, - }), + Json(workspace_config_state_to_api(state)), )) } @@ -5714,43 +10028,6 @@ async fn scoped_show_ticket( )?)) } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct TicketRoleAssignmentsResponse { - workspace_id: String, - ticket_id: String, - assignments: Vec, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct TicketRoleAssignmentMutationResponse { - workspace_id: String, - ticket_id: String, - assignment: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct SetTicketRoleAssignmentRequest { - operation_id: String, - principal: TicketAssignmentPrincipal, - expected_assignment_id: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CancelTicketImplementationRequest { - operation_id: String, - assignment_id: String, - reason: String, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct ClearTicketRoleAssignmentQuery { - operation_id: Option, - assignment_id: Option, -} - fn parse_ticket_assignment_role(role: &str) -> ApiResult { match role { "orchestrator" => Ok(TicketAssignmentRole::Orchestrator), @@ -5761,27 +10038,337 @@ fn parse_ticket_assignment_role(role: &str) -> ApiResult { } } +fn project_ticket_role_assignment_record( + record: TicketRoleAssignmentRecord, +) -> server_api::TicketRoleAssignmentRecord { + let role = match record.role { + TicketAssignmentRole::Orchestrator => server_api::TicketAssignmentRole::Orchestrator, + TicketAssignmentRole::Coder => server_api::TicketAssignmentRole::Coder, + TicketAssignmentRole::Owner => server_api::TicketAssignmentRole::Owner, + TicketAssignmentRole::Contributor => server_api::TicketAssignmentRole::Contributor, + }; + let principal = match record.principal { + TicketAssignmentPrincipal::User { account_id } => { + server_api::TicketAssignmentPrincipal::User { account_id } + } + TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } => server_api::TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + }, + TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { + server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } + } + }; + server_api::TicketRoleAssignmentRecord { + workspace_id: record.workspace_id, + ticket_id: record.ticket_id, + assignment_id: record.assignment_id, + role, + principal, + assigned_by: record.assigned_by, + assigned_at: record.assigned_at, + } +} + +fn internal_ticket_assignment_principal( + principal: server_api::TicketAssignmentPrincipal, +) -> ApiResult { + match principal { + server_api::TicketAssignmentPrincipal::User { .. } => Err( + Error::TicketAssignmentConflict( + "user-principal Ticket assignment requires an authenticated authoring boundary; weak Workspace Web access is not authority" + .to_string(), + ) + .into(), + ), + server_api::TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } => Ok(TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + }), + server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { + Ok(TicketAssignmentPrincipal::WorkspaceAgent { agent_key }) + } + } +} + async fn scoped_list_ticket_assignments( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let ticket = api.authority.ticket(&path.id)?; let assignments = api .store .list_current_ticket_role_assignments(&path.workspace_id, &ticket.id)?; - Ok(Json(TicketRoleAssignmentsResponse { + Ok(Json(server_api::TicketRoleAssignmentsResponse { workspace_id: path.workspace_id, ticket_id: ticket.id, - assignments, + assignments: assignments + .into_iter() + .map(project_ticket_role_assignment_record) + .collect(), })) } +async fn compensate_manual_ticket_coder_binding( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + binding: &ManualTicketCoderWorkdirBinding, + worker_may_be_live: bool, +) -> Vec { + let mut diagnostics = Vec::new(); + if worker_may_be_live { + match api.runtime.stop_worker( + worker, + WorkerLifecycleRequest { + reason: Some( + "roll back failed manual Coder assignment capability binding".to_string(), + ), + ticket_assignment: None, + }, + ) { + Ok(result) if result.state == InternalWorkerOperationState::Accepted => {} + Ok(result) => { + diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_worker_stop_rollback_failed", + runtime_diagnostics_message(&result.diagnostics), + )); + return diagnostics; + } + Err(error) => { + diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_worker_stop_rollback_failed", + sanitize_backend_error(&error.message()), + )); + return diagnostics; + } + } + if let Err(error) = close_current_worker_session_locked(api, worker).await { + diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_session_close_rollback_failed", + sanitize_backend_error(&error.to_string()), + )); + return diagnostics; + } + } + if let Err(error) = api.store.replace_worker_workdir_link_capabilities( + &api.config.workspace_id, + worker, + &binding.original_links, + ) { + diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_workdir_capability_rollback_failed", + format!( + "Failed to restore Workdir capabilities for Worker {}:{}: {}", + worker.runtime_id, + worker.worker_id, + sanitize_backend_error(&error.to_string()) + ), + )); + return diagnostics; + } + let original_attachments = match logical_attachments_from_links(&binding.original_links) { + Ok(attachments) => attachments, + Err(error) => { + diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_runtime_attachment_rollback_failed", + sanitize_backend_error(&error.to_string()), + )); + return diagnostics; + } + }; + match api + .runtime + .replace_worker_workdir_attachments(worker, original_attachments) + { + Ok(result) if result.state == InternalWorkerOperationState::Accepted => {} + Ok(result) => { + diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_runtime_attachment_rollback_failed", + runtime_diagnostics_message(&result.diagnostics), + )); + return diagnostics; + } + Err(error) => { + diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_runtime_attachment_rollback_failed", + sanitize_backend_error(&error.message()), + )); + return diagnostics; + } + } + match api.restore_workspace_worker(worker) { + Ok(result) if result.state == server_api::WorkerRestoreState::Accepted => {} + Ok(result) => diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_worker_restore_failed", + runtime_diagnostics_message(&result.diagnostics), + )), + Err(error) => diagnostics.push(spawn_compensation_diagnostic( + "manual_coder_worker_restore_failed", + sanitize_backend_error(&error.error.to_string()), + )), + } + diagnostics +} + +async fn start_manual_ticket_coder_assignment( + api: &WorkspaceApi, + record: &TicketRoleAssignmentRecord, + operation_id: &str, +) -> ApiResult { + let TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } = &record.principal + else { + return Err(Error::TicketAssignmentConflict( + "Workspace agent principal cannot occupy the Coder role".to_string(), + ) + .into()); + }; + let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); + + if api + .store + .get_ticket_assignment_operation(&api.config.workspace_id, operation_id)? + .is_some() + { + return api + .store + .start_ready_ticket_with_coder_assignment(record, &new_id("tasev"), operation_id) + .map_err(ApiError::from); + } + + let observed = api + .runtime + .worker(&worker) + .map_err(|error| error.into_error())?; + if observed.state != "idle" { + return Err(Error::TicketAssignmentConflict(format!( + "manual Coder assignment requires an idle Worker; Worker {}:{} is {}", + worker.runtime_id, worker.worker_id, observed.state + )) + .into()); + } + let session_lock = current_worker_session_lock(api, &worker); + let _session_guard = session_lock.lock().await; + let binding = validate_manual_ticket_coder_workdir_binding(api, &worker, &record.ticket_id)?; + let stopped = api + .runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: Some(format!( + "bind authoritative Workdir capabilities for Ticket {} manual Coder assignment", + record.ticket_id + )), + ticket_assignment: None, + }, + ) + .map_err(|error| error.into_error())?; + if stopped.state != InternalWorkerOperationState::Accepted { + return Err(Error::RuntimeOperationFailed { + runtime_id: worker.runtime_id.clone(), + code: "manual_coder_worker_stop_rejected".to_string(), + message: runtime_diagnostics_message(&stopped.diagnostics), + } + .into()); + } + if let Err(error) = close_current_worker_session_locked(api, &worker).await { + let diagnostics = match api.restore_workspace_worker(&worker) { + Ok(result) if result.state == server_api::WorkerRestoreState::Accepted => Vec::new(), + Ok(result) => vec![spawn_compensation_diagnostic( + "manual_coder_worker_restore_failed", + runtime_diagnostics_message(&result.diagnostics), + )], + Err(restore_error) => vec![spawn_compensation_diagnostic( + "manual_coder_worker_restore_failed", + sanitize_backend_error(&restore_error.error.to_string()), + )], + }; + return Err(ApiError::with_diagnostics(error, diagnostics)); + } + + let replacement = api + .runtime + .replace_worker_workdir_attachments(&worker, binding.runtime_attachments.clone()) + .map_err(|error| error.into_error()); + match replacement { + Ok(result) if result.state == InternalWorkerOperationState::Accepted => {} + Ok(result) => { + let diagnostics = + compensate_manual_ticket_coder_binding(api, &worker, &binding, false).await; + return Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: worker.runtime_id.clone(), + code: "manual_coder_workdir_binding_rejected".to_string(), + message: runtime_diagnostics_message(&result.diagnostics), + }, + diagnostics, + )); + } + Err(error) => { + let diagnostics = + compensate_manual_ticket_coder_binding(api, &worker, &binding, false).await; + return Err(ApiError::with_diagnostics(error, diagnostics)); + } + } + + if let Err(error) = api.store.replace_worker_workdir_link_capabilities( + &api.config.workspace_id, + &worker, + &binding.effective_links, + ) { + let diagnostics = + compensate_manual_ticket_coder_binding(api, &worker, &binding, false).await; + return Err(ApiError::with_diagnostics(error, diagnostics)); + } + + match api.restore_workspace_worker(&worker) { + Ok(result) if result.state == server_api::WorkerRestoreState::Accepted => {} + Ok(result) => { + let diagnostics = + compensate_manual_ticket_coder_binding(api, &worker, &binding, false).await; + return Err(ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: worker.runtime_id.clone(), + code: "manual_coder_worker_restore_rejected".to_string(), + message: runtime_diagnostics_message(&result.diagnostics), + }, + diagnostics, + )); + } + Err(error) => { + let diagnostics = + compensate_manual_ticket_coder_binding(api, &worker, &binding, false).await; + return Err(ApiError::with_diagnostics(error.error, diagnostics)); + } + } + + match api + .store + .start_ready_ticket_with_coder_assignment(record, &new_id("tasev"), operation_id) + { + Ok(assignment) => Ok(assignment), + Err(error) => { + let diagnostics = + compensate_manual_ticket_coder_binding(api, &worker, &binding, true).await; + Err(ApiError::with_diagnostics(error, diagnostics)) + } + } +} + async fn scoped_set_ticket_assignment( State(api): State, AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let ticket = api.authority.ticket(&id)?; let role = parse_ticket_assignment_role(&role)?; @@ -5790,20 +10377,14 @@ async fn scoped_set_ticket_assignment( .expected_assignment_id .map(|value| require_ticket_assignment_value("expected_assignment_id", value)) .transpose()?; - if matches!(request.principal, TicketAssignmentPrincipal::User { .. }) { - return Err(Error::TicketAssignmentConflict( - "user-principal Ticket assignment requires an authenticated authoring boundary; weak Workspace Web access is not authority" - .to_string(), - ) - .into()); - } + let principal = internal_ticket_assignment_principal(request.principal)?; let assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); let record = TicketRoleAssignmentRecord { workspace_id: workspace_id.clone(), ticket_id: ticket.id.clone(), assignment_id: new_id("tasg"), role, - principal: request.principal, + principal, assigned_by: "workspace-web".to_string(), assigned_at, }; @@ -5850,23 +10431,7 @@ async fn scoped_set_ticket_assignment( ) .into()); } - if let TicketAssignmentPrincipal::Worker { - runtime_id, - worker_id, - } = &record.principal - { - api.runtime - .worker(&RuntimeWorkerRef::new( - runtime_id.clone(), - worker_id.clone(), - )) - .map_err(|error| error.into_error())?; - } - api.store.start_ready_ticket_with_coder_assignment( - &record, - &new_id("tasev"), - &operation_id, - )? + start_manual_ticket_coder_assignment(&api, &record, &operation_id).await? } TicketAssignmentRole::Owner | TicketAssignmentRole::Contributor => { return Err(Error::TicketAssignmentConflict( @@ -5876,18 +10441,18 @@ async fn scoped_set_ticket_assignment( .into()); } }; - Ok(Json(TicketRoleAssignmentMutationResponse { + Ok(Json(server_api::TicketRoleAssignmentMutationResponse { workspace_id, ticket_id: ticket.id, - assignment: Some(assignment), + assignment: Some(project_ticket_role_assignment_record(assignment)), })) } async fn scoped_clear_ticket_assignment( State(api): State, AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, - Query(query): Query, -) -> ApiResult> { + Query(query): Query, +) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let ticket = api.authority.ticket(&id)?; let role = parse_ticket_assignment_role(&role)?; @@ -5935,7 +10500,7 @@ async fn scoped_clear_ticket_assignment( )) .into()); } - Ok(Json(TicketRoleAssignmentMutationResponse { + Ok(Json(server_api::TicketRoleAssignmentMutationResponse { workspace_id, ticket_id: ticket.id, assignment: None, @@ -5945,7 +10510,7 @@ async fn scoped_clear_ticket_assignment( async fn scoped_cancel_ticket_implementation( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let ticket = api.authority.ticket(&path.id)?; @@ -6030,7 +10595,7 @@ async fn cancel_ticket_coder_worker( ticket_assignment: None, }, ) { - Ok(result) if result.state == WorkerOperationState::Accepted => {} + Ok(result) if result.state == InternalWorkerOperationState::Accepted => {} Ok(result) => { return Err(ApiError::with_diagnostics( Error::RuntimeOperationFailed { @@ -6126,6 +10691,16 @@ fn validate_ticket_assignment_spawn( "Ticket-assigned Coder spawn requires one Flow segment in initial_submit".to_string(), )); } + if !request.workdir_attachment_requests.is_empty() + || !request.resolved_workdir_attachment_requests.is_empty() + { + return Err(Error::TicketAssignmentConflict( + "Ticket-assigned Coder spawn requires existing Workspace Workdir attachments; new materialization requests cannot carry persisted Ticket target capabilities" + .to_string(), + )); + } + validated_ticket_implementation_targets(api, &assignment.ticket_id) + .map_err(|error| Error::TicketAssignmentConflict(error.error.to_string()))?; validate_ticket_assignment_state(api, assignment)?; if let Some(current) = api @@ -6280,7 +10855,7 @@ fn existing_lifecycle_assignment_worker( api: &WorkspaceApi, assignment: &crate::hosts::WorkerTicketAssignmentRequest, runtime_id: &str, -) -> Result> { +) -> Result> { let Some(operation) = api .store .get_ticket_assignment_operation(&api.config.workspace_id, &assignment.operation_id)? @@ -6321,66 +10896,6 @@ fn require_ticket_assignment_value(field: &str, value: String) -> Result Ok(value.to_string()) } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct BrowserEditTicketRequest { - title: Option, - body: Option, - old_string: Option, - new_string: Option, - #[serde(default)] - replace_all: bool, - target: Option, - author: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct BrowserTransitionTicketStateRequest { - state: TicketWorkflowState, - reason: Option, - body: Option, - author: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -enum BrowserTicketThreadRole { - Comment, - Plan, - Decision, - ImplementationReport, -} - -impl From for TicketEventKind { - fn from(role: BrowserTicketThreadRole) -> Self { - match role { - BrowserTicketThreadRole::Comment => Self::Comment, - BrowserTicketThreadRole::Plan => Self::Plan, - BrowserTicketThreadRole::Decision => Self::Decision, - BrowserTicketThreadRole::ImplementationReport => Self::ImplementationReport, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct BrowserAppendTicketEventRequest { - role: BrowserTicketThreadRole, - body: String, - author: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct BrowserQueueTicketRequest {} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct BrowserCloseTicketRequest { - resolution: String, -} - #[derive(Clone)] struct WorkspaceTicketTargetAuthority { api: WorkspaceApi, @@ -6390,42 +10905,46 @@ impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority { fn resolve_target( &self, workspace_id: &str, - repository_id: Option<&str>, + repository_key: &str, ref_selector: Option<&str>, ) -> ticket::Result { if workspace_id != self.api.config.workspace_id { return Err(ticket::TicketError::UnknownTargetRepository( - repository_id.unwrap_or_default().to_owned(), + repository_key.to_owned(), )); } - let repository_id = repository_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or(ticket::TicketError::MissingTargetRepository)?; + let repository_key = repository_key.trim(); + if repository_key.is_empty() { + return Err(ticket::TicketError::MissingTargetRepository); + } let repository = self .api .store - .get_repository(workspace_id, repository_id) + .get_repository_by_key(workspace_id, repository_key) .map_err(|error| ticket::TicketError::Conflict(error.to_string()))? .ok_or_else(|| { - ticket::TicketError::UnknownTargetRepository(repository_id.to_owned()) + ticket::TicketError::UnknownTargetRepository(repository_key.to_owned()) })?; let selector = ref_selector .map(str::trim) .filter(|value| !value.is_empty()) .or(repository.default_ref.as_deref()) - .ok_or_else(|| ticket::TicketError::MissingTargetSelector(repository_id.to_owned()))?; + .ok_or_else(|| { + ticket::TicketError::MissingTargetSelector(repository.repository_key.clone()) + })?; self.api - .repository_reader() - .observe_merge_target(repository_id, Some(selector)) + .live_repository_reader() + .map_err(|error| ticket::TicketError::Conflict(error.to_string()))? + .observe_merge_target(&repository.repository_id, Some(selector)) .map_err(|error| ticket::TicketError::InvalidTargetSelector { - repository_id: repository_id.to_owned(), + repository_key: repository.repository_key.clone(), selector: selector.to_owned(), reason: format!("{error:?}"), })?; Ok(ticket::ResolvedTicketTarget { - repository_id: repository_id.to_owned(), + repository_key: repository.repository_key, ref_selector: selector.to_owned(), + access: TicketTargetAccess::ReadOnly, }) } } @@ -6452,27 +10971,24 @@ fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult, AxumPath(path): AxumPath, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let mut target = request.target; - if let Some(TicketTargetEdit::Set { repository_id, .. }) = target.as_mut() { - 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", - ) - })?; - *repository_id = api - .store - .get_repository_by_key(&api.config.workspace_id, repository_id)? - .map(|repository| repository.repository_id) - .ok_or_else(|| { - settings_bad_request( - "unknown_ticket_repository", - "repository_key must identify a Repository registered in this Workspace", - ) - })?; + let mut targets = request.target.map(|target| match target { + server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set { + targets: targets + .into_iter() + .map(|target| ticket::TicketTarget { + repository_key: target.repository_key, + ref_selector: target.ref_selector, + access: target.access, + }) + .collect(), + }, + server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear, + }); + if let Some(TicketTargetsEdit::Set { targets }) = targets.as_mut() { + validate_ticket_target_repository_keys(&api, &path.workspace_id, targets)?; } browser_ticket_backend(&api)? .edit_item( @@ -6494,7 +11010,7 @@ async fn scoped_edit_ticket_item( )); } }, - target, + targets, author: request.author, }, ) @@ -6505,16 +11021,24 @@ async fn scoped_edit_ticket_item( async fn scoped_transition_ticket_state( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - if request.state == TicketWorkflowState::Done { + let state = match request.state { + server_api::BrowserTicketWorkflowState::Planning => TicketWorkflowState::Planning, + server_api::BrowserTicketWorkflowState::Ready => TicketWorkflowState::Ready, + server_api::BrowserTicketWorkflowState::Queued => TicketWorkflowState::Queued, + server_api::BrowserTicketWorkflowState::Inprogress => TicketWorkflowState::InProgress, + server_api::BrowserTicketWorkflowState::Done => TicketWorkflowState::Done, + server_api::BrowserTicketWorkflowState::Closed => TicketWorkflowState::Closed, + }; + if state == TicketWorkflowState::Done { return Err(Error::TicketAssignmentConflict( "done is guarded by CompleteMergeRequest with an approved exact source ref and operation_id".to_string(), ).into()); } let current = api.authority.ticket(&path.id)?; - if request.state == TicketWorkflowState::InProgress + if state == TicketWorkflowState::InProgress && current.state != TicketWorkflowState::InProgress.as_str() { return Err(Error::TicketAssignmentConflict( @@ -6525,7 +11049,7 @@ async fn scoped_transition_ticket_state( } let mut change = TicketStateChange::new( current.state, - request.state.as_str(), + state.as_str(), request .reason .unwrap_or_else(|| "state changed from Web Ticket API".to_owned()), @@ -6541,10 +11065,18 @@ async fn scoped_transition_ticket_state( async fn scoped_append_ticket_event( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let mut event = NewTicketEvent::new(request.role.into(), request.body); + let role = match request.role { + server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment, + server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan, + server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision, + server_api::BrowserTicketThreadRole::ImplementationReport => { + TicketEventKind::ImplementationReport + } + }; + let mut event = NewTicketEvent::new(role, request.body); event.author = request.author; browser_ticket_backend(&api)? .add_event(TicketIdOrSlug::Id(path.id.clone()), event) @@ -6555,7 +11087,7 @@ async fn scoped_append_ticket_event( async fn scoped_mark_ticket_ready_from_browser( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; browser_ticket_backend(&api)? @@ -6575,7 +11107,7 @@ async fn scoped_mark_ticket_ready_from_browser( async fn scoped_queue_ticket( State(api): State, AxumPath(path): AxumPath, - Json(_request): Json, + Json(_request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let result = execute_ticket_rest_operation( @@ -6597,7 +11129,7 @@ async fn scoped_queue_ticket( async fn scoped_close_ticket( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; browser_ticket_backend(&api)? @@ -6640,36 +11172,54 @@ fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Res Ok(()) } -fn resolve_ticket_operation_repository_keys( +fn validate_ticket_target_repository_keys( api: &WorkspaceApi, workspace_id: &str, - operation: &mut TicketBackendOperation, + targets: &[ticket::TicketTarget], ) -> ApiResult<()> { - let submitted_key = match operation { - TicketBackendOperation::Create { input } => input.repository_id.as_mut(), - TicketBackendOperation::EditItem { edit, .. } => match edit.target.as_mut() { - Some(ticket::TicketTargetEdit::Set { repository_id, .. }) => Some(repository_id), - _ => None, - }, - _ => None, - }; - let Some(repository_key) = submitted_key else { - return Ok(()); - }; - server_api::validate_repository_key(repository_key).map_err(|error| { - ApiError::from(Error::InvalidInput(format!( - "invalid Repository key: {error}" - ))) - })?; - let repository_id = api - .store - .get_repository_by_key(workspace_id, repository_key)? - .map(|repository| repository.repository_id) - .ok_or_else(|| ApiError::from(Error::UnknownRepository(repository_key.clone())))?; - *repository_key = repository_id; + let mut repositories = HashSet::new(); + for target in targets { + server_api::validate_repository_key(&target.repository_key).map_err(|error| { + ApiError::from(Error::InvalidInput(format!( + "invalid Repository key: {error}" + ))) + })?; + let repository = api + .store + .get_repository_by_key(workspace_id, &target.repository_key)? + .ok_or_else(|| { + ApiError::from(Error::UnknownRepository(target.repository_key.clone())) + })?; + if !repositories.insert(repository.repository_id) { + return Err(Error::InvalidInput(format!( + "Ticket target Repository `{}` appears more than once", + target.repository_key + )) + .into()); + } + } Ok(()) } +fn validate_ticket_operation_repository_keys( + api: &WorkspaceApi, + workspace_id: &str, + operation: &TicketBackendOperation, +) -> ApiResult<()> { + match operation { + TicketBackendOperation::Create { input } => { + validate_ticket_target_repository_keys(api, workspace_id, &input.targets) + } + TicketBackendOperation::EditItem { edit, .. } => match edit.targets.as_ref() { + Some(TicketTargetsEdit::Set { targets }) => { + validate_ticket_target_repository_keys(api, workspace_id, targets) + } + Some(TicketTargetsEdit::Clear) | None => Ok(()), + }, + _ => Ok(()), + } +} + async fn execute_ticket_rest_operation( api: &WorkspaceApi, workspace_id: &str, @@ -6677,7 +11227,7 @@ async fn execute_ticket_rest_operation( mut operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; - resolve_ticket_operation_repository_keys(api, workspace_id, &mut operation)?; + validate_ticket_operation_repository_keys(api, workspace_id, &operation)?; let mut backend = SqliteTicketBackend::open_verified( api.config.database_path.clone(), api.config.workspace_id.clone(), @@ -6779,7 +11329,7 @@ async fn execute_ticket_rest_operation( backend = backend.with_event_attributes(event_attributes); } - let mut result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?; + let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?; if is_mutation { if let TicketBackendOperationResult::QueueOutcome(outcome) = &result { for ticket_id in &outcome.queued_tickets { @@ -6805,16 +11355,6 @@ async fn execute_ticket_rest_operation( ); } } - if let TicketBackendOperationResult::Ticket(ticket) = &mut result - && let Some(repository_id) = ticket.meta.repository_id.as_deref() - { - let repository_key = api - .store - .get_repository(workspace_id, repository_id)? - .map(|repository| repository.repository_key) - .ok_or_else(|| Error::UnknownRepository(repository_id.to_string()))?; - ticket.meta.repository_id = Some(repository_key); - } Ok(result) } @@ -6851,7 +11391,7 @@ fn ticket_rest_unit(result: TicketBackendOperationResult) -> ApiResult, AxumPath(path): AxumPath, headers: HeaderMap, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { let result = execute_ticket_rest_operation( &api, @@ -6874,17 +11414,13 @@ async fn scoped_default_intake_ready_body( }) } -#[derive(Debug, Default, Deserialize)] -struct TicketSummarySearchQuery { - state: Option, -} - async fn scoped_list_ticket_summaries( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, - Query(query): Query, + Query(query): Query, ) -> ApiResult>> { + let limit = query.limit.map(|limit| limit.min(1000)); let filter = match query.state.as_deref().unwrap_or("active") { "active" => ticket::TicketListQuery::active(), "all" => ticket::TicketListQuery::all(), @@ -6906,7 +11442,12 @@ async fn scoped_list_ticket_summaries( ) .await?; ticket_rest_result(result, |result| match result { - TicketBackendOperationResult::Tickets(tickets) => Some(tickets), + TicketBackendOperationResult::Tickets(mut tickets) => { + if let Some(limit) = limit { + tickets.truncate(limit); + } + Some(tickets) + } _ => None, }) } @@ -7059,7 +11600,7 @@ async fn scoped_add_ticket_intake_summary( } #[derive(Debug, Deserialize)] -struct TicketMarkReadyRequest { +struct InternalTicketMarkReadyRequest { operation_key: String, #[serde(default)] reason: Option, @@ -7110,7 +11651,7 @@ async fn scoped_mark_ticket_ready( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, headers: HeaderMap, - Json(request): Json, + Json(request): Json, ) -> ApiResult> { let result = execute_ticket_rest_operation( &api, @@ -7154,66 +11695,6 @@ async fn scoped_queue_ticket_record( }) } -#[derive(Debug, serde::Deserialize)] -struct OpenMergeRequestRequest { - repository_key: String, - selector_from: String, - selector_to: String, - #[serde(default)] - summary: String, -} - -#[derive(Debug, serde::Deserialize)] -struct RepairMergeRequestSelectorRequest { - selector_from: String, - reason: String, - explicit_confirmation: bool, -} - -#[derive(Debug, serde::Deserialize)] -struct RevokeMergeRequestReviewRequest { - review_event_id: String, - reason: String, - explicit_confirmation: bool, -} - -#[derive(Debug, serde::Deserialize)] -struct MergeRequestThreadQuery { - after: Option, - limit: Option, -} - -#[derive(Debug, serde::Deserialize)] -struct RegisterReviewerChildSessionRequest { - child_session_id: String, -} - -#[derive(Debug, serde::Deserialize)] -struct RegisterMergeRequestReviewCapabilityRequest { - child_session_id: String, - capability_token: String, -} - -#[derive(Debug, serde::Deserialize)] -struct SubmitMergeRequestReviewRequest { - capability_token: String, - decision: merge_request::ReviewDecision, - #[serde(default)] - body: String, - #[serde(default)] - findings: Vec, -} - -#[derive(Debug, serde::Deserialize)] -struct CompleteMergeRequestRequest { - operation_id: String, - approval_event_id: String, - target_ref_before: String, - target_ref_after: String, - strategy: merge_request::MergeStrategy, - resolution: merge_request::ConflictResolution, -} - fn parse_workspace_id(value: &str) -> ApiResult { if value.trim().is_empty() { return Err(Error::InvalidInput("workspace_id must not be empty".to_string()).into()); @@ -7589,84 +12070,234 @@ fn resolve_workspace_worker_ticket_assignment( Ok(()) } +fn public_merge_request_state( + state: merge_request::MergeRequestState, +) -> server_api::MergeRequestState { + match state { + merge_request::MergeRequestState::Open => server_api::MergeRequestState::Open, + merge_request::MergeRequestState::Merged => server_api::MergeRequestState::Merged, + merge_request::MergeRequestState::Closed => server_api::MergeRequestState::Closed, + } +} + +fn public_merge_request_worker_identity( + identity: merge_request::WorkerIdentity, +) -> server_api::MergeRequestWorkerIdentity { + server_api::MergeRequestWorkerIdentity { + runtime_id: identity.runtime_id, + worker_id: identity.worker_id, + } +} + +fn public_review_decision(decision: merge_request::ReviewDecision) -> server_api::ReviewDecision { + match decision { + merge_request::ReviewDecision::Approve => server_api::ReviewDecision::Approve, + merge_request::ReviewDecision::RequestChanges => server_api::ReviewDecision::RequestChanges, + } +} + +fn internal_review_decision(decision: server_api::ReviewDecision) -> merge_request::ReviewDecision { + match decision { + server_api::ReviewDecision::Approve => merge_request::ReviewDecision::Approve, + server_api::ReviewDecision::RequestChanges => merge_request::ReviewDecision::RequestChanges, + } +} + +fn public_review_finding(finding: merge_request::ReviewFinding) -> server_api::ReviewFinding { + let severity = match finding.severity { + merge_request::FindingSeverity::Blocker => server_api::FindingSeverity::Blocker, + merge_request::FindingSeverity::Major => server_api::FindingSeverity::Major, + merge_request::FindingSeverity::Minor => server_api::FindingSeverity::Minor, + merge_request::FindingSeverity::Note => server_api::FindingSeverity::Note, + }; + server_api::ReviewFinding { + severity, + code: finding.code, + path: finding.path, + line: finding.line, + body: finding.body, + } +} + +fn internal_review_finding(finding: server_api::ReviewFinding) -> merge_request::ReviewFinding { + let severity = match finding.severity { + server_api::FindingSeverity::Blocker => merge_request::FindingSeverity::Blocker, + server_api::FindingSeverity::Major => merge_request::FindingSeverity::Major, + server_api::FindingSeverity::Minor => merge_request::FindingSeverity::Minor, + server_api::FindingSeverity::Note => merge_request::FindingSeverity::Note, + }; + merge_request::ReviewFinding { + severity, + code: finding.code, + path: finding.path, + line: finding.line, + body: finding.body, + } +} + +fn public_review_event(event: merge_request::ReviewEvent) -> server_api::ReviewEvent { + server_api::ReviewEvent { + event_id: event.event_id, + sequence: event.sequence, + request_event_id: event.request_event_id, + subject_ref: event.subject_ref, + decision: public_review_decision(event.decision), + body: event.body, + findings: event + .findings + .into_iter() + .map(public_review_finding) + .collect(), + reviewer: public_merge_request_worker_identity(event.reviewer), + created_at: event.created_at.to_rfc3339(), + } +} + +fn public_review_revoked_event( + event: merge_request::ReviewRevokedEvent, +) -> server_api::ReviewRevokedEvent { + server_api::ReviewRevokedEvent { + event_id: event.event_id, + sequence: event.sequence, + review_event_id: event.review_event_id, + subject_ref: event.subject_ref, + reason: event.reason, + revoked_by: public_merge_request_worker_identity(event.revoked_by), + created_at: event.created_at.to_rfc3339(), + } +} + +fn public_merge_event(event: merge_request::MergeEvent) -> server_api::MergeEvent { + server_api::MergeEvent { + event_id: event.event_id, + sequence: event.sequence, + operation_id: event.operation_id, + approval_event_id: event.approval_event_id, + approved_source_ref: event.approved_source_ref, + target_ref_before: event.target_ref_before, + target_ref_after: event.target_ref_after, + strategy: public_merge_strategy(event.strategy), + resolution: public_conflict_resolution(event.resolution), + merged_by: public_merge_request_worker_identity(event.merged_by), + created_at: event.created_at.to_rfc3339(), + } +} + +fn public_merge_strategy(strategy: merge_request::MergeStrategy) -> server_api::MergeStrategy { + match strategy { + merge_request::MergeStrategy::FastForward => server_api::MergeStrategy::FastForward, + merge_request::MergeStrategy::Merge => server_api::MergeStrategy::Merge, + } +} + +fn internal_merge_strategy(strategy: server_api::MergeStrategy) -> merge_request::MergeStrategy { + match strategy { + server_api::MergeStrategy::FastForward => merge_request::MergeStrategy::FastForward, + server_api::MergeStrategy::Merge => merge_request::MergeStrategy::Merge, + } +} + +fn public_conflict_resolution( + resolution: merge_request::ConflictResolution, +) -> server_api::ConflictResolution { + match resolution { + merge_request::ConflictResolution::None => server_api::ConflictResolution::None, + merge_request::ConflictResolution::Clean => server_api::ConflictResolution::Clean, + merge_request::ConflictResolution::ConflictsResolved => { + server_api::ConflictResolution::ConflictsResolved + } + } +} + +fn internal_conflict_resolution( + resolution: server_api::ConflictResolution, +) -> merge_request::ConflictResolution { + match resolution { + server_api::ConflictResolution::None => merge_request::ConflictResolution::None, + server_api::ConflictResolution::Clean => merge_request::ConflictResolution::Clean, + server_api::ConflictResolution::ConflictsResolved => { + merge_request::ConflictResolution::ConflictsResolved + } + } +} + +fn public_merge_request_thread_event( + event: merge_request::MergeRequestThreadEvent, +) -> server_api::MergeRequestThreadEvent { + match event { + merge_request::MergeRequestThreadEvent::ReviewRequested(event) => { + server_api::MergeRequestThreadEvent::ReviewRequested(server_api::ReviewRequestedEvent { + event_id: event.event_id, + sequence: event.sequence, + subject_ref: event.subject_ref, + requested_by: public_merge_request_worker_identity(event.requested_by), + reviewer: public_merge_request_worker_identity(event.reviewer), + created_at: event.created_at.to_rfc3339(), + }) + } + merge_request::MergeRequestThreadEvent::Review(event) => { + server_api::MergeRequestThreadEvent::Review(public_review_event(event)) + } + merge_request::MergeRequestThreadEvent::ReviewRevoked(event) => { + server_api::MergeRequestThreadEvent::ReviewRevoked(public_review_revoked_event(event)) + } + merge_request::MergeRequestThreadEvent::ReviewCancelled(event) => { + server_api::MergeRequestThreadEvent::ReviewCancelled(server_api::ReviewCancelledEvent { + event_id: event.event_id, + sequence: event.sequence, + request_event_id: event.request_event_id, + subject_ref: event.subject_ref, + reason: event.reason, + created_at: event.created_at.to_rfc3339(), + }) + } + merge_request::MergeRequestThreadEvent::Comment(event) => { + server_api::MergeRequestThreadEvent::Comment(server_api::MergeRequestCommentEvent { + event_id: event.event_id, + sequence: event.sequence, + body: event.body, + author: public_merge_request_worker_identity(event.author), + created_at: event.created_at.to_rfc3339(), + }) + } + merge_request::MergeRequestThreadEvent::Merge(event) => { + server_api::MergeRequestThreadEvent::Merge(public_merge_event(event)) + } + } +} + fn public_merge_request( api: &WorkspaceApi, workspace_id: &str, mr: merge_request::MergeRequest, -) -> ApiResult { +) -> ApiResult { let repository_key = api .store .get_repository(workspace_id, &mr.repository_id)? .map(|repository| repository.repository_key) .ok_or_else(|| ApiError::from(Error::UnknownRepository(mr.repository_id.clone())))?; - Ok(PublicMergeRequest { + Ok(server_api::PublicMergeRequest { workspace_id: mr.workspace_id, merge_request_id: mr.merge_request_id, repository_key, - state: mr.state, + state: public_merge_request_state(mr.state), selector_from: mr.selector_from, selector_to: mr.selector_to, ticket_ids: mr.ticket_ids, - created_at: mr.created_at, - updated_at: mr.updated_at, - thread: mr.thread, + created_at: mr.created_at.to_rfc3339(), + updated_at: mr.updated_at.to_rfc3339(), + thread: mr + .thread + .into_iter() + .map(public_merge_request_thread_event) + .collect(), }) } -#[derive(Debug, serde::Deserialize)] -struct MergeRequestListHttpQuery { - state: Option, - repository_key: Option, - ticket_ref: Option, - selector_from: Option, - selector_to: Option, - cursor: Option, - limit: Option, -} - -#[derive(Debug, serde::Serialize)] -struct MergeRequestRefResponse { - status: String, - #[serde(rename = "ref")] - revision_ref: Option, - observed_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - diagnostic: Option, -} - -#[derive(Debug, serde::Serialize)] -struct MergeRequestLinkedTicketResponse { - ticket_id: String, - key: Option, -} - -#[derive(Debug, serde::Serialize)] -struct MergeRequestDetailResponse { - #[serde(flatten)] - merge_request: PublicMergeRequest, - source: MergeRequestRefResponse, - target: MergeRequestRefResponse, - linked_tickets: Vec, -} - -#[derive(Debug, Clone, serde::Serialize)] -struct PublicMergeRequest { - workspace_id: String, - merge_request_id: String, - repository_key: String, - state: merge_request::MergeRequestState, - selector_from: Option, - selector_to: String, - ticket_ids: Vec, - created_at: chrono::DateTime, - updated_at: chrono::DateTime, - thread: Vec, -} - async fn scoped_list_merge_requests( State(api): State, AxumPath(workspace_id): AxumPath, - Query(query): Query, + Query(query): Query, ) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; @@ -7789,8 +12420,8 @@ fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic { }) } -fn unknown_merge_ref(code: &str, message: &str) -> MergeRequestRefResponse { - MergeRequestRefResponse { +fn unknown_merge_ref(code: &str, message: &str) -> server_api::MergeRequestRefResponse { + server_api::MergeRequestRefResponse { status: "unknown".to_string(), revision_ref: None, observed_at: Utc::now().to_rfc3339(), @@ -7801,8 +12432,8 @@ fn unknown_merge_ref(code: &str, message: &str) -> MergeRequestRefResponse { } } -fn unknown_merge_ref_response(error: ApiError) -> MergeRequestRefResponse { - MergeRequestRefResponse { +fn unknown_merge_ref_response(error: ApiError) -> server_api::MergeRequestRefResponse { + server_api::MergeRequestRefResponse { status: "unknown".to_string(), revision_ref: None, observed_at: Utc::now().to_rfc3339(), @@ -7810,12 +12441,14 @@ fn unknown_merge_ref_response(error: ApiError) -> MergeRequestRefResponse { } } -fn merge_ref_response(observation: RepositoryRefObservation) -> MergeRequestRefResponse { +fn merge_ref_response( + observation: RepositoryRefObservation, +) -> server_api::MergeRequestRefResponse { let observed_at = chrono::DateTime::::from_timestamp(observation.observed_at_epoch_seconds as i64, 0) .unwrap_or_else(Utc::now) .to_rfc3339(); - MergeRequestRefResponse { + server_api::MergeRequestRefResponse { status: "known".into(), revision_ref: Some(observation.revision_ref), observed_at, @@ -7826,8 +12459,8 @@ fn merge_ref_response(observation: RepositoryRefObservation) -> MergeRequestRefR async fn scoped_show_merge_request( State(api): State, AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, - Query(query): Query, -) -> ApiResult> { + Query(query): Query, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; let store = merge_request_store(&api, &workspace_id)?; @@ -7863,7 +12496,7 @@ async fn scoped_show_merge_request( "source_ref_runtime_unavailable", "No current Coder Runtime is available to observe the source ref", ), - (None, _) => MergeRequestRefResponse { + (None, _) => server_api::MergeRequestRefResponse { status: "requires_repair".into(), revision_ref: None, observed_at: Utc::now().to_rfc3339(), @@ -7890,7 +12523,7 @@ async fn scoped_show_merge_request( .ticket_ids .iter() .map(|ticket_id| { - Ok(MergeRequestLinkedTicketResponse { + Ok(server_api::MergeRequestLinkedTicketResponse { ticket_id: ticket_id.clone(), key: api.store.resource_key( &workspace_id, @@ -7901,7 +12534,7 @@ async fn scoped_show_merge_request( }) .collect::>>()?; let merge_request = public_merge_request(&api, &workspace_id, mr)?; - Ok(Json(MergeRequestDetailResponse { + Ok(Json(server_api::MergeRequestDetailResponse { merge_request, source, target, @@ -7912,7 +12545,7 @@ async fn scoped_show_merge_request( async fn scoped_merge_request_readiness( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, -) -> ApiResult> { +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; let store = merge_request_store(&api, &workspace_id)?; @@ -7958,15 +12591,20 @@ async fn scoped_merge_request_readiness( } report.ready = false; } - Ok(Json(report)) + Ok(Json(server_api::MergeRequestReadinessResponse { + ready: report.ready, + blockers: report.blockers, + subject_ref: report.subject_ref, + review: report.review.map(public_review_event), + })) } async fn scoped_open_merge_request( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { + Json(input): Json, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; @@ -7985,19 +12623,9 @@ async fn scoped_open_merge_request( ) .into()); } - let ticket = browser_ticket_backend(&api)? - .show(TicketIdOrSlug::Id(ticket_id.clone().into())) - .map_err(Error::from)?; let repository = api.require_configured_workspace_repository_by_key(&input.repository_key)?; let repository_id = repository.id; - if ticket.meta.repository_id.as_deref() != Some(repository_id.as_str()) - || ticket.meta.ref_selector.as_deref() != Some(input.selector_to.as_str()) - { - return Err(Error::InvalidInput( - "selectors must match the authoritative Ticket repository target".into(), - ) - .into()); - } + require_ticket_read_write_target(&api, &ticket_id, &repository_id, &input.selector_to)?; let source_observation = observe_published_source_ref( &api, &workspace_id, @@ -8047,17 +12675,21 @@ async fn scoped_open_merge_request( async fn scoped_merge_request_thread( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Query(query): Query, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult>> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; + let events = merge_request_store(&api, &workspace_id)?.thread_page( + &workspace_id, + &ticket_id, + query.after, + query.limit.unwrap_or(100), + )?; Ok(Json( - merge_request_store(&api, &workspace_id)?.thread_page( - &workspace_id, - &ticket_id, - query.after, - query.limit.unwrap_or(100), - )?, + events + .into_iter() + .map(public_merge_request_thread_event) + .collect(), )) } @@ -8065,8 +12697,8 @@ async fn scoped_repair_merge_request_selector( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { + Json(input): Json, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; @@ -8110,7 +12742,7 @@ async fn scoped_register_reviewer_child_session( State(api): State, headers: HeaderMap, AxumPath(workspace_id): AxumPath, - Json(input): Json, + Json(input): Json, ) -> ApiResult { let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; @@ -8132,7 +12764,7 @@ async fn scoped_register_merge_request_review_capability( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, + Json(input): Json, ) -> ApiResult { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; @@ -8193,8 +12825,8 @@ async fn scoped_register_merge_request_review_capability( async fn scoped_submit_merge_request_review( State(api): State, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { + Json(input): Json, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; let store = merge_request_store(&api, &workspace_id)?; @@ -8227,25 +12859,30 @@ async fn scoped_submit_merge_request_review( selector, )? .revision_ref; - Ok(Json(store.submit_review( - merge_request::SubmitMergeRequestReview { - ticket_id, - current_subject_ref, - capability_token: input.capability_token, - decision: input.decision, - body: input.body, - findings: input.findings, - now: Utc::now(), - }, - )?)) + let decision = internal_review_decision(input.decision); + let findings = input + .findings + .into_iter() + .map(internal_review_finding) + .collect(); + let event = store.submit_review(merge_request::SubmitMergeRequestReview { + ticket_id, + current_subject_ref, + capability_token: input.capability_token, + decision, + body: input.body, + findings, + now: Utc::now(), + })?; + Ok(Json(public_review_event(event))) } async fn scoped_revoke_merge_request_review( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { + Json(input): Json, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; @@ -8269,29 +12906,28 @@ async fn scoped_revoke_merge_request_review( } let store = merge_request_store(&api, &workspace_id)?; let mr = store.get(&workspace_id, &ticket_id)?; - Ok(Json(store.revoke_review( - merge_request::RevokeMergeRequestReview { - ticket_id, - review_event_id: input.review_event_id, - reason: input.reason, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: mr.repository_id, - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id: assignment.assignment_id, - }, - now: Utc::now(), + let event = store.revoke_review(merge_request::RevokeMergeRequestReview { + ticket_id, + review_event_id: input.review_event_id, + reason: input.reason, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, }, - )?)) + now: Utc::now(), + })?; + Ok(Json(public_review_revoked_event(event))) } async fn scoped_complete_merge_request( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { + Json(input): Json, +) -> ApiResult> { let workspace_id = parse_workspace_id(&workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; @@ -8307,8 +12943,8 @@ async fn scoped_complete_merge_request( current_subject_ref: existing.approved_source_ref.clone(), target_ref_before: input.target_ref_before, target_ref_after: input.target_ref_after, - strategy: input.strategy, - resolution: input.resolution, + strategy: internal_merge_strategy(input.strategy), + resolution: internal_conflict_resolution(input.resolution), auth: merge_request::MergeRequestAuth { workspace_id, repository_id: mr.repository_id.clone(), @@ -8318,7 +12954,11 @@ async fn scoped_complete_merge_request( }, now: Utc::now(), }; - return store.complete(replay).map(Json).map_err(Into::into); + return store + .complete(replay) + .map(public_merge_event) + .map(Json) + .map_err(Into::into); } let assignment = api .store @@ -8357,8 +12997,8 @@ async fn scoped_complete_merge_request( current_subject_ref: current_source_ref, target_ref_before: input.target_ref_before.clone(), target_ref_after: input.target_ref_after.clone(), - strategy: input.strategy, - resolution: input.resolution, + strategy: internal_merge_strategy(input.strategy), + resolution: internal_conflict_resolution(input.resolution), auth: merge_request::MergeRequestAuth { workspace_id, repository_id: mr.repository_id.clone(), @@ -8369,7 +13009,11 @@ async fn scoped_complete_merge_request( now: Utc::now(), }; store.validate_completion(&completion)?; - store.complete(completion).map(Json).map_err(Into::into) + store + .complete(completion) + .map(public_merge_event) + .map(Json) + .map_err(Into::into) } fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { @@ -8420,17 +13064,11 @@ async fn scoped_record_ticket_relation( }) } -#[derive(Debug, Deserialize)] -struct TicketRelationRemoveRequest { - kind: ticket::TicketRelationKind, - target: String, -} - async fn scoped_remove_ticket_relation( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, headers: HeaderMap, - Json(relation): Json, + Json(relation): Json, ) -> ApiResult> { let result = execute_ticket_rest_operation( &api, @@ -8449,17 +13087,11 @@ async fn scoped_remove_ticket_relation( }) } -#[derive(Debug, Deserialize)] -struct TicketRelationSearchRequest { - ticket: Option, - kind: Option, -} - async fn scoped_query_ticket_relations( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, - Json(query): Json, + Json(query): Json, ) -> ApiResult>> { let result = execute_ticket_rest_operation( &api, @@ -8519,17 +13151,11 @@ async fn scoped_record_ticket_orchestration_plan( }) } -#[derive(Debug, Deserialize)] -struct TicketOrchestrationPlanSearchRequest { - ticket: Option, - kind: Option, -} - async fn scoped_query_ticket_orchestration_plans( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, - Json(query): Json, + Json(query): Json, ) -> ApiResult>> { let result = execute_ticket_rest_operation( &api, @@ -8589,16 +13215,16 @@ fn validate_ticket_repository_operation( api: &WorkspaceApi, operation: &TicketBackendOperation, ) -> ApiResult<()> { - let repository_id = match operation { - TicketBackendOperation::Create { input } => input.repository_id.as_deref(), - TicketBackendOperation::EditItem { edit, .. } => match edit.target.as_ref() { - Some(TicketTargetEdit::Set { repository_id, .. }) => Some(repository_id.as_str()), - _ => None, + let targets: &[ticket::TicketTarget] = match operation { + TicketBackendOperation::Create { input } => &input.targets, + TicketBackendOperation::EditItem { edit, .. } => match edit.targets.as_ref() { + Some(TicketTargetsEdit::Set { targets }) => targets, + Some(TicketTargetsEdit::Clear) | None => &[], }, - _ => None, + _ => &[], }; - if let Some(repository_id) = repository_id { - api.require_workspace_repository(repository_id)?; + for target in targets { + api.require_configured_workspace_repository_by_key(&target.repository_key)?; } Ok(()) } @@ -8846,9 +13472,9 @@ fn ticket_notification_delivery_error_category( ) -> Option<&'static str> { match result { Ok(result) => match result.state { - WorkerOperationState::Accepted => None, - WorkerOperationState::Rejected => Some("runtime_rejected"), - WorkerOperationState::Unsupported => Some("runtime_unsupported"), + InternalWorkerOperationState::Accepted => None, + InternalWorkerOperationState::Rejected => Some("runtime_rejected"), + InternalWorkerOperationState::Unsupported => Some("runtime_unsupported"), }, Err(RuntimeRegistryError::InvalidIdentifier { .. }) => Some("invalid_identifier"), Err(RuntimeRegistryError::UnknownRuntime(_)) => Some("unknown_runtime"), @@ -9011,6 +13637,12 @@ async fn open_current_worker_workdir_session_locked( link.workdir_id ), })?; + let effective_capabilities = link + .capabilities + .intersection(workdir_source_capabilities(&workdir)) + .intersection(ticket_target_capabilities_for_workdir( + api, worker, &workdir, + )?); if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source { close_current_worker_attachment_session_locked(api, worker).await?; let mut grant = api @@ -9057,7 +13689,7 @@ async fn open_current_worker_workdir_session_locked( code: "external_workdir_provider_offline".to_string(), message: "External Workdir provider connection is unavailable".to_string(), })?; - let session: WorkdirSessionHandle = Arc::new(ExternalProviderWorkdirSession::new( + let source: WorkdirSessionHandle = Arc::new(ExternalProviderWorkdirSession::new( connection, Some(ExternalProviderAuditContext { store: api.store.clone(), @@ -9066,6 +13698,8 @@ async fn open_current_worker_workdir_session_locked( attachment_alias: link.alias.clone(), }), )); + let session = workdir::WorkdirToolBroker::with_capabilities(source, effective_capabilities) + .tool_session(); let replaced = api .workdir_sessions .lock() @@ -9128,11 +13762,13 @@ async fn open_current_worker_workdir_session_locked( close_current_worker_attachment_session_locked(api, worker).await?; } let owner_worker_id = runtime_local_owner_worker_id(worker, runtime_id); - let session = api + let source = api .runtime .open_workdir_session(runtime_id, &workdir.workdir_id, owner_worker_id) .await .map_err(|error| error.into_error())?; + let session = workdir::WorkdirToolBroker::with_capabilities(source, effective_capabilities) + .tool_session(); let replaced = api .workdir_sessions .lock() @@ -9212,7 +13848,7 @@ fn sync_runtime_worker_workdir_attachments( .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() .filter(|link| link.unlinked_at.is_none()) - .map(|link| { + .map(|mut link| { let workdir = api .store .get_workdir_registry(&api.config.workspace_id, &link.workdir_id)? @@ -9222,14 +13858,16 @@ fn sync_runtime_worker_workdir_attachments( link.workdir_id )) })?; - let capabilities = match workdir.source { - WorkdirRegistrySource::Repository { .. } => { - workdir::WorkdirSessionCapabilities::ALL - } - WorkdirRegistrySource::ExternalGrant { .. } => { - workdir::WorkdirSessionCapabilities::READ_ONLY - } - }; + let capabilities = link + .capabilities + .intersection(workdir_source_capabilities(&workdir)) + .intersection(ticket_target_capabilities_for_workdir( + api, worker, &workdir, + )?); + if capabilities != link.capabilities { + link.capabilities = capabilities; + api.store.attach_worker_workdir(&link)?; + } Ok(LogicalWorkdirAttachment { alias: workdir::WorkdirAttachmentAlias::new(link.alias) .map_err(|error| Error::InvalidInput(error.to_string()))?, @@ -9242,7 +13880,7 @@ fn sync_runtime_worker_workdir_attachments( .runtime .replace_worker_workdir_attachments(worker, attachments) .map_err(RuntimeRegistryError::into_error)?; - if result.state != WorkerOperationState::Accepted { + if result.state != InternalWorkerOperationState::Accepted { return Err(Error::RuntimeOperationFailed { runtime_id: worker.runtime_id.clone(), code: "worker_workdir_attachments_replace_failed".to_string(), @@ -9279,7 +13917,7 @@ async fn scoped_attach_current_worker_workdir( AxumPath(path): AxumPath, headers: HeaderMap, Json(request): Json, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; let alias = workdir::WorkdirAttachmentAlias::new(request.alias) @@ -9305,8 +13943,8 @@ async fn scoped_attach_current_worker_workdir( )) .into()); } - let capabilities = match &workdir.source { - WorkdirRegistrySource::Repository { .. } => workdir::WorkdirSessionCapabilities::ALL, + match &workdir.source { + WorkdirRegistrySource::Repository { .. } => {} WorkdirRegistrySource::ExternalGrant { grant_id } => { let grant = api .store @@ -9338,9 +13976,9 @@ async fn scoped_attach_current_worker_workdir( } .into()); } - workdir::WorkdirSessionCapabilities::READ_ONLY } - }; + } + let capabilities = ticket_target_capabilities_for_workdir(&api, &worker, &workdir)?; let session_lock = current_worker_session_lock(&api, &worker); let _session_guard = session_lock.lock().await; if !api @@ -9368,15 +14006,21 @@ async fn scoped_attach_current_worker_workdir( )) .into()); } + let effective_capabilities = existing.capabilities.intersection(capabilities); + if effective_capabilities != existing.capabilities { + let mut attenuated = existing.clone(); + attenuated.capabilities = effective_capabilities; + api.store.attach_worker_workdir(&attenuated)?; + } sync_runtime_worker_workdir_attachments(&api, &worker)?; api.worker_projection .refresh(&worker) .map_err(ApiError::from)?; - return Ok(Json(CurrentWorkerWorkdirAttachmentResponse { + return Ok(Json(server_api::CurrentWorkerWorkdirAttachmentResponse { workspace_id: api.config.workspace_id.clone(), alias: alias.to_string(), working_directory_id: workdir_id.to_string(), - capabilities, + capabilities: effective_capabilities, attached: true, })); } @@ -9386,6 +14030,7 @@ async fn scoped_attach_current_worker_workdir( worker: worker.clone(), workdir_id: workdir_id.to_string(), alias: alias.to_string(), + capabilities, linked_at: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), unlinked_at: None, })?; @@ -9431,7 +14076,7 @@ async fn scoped_attach_current_worker_workdir( refresh_current_worker_session_locked(&api, &worker).await?; return Err(error); } - Ok(Json(CurrentWorkerWorkdirAttachmentResponse { + Ok(Json(server_api::CurrentWorkerWorkdirAttachmentResponse { workspace_id: api.config.workspace_id.clone(), alias: alias.to_string(), working_directory_id: workdir_id.to_string(), @@ -9444,7 +14089,7 @@ async fn scoped_detach_current_worker_workdir( State(api): State, AxumPath((workspace_id, alias)): AxumPath<(String, String)>, headers: HeaderMap, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let alias = workdir::WorkdirAttachmentAlias::new(alias) .map_err(|error| Error::InvalidInput(error.to_string()))?; @@ -9504,7 +14149,7 @@ async fn scoped_detach_current_worker_workdir( refresh_current_worker_session_locked(&api, &worker).await?; return Err(error); } - Ok(Json(CurrentWorkerWorkdirAttachmentResponse { + Ok(Json(server_api::CurrentWorkerWorkdirAttachmentResponse { workspace_id: api.config.workspace_id.clone(), alias: alias.to_string(), working_directory_id: link.workdir_id, @@ -9556,30 +14201,30 @@ fn validated_current_worker_attachment( } #[derive(Debug)] -enum WorkdirOperationApiError { +enum InternalWorkdirOperationError { Api(ApiError), Provider(WorkdirTransportError), } -impl From for WorkdirOperationApiError { +impl From for InternalWorkdirOperationError { fn from(error: ApiError) -> Self { Self::Api(error) } } -impl From for WorkdirOperationApiError { +impl From for InternalWorkdirOperationError { fn from(error: Error) -> Self { Self::Api(error.into()) } } -impl From for WorkdirOperationApiError { +impl From for InternalWorkdirOperationError { fn from(error: WorkdirTransportError) -> Self { Self::Provider(error) } } -impl IntoResponse for WorkdirOperationApiError { +impl IntoResponse for InternalWorkdirOperationError { fn into_response(self) -> Response { match self { Self::Api(error) => error.into_response(), @@ -9604,7 +14249,7 @@ async fn scoped_execute_current_worker_workdir_operation( AxumPath(path): AxumPath, headers: HeaderMap, Json(request): Json, -) -> std::result::Result, WorkdirOperationApiError> { +) -> std::result::Result, InternalWorkdirOperationError> { validate_workspace_scope(&api, &path.workspace_id)?; let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; let target_workdir = request.target_workdir; @@ -9695,7 +14340,7 @@ fn current_worker_command_session( worker: &RuntimeWorkerRef, target_workdir: &str, external_handle: &CommandHandle, -) -> std::result::Result<(WorkdirSessionHandle, CommandHandle), WorkdirOperationApiError> { +) -> std::result::Result<(WorkdirSessionHandle, CommandHandle), InternalWorkdirOperationError> { let _link = validated_current_worker_attachment(api, worker, target_workdir)?; let command = api .workdir_sessions @@ -9703,7 +14348,7 @@ fn current_worker_command_session( .expect("Workdir session registry lock poisoned") .command(worker, target_workdir, external_handle) .ok_or_else(|| { - WorkdirOperationApiError::Provider(current_worker_workdir_operation_error( + InternalWorkdirOperationError::Provider(current_worker_workdir_operation_error( worker, workdir::WorkdirError::UnknownCommand(external_handle.0.clone()), )) @@ -9964,7 +14609,7 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { segments: None, }, ) - .is_ok_and(|result| result.state == WorkerOperationState::Accepted); + .is_ok_and(|result| result.state == InternalWorkerOperationState::Accepted); if accepted { *api.orchestrator_attention_fingerprint .lock() @@ -10011,7 +14656,7 @@ fn require_online_workspace_orchestrator_source( Ok(()) } -fn find_online_workspace_orchestrator(api: &WorkspaceApi) -> Option { +fn find_online_workspace_orchestrator(api: &WorkspaceApi) -> Option { api.runtime .list_workers(1000) .items @@ -10025,8 +14670,8 @@ fn find_online_workspace_orchestrator(api: &WorkspaceApi) -> Option Option { - let is_orchestrator = |worker: &WorkerSummary| { +fn find_workspace_orchestrator(api: &WorkspaceApi) -> Option { + let is_orchestrator = |worker: &InternalWorkerSummary| { worker.singleton_key.as_deref() == Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY) }; if let Some(worker) = find_online_workspace_orchestrator(api) { @@ -10063,12 +14708,14 @@ async fn scoped_get_memory_document( async fn scoped_list_memory_staging( State(api): State, AxumPath(path): AxumPath, - Query(query): Query, + Query(query): Query, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; Ok(Json(list_memory_staging_from_authority( &api.authority, - query.limit, + query.limit.map(usize::try_from).transpose().map_err(|_| { + Error::InvalidInput("Memory staging limit exceeds this host's usize range".to_string()) + })?, )?)) } @@ -10168,7 +14815,7 @@ fn start_memory_staging_consolidation( resolved_control_operation: None, }, )?; - if result.state != WorkerOperationState::Accepted { + if result.state != InternalWorkerOperationState::Accepted { return Ok(MemoryConsolidationOutput { status: "skipped_spawn_rejected".to_string(), summary: "Runtime rejected Memory consolidater spawn.".to_string(), @@ -10243,7 +14890,7 @@ fn try_reuse_memory_consolidation_worker( }, ) .map_err(|err| err.into_error())?; - if input.state != WorkerOperationState::Accepted { + if input.state != InternalWorkerOperationState::Accepted { return Ok(Some(MemoryConsolidationOutput { status: "skipped_existing_input_rejected".to_string(), summary: format!( @@ -10265,7 +14912,7 @@ fn try_reuse_memory_consolidation_worker( })) } -fn is_memory_consolidation_worker(worker: &WorkerSummary) -> bool { +fn is_memory_consolidation_worker(worker: &InternalWorkerSummary) -> bool { worker.singleton_key.as_deref() == Some(MEMORY_CONSOLIDATION_SINGLETON_KEY) || worker.profile.as_deref() == Some(MEMORY_CONSOLIDATION_PROFILE) } @@ -10383,7 +15030,7 @@ fn skill_api_error(error: skills::SkillError) -> ApiError { }, vec![RuntimeDiagnostic { code: "skill_not_found".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: format!("unknown Skill `{name}`"), }], ), @@ -10401,8 +15048,8 @@ fn skill_api_error(error: skills::SkillError) -> ApiError { async fn scoped_list_objectives( State(api): State, AxumPath(path): AxumPath, - Query(query): Query, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; list_objectives(State(api), Query(query)).await } @@ -10757,110 +15404,107 @@ async fn scoped_repository_log( async fn scoped_list_hosts( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult>> { +) -> ApiResult>> { validate_workspace_scope(&api, &path.workspace_id)?; list_hosts(State(api)).await } -#[derive(Debug, Deserialize)] -struct LatestWorkspaceRuntimeConfigQuery { - profile: String, -} - -async fn get_latest_workspace_runtime_config( - State(api): State, - AxumPath(path): AxumPath, - Query(query): Query, - headers: HeaderMap, - source: Option>, -) -> Response { - let Some(Extension(_source)) = source else { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ "error": "runtime_config_source_required" })), - ) - .into_response(); - }; - if let Err(error) = validate_workspace_scope(&api, &path.workspace_id) { - return error.into_response(); +fn workspace_runtime_config_result( + api: &WorkspaceApi, + context: server_api::ServerRequestContext, + workspace_id: &str, + query: server_api::WorkspaceRuntimeConfigQuery, + if_none_match: Option, +) -> std::result::Result< + server_api::server_api_responses::WorkspaceRuntimeConfig, + server_api::RepositoryApiError, +> { + if context.runtime_source.is_none() { + return Err(server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_config_source_required", + "runtime config requires an authenticated Runtime source", + Vec::new(), + )); } - let config_state = match api.config_store.load_workspace_config(&path.workspace_id) { - Ok(Some(config)) => config, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "workspace_config_not_found" })), + validate_workspace_scope(api, workspace_id).map_err(ApiError::into_repository_api_error)?; + let config_state = api + .config_store + .load_workspace_config(workspace_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())? + .ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "workspace_config_not_found", + "Workspace Config was not found", + Vec::new(), ) - .into_response(); - } - Err(error) => return ApiError::from(error).into_response(), - }; - let profile_projection = match crate::profile_settings::project_profiles_from_workspace_config( - &path.workspace_id, + })?; + let profile_projection = crate::profile_settings::project_profiles_from_workspace_config( + workspace_id, &config_state, - ) { - Ok(projection) => projection, - Err(error) => return ApiError::from(error).into_response(), - }; + ) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; if crate::profile_settings::selector_for_workspace_candidate( &profile_projection, &query.profile, ) .is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "runtime_config_profile_not_found" })), - ) - .into_response(); - }; - let prompt_catalog = match api + return Err(server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "runtime_config_profile_not_found", + "Runtime Config profile was not found", + Vec::new(), + )); + } + let prompt_catalog = api .prompt_projection_cache - .resolve(&path.workspace_id, &config_state) - { - Ok(catalog) => catalog, - Err(error) => return ApiError::from(error).into_response(), - }; - let Some(bundle) = - (match crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection( + .resolve(workspace_id, &config_state) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + let bundle = + crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection( &profile_projection, &config_state, - &path.workspace_id, + workspace_id, &api.config.workspace_created_at, &query.profile, prompt_catalog.as_ref(), - ) { - Ok(bundle) => bundle, - Err(error) => return ApiError::from(error).into_response(), - }) - else { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "runtime_config_profile_not_found" })), ) - .into_response(); - }; + .map_err(|error| ApiError::from(error).into_repository_api_error())? + .ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "runtime_config_profile_not_found", + "Runtime Config profile was not found", + Vec::new(), + ) + })?; let etag = worker_runtime::config_bundle::workspace_config_etag(&bundle.metadata.digest); - let mut response_headers = HeaderMap::new(); - response_headers.insert(ETAG, etag.parse().expect("Workspace Config ETag is valid")); - response_headers.insert( - CACHE_CONTROL, - "no-cache".parse().expect("valid cache policy"), - ); - if headers - .get(IF_NONE_MATCH) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag)) + let header_cache_control = "no-cache".to_owned(); + if if_none_match.is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag)) { - return (StatusCode::NOT_MODIFIED, response_headers).into_response(); + return Ok( + server_api::server_api_responses::WorkspaceRuntimeConfig::Status304 { + header_etag: etag, + header_cache_control, + }, + ); } - (StatusCode::OK, response_headers, Json(bundle)).into_response() + let body = server_api::WorkspaceRuntimeConfigResponse::from(bundle); + Ok( + server_api::server_api_responses::WorkspaceRuntimeConfig::Status200 { + body, + header_etag: etag, + header_cache_control, + }, + ) } 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?, @@ -10961,92 +15605,109 @@ fn worker_retention_error_response( } } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct WorkerRemoveBoundaryRequest { - target_runtime_id: String, - target_worker_id: String, - reason: String, +fn worker_remove_repository_error( + error: server_api::RepositoryApiError, +) -> server_api::WorkerRemoveApiError { + server_api::WorkerRemoveApiError::new( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + Some(error.error), + None, + Some(error.message), + Some(error.diagnostics), + ) } -async fn scoped_worker_remove_source_boundary( - State(api): State, - AxumPath(path): AxumPath, - headers: HeaderMap, - Json(request): Json, -) -> Response { - if let Err(error) = validate_workspace_scope(&api, &path.workspace_id) { - return error.into_response(); +async fn worker_remove_contract( + api: &WorkspaceApi, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkerRemoveRequest, +) -> std::result::Result { + if workspace_id != api.config.workspace_id { + return Err(server_api::WorkerRemoveApiError::new( + StatusCode::NOT_FOUND.as_u16(), + Some("workspace_not_found".to_owned()), + None, + Some("Workspace was not found".to_owned()), + None, + )); } - let proof = match crate::worker_source::presented_worker_remove_source(&headers, None) { - Ok(proof) => proof, - Err(error) => { - return ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": error.to_string() })), + let headers = contract_request_headers(&context).map_err(worker_remove_repository_error)?; + let proof = + crate::worker_source::presented_worker_remove_source(&headers, None).map_err(|error| { + server_api::WorkerRemoveApiError::new( + StatusCode::UNAUTHORIZED.as_u16(), + Some(error.to_string()), + None, + None, + None, ) - .into_response(); - } - }; - match crate::worker_source::verify_worker_remove_source( - &api, + })?; + let source = crate::worker_source::verify_worker_remove_source( + api, proof, &request.target_runtime_id, &request.target_worker_id, ) .await - { - Ok(source) => { - let executor = WorkerRemovalService::new(&api); - match executor - .execute_async( - source, - &request.target_runtime_id, - &request.target_worker_id, - &request.reason, - ) - .await - { - Ok(response) => ( - StatusCode::from_u16(response.status) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - [(CONTENT_TYPE, "application/json")], - response.body, - ) - .into_response(), - Err(_) => { - let response = worker_remove_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "worker_remove_failed", - "Worker removal failed before lifecycle execution", - ); - ( - StatusCode::from_u16(response.status) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - [(CONTENT_TYPE, "application/json")], - response.body, - ) - .into_response() - } + .map_err(|error| { + let status = match error { + crate::worker_source::WorkerMutationSourceProofError::Replay => StatusCode::CONFLICT, + crate::worker_source::WorkerMutationSourceProofError::Authority(_) => { + StatusCode::INTERNAL_SERVER_ERROR } - } - Err(error) => { - let status = match error { - crate::worker_source::WorkerMutationSourceProofError::Replay => { - StatusCode::CONFLICT - } - crate::worker_source::WorkerMutationSourceProofError::Authority(_) => { - StatusCode::INTERNAL_SERVER_ERROR - } - _ => StatusCode::FORBIDDEN, - }; - ( - status, - Json(serde_json::json!({ "error": error.to_string() })), + _ => StatusCode::FORBIDDEN, + }; + server_api::WorkerRemoveApiError::new( + status.as_u16(), + Some(error.to_string()), + None, + None, + None, + ) + })?; + let response = WorkerRemovalService::new(api) + .execute_async( + source, + &request.target_runtime_id, + &request.target_worker_id, + &request.reason, + ) + .await + .unwrap_or_else(|_| { + worker_remove_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "worker_remove_failed", + "Worker removal failed before lifecycle execution", ) - .into_response() - } + }); + if (200..300).contains(&response.status) { + return serde_json::from_str(&response.body).map_err(|error| { + server_api::WorkerRemoveApiError::new( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + Some("worker_remove_invalid_response".to_owned()), + None, + Some(error.to_string()), + None, + ) + }); } + let body: serde_json::Value = serde_json::from_str(&response.body).unwrap_or_default(); + Err(server_api::WorkerRemoveApiError::new( + response.status, + body.get("error") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + body.get("code") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + body.get("message") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + body.get("diagnostics") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()), + )) } async fn scoped_get_workspace_worker( @@ -11078,73 +15739,43 @@ async fn scoped_get_workspace_worker( }) } -#[derive(Debug, Deserialize)] -struct WorkspaceWorkerDiscoveryQuery { - cursor: Option, - limit: Option, - query: Option, -} - -async fn scoped_discover_workspace_workers( - State(api): State, - AxumPath(path): AxumPath, - Query(query): Query, - source: Option>, -) -> Response { - let Some(Extension(_source)) = source else { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "workspace_worker_discovery_source_required" - })), - ) - .into_response(); - }; - - if let Err(error) = validate_workspace_scope(&api, &path.workspace_id) { - return error.into_response(); +async fn discover_workspace_workers( + api: WorkspaceApi, + workspace_id: String, + query: server_api::WorkspaceWorkerDiscoveryQuery, + verified_runtime_source: bool, +) -> std::result::Result { + if !verified_runtime_source { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "workspace_worker_discovery_source_required".to_string(), + ))); } + validate_workspace_scope(&api, &workspace_id)?; let limit = query.limit.unwrap_or(50).clamp(1, 100); - let cursor = query.cursor.as_deref(); + let cursor = query.cursor.clone(); let query = match query.query.as_deref().map(str::trim) { Some("") | None => None, Some(query) if query.len() <= 128 => Some(query), Some(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_workspace_worker_discovery_query" - })), - ) - .into_response(); + return Err(ApiError::from(Error::InvalidInput( + "invalid_workspace_worker_discovery_query".to_string(), + ))); } }; let filter_fingerprint = workspace_worker_discovery_filter_fingerprint(query); - let offset = match query_cursor_offset(cursor, filter_fingerprint) { - Ok(offset) => offset, - Err(()) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_workspace_worker_discovery_cursor" - })), - ) - .into_response(); - } - }; - - let workers = match workers_response(api) { - Ok(workers) => workers, - Err(error) => return error.into_response(), - }; - Json(workspace_worker_discovery_page( + let offset = query_cursor_offset(cursor.as_deref(), filter_fingerprint).map_err(|()| { + ApiError::from(Error::InvalidInput( + "invalid_workspace_worker_discovery_cursor".to_string(), + )) + })?; + let workers = workers_response(api)?; + Ok(workspace_worker_discovery_page( workers.items, query, offset, limit, filter_fingerprint, )) - .into_response() } fn workspace_worker_discovery_page( @@ -11219,7 +15850,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 } @@ -11232,27 +15863,11 @@ async fn scoped_workspace_orchestrator_status( Ok(Json(workspace_orchestrator_response(&api, "observed"))) } -#[derive(Debug, Serialize, Deserialize)] -struct KnownWorkerRecord { - subject: RuntimeWorkerRef, - relation: String, - origin: String, - permissions: Vec, - summary: WorkerSummary, -} - -#[derive(Debug, Serialize, Deserialize)] -struct KnownWorkersResponse { - workspace_id: String, - items: Vec, - truncated: bool, -} - async fn list_known_workers( 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 controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); @@ -11267,15 +15882,18 @@ async fn list_known_workers( .runtime .worker(&grant.subject) .map_err(|error| error.into_error())?; - items.push(KnownWorkerRecord { - subject: grant.subject, + items.push(server_api::WorkerControlRecord { + subject: server_api::WorkerControlSubject { + runtime_id: grant.subject.runtime_id, + worker_id: grant.subject.worker_id, + }, relation: grant.relation, origin: grant.origin, permissions: grant.permissions, - summary, + summary: worker_launch_worker_summary(summary), }); } - Ok(Json(KnownWorkersResponse { + Ok(Json(server_api::WorkerControlListResponse { workspace_id: path.workspace_id, items, truncated, @@ -11407,14 +16025,15 @@ async fn send_known_worker_input( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); - let permission = match request.kind { - WorkerInputKind::Notify => "notify", - _ => "send_input", + let permission = if request.kind.as_deref() == Some("notify") { + "notify" + } else { + "send_input" }; let grant = authorize_known_worker_permission( &api, @@ -11439,8 +16058,8 @@ async fn cancel_known_worker( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); @@ -11467,8 +16086,8 @@ async fn stop_known_worker( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; let subject = path.worker.clone(); @@ -11500,14 +16119,22 @@ async fn restore_known_worker( let lock = worker_control_lock(&api, &grant.grant_id); let _guard = lock.lock().await; authorize_known_worker_permission(&api, &path.workspace_id, &controller, &subject, "restore")?; - scoped_restore_runtime_worker(State(api), AxumPath(path), Query(Default::default())).await + scoped_restore_runtime_worker( + State(api), + AxumPath(path), + Query(RestoreTicketAssignmentQuery { + ticket_id: None, + assignment_operation_id: None, + }), + ) + .await } async fn scoped_list_worker_observation_sessions( 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 controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); @@ -11529,8 +16156,8 @@ async fn scoped_list_worker_observation_sessions( ) { return None; } - Some(WorkerObservationSubject { - subject: WorkerObservationSubjectRef::RuntimeWorker { + Some(server_api::WorkerObservationSubject { + subject: server_api::WorkerObservationSubjectRef::RuntimeWorker { runtime_id: grant.subject.runtime_id, worker_id: grant.subject.worker_id, }, @@ -11540,18 +16167,20 @@ async fn scoped_list_worker_observation_sessions( }) }) .collect::>(); - Ok(Json(serde_json::json!({ "sessions": sessions }))) + Ok(Json(server_api::WorkerObservationSessionsResponse { + sessions, + })) } async fn scoped_capture_worker_observation_session( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, - Json(subject): Json, -) -> ApiResult> { + Json(subject): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; - let WorkerObservationSubjectRef::RuntimeWorker { + let server_api::WorkerObservationSubjectRef::RuntimeWorker { runtime_id, worker_id, } = subject @@ -11607,10 +16236,10 @@ async fn scoped_capture_worker_observation_session( message: "worker protocol did not begin with a committed session snapshot".to_string(), })); }; - Ok(Json(serde_json::json!({ - "segment_id": format!("runtime:{}:worker:{}", target.runtime_id, target.worker_id), - "session": session, - }))) + Ok(Json(server_api::WorkerObservationCaptureResponse { + segment_id: format!("runtime:{}:worker:{}", target.runtime_id, target.worker_id), + session, + })) } fn diagnostics_indicate_unrecoverable_pending_workspace_restore( @@ -11670,7 +16299,7 @@ async fn scoped_start_workspace_orchestrator( .runtime .delete_worker(&existing.worker) .map_err(|error| error.into_error())?; - if deleted.state != WorkerOperationState::Accepted { + if deleted.state != InternalWorkerOperationState::Accepted { return Err(ApiError::with_diagnostics( Error::RuntimeOperationFailed { runtime_id: existing.worker.runtime_id.clone(), @@ -11707,7 +16336,7 @@ async fn scoped_start_workspace_orchestrator( resolved_control_operation: None, }, )?; - if result.state != WorkerOperationState::Accepted || result.worker.is_none() { + if result.state != InternalWorkerOperationState::Accepted || result.worker.is_none() { return Err(ApiError::with_diagnostics( Error::RuntimeOperationFailed { runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), @@ -11732,7 +16361,7 @@ async fn scoped_start_workspace_orchestrator( Ok(Json(workspace_orchestrator_response(&api, disposition))) } -fn worker_launch_worker_summary(worker: WorkerSummary) -> WorkerLaunchWorkerSummary { +fn worker_launch_worker_summary(worker: InternalWorkerSummary) -> WorkerLaunchWorkerSummary { WorkerLaunchWorkerSummary { runtime_id: worker.worker.runtime_id, worker_id: worker.worker.worker_id, @@ -11805,7 +16434,7 @@ fn workspace_orchestrator_response( } } -fn workspace_orchestrator_is_online(worker: &WorkerSummary) -> bool { +fn workspace_orchestrator_is_online(worker: &InternalWorkerSummary) -> bool { matches!(worker.state.as_str(), "idle" | "running" | "paused") } @@ -11835,9 +16464,9 @@ fn working_directory_diagnostics( .map(|diagnostic| server_api::Diagnostic { code: diagnostic.code, severity: match diagnostic.severity { - DiagnosticSeverity::Info => server_api::DiagnosticSeverity::Info, - DiagnosticSeverity::Warning => server_api::DiagnosticSeverity::Warning, - DiagnosticSeverity::Error => server_api::DiagnosticSeverity::Error, + HostDiagnosticSeverity::Info => server_api::DiagnosticSeverity::Info, + HostDiagnosticSeverity::Warning => server_api::DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Error => server_api::DiagnosticSeverity::Error, }, message: diagnostic.message, }) @@ -11898,13 +16527,81 @@ async fn scoped_runtime_working_directory_detail( working_directory_detail_for_runtime(api, &path.runtime_id, &path.working_directory_id) } -async fn scoped_cleanup_runtime_working_directory( - State(api): State, - AxumPath(path): AxumPath, - worker_source: Option>, - request_actor: Option>, - Json(request): Json, -) -> ApiResult> { +fn workdir_removal_source_actor( + worker_source: Option<(&str, &str)>, + request_actor: Option<&RequestActor>, +) -> ApiResult { + if let Some((runtime_id, worker_id)) = worker_source { + Ok(format!("worker:{runtime_id}:{worker_id}")) + } else if let Some(actor) = request_actor { + Ok(format!("account:{}", actor.account_id)) + } else { + Err(ApiError::from(Error::WorkspacePermissionDenied( + "Workdir removal requires authenticated source authority".to_string(), + ))) + } +} + +fn contract_workdir_removal_source_actor( + context: &server_api::ServerRequestContext, +) -> ApiResult { + workdir_removal_source_actor( + context + .worker_source + .as_ref() + .map(|source| (source.runtime_id.as_str(), source.worker_id.as_str())), + context.actor.as_ref(), + ) +} + +async fn cleanup_runtime_working_directory_contract( + api: WorkspaceApi, + workspace_id: String, + runtime_id: String, + working_directory_id: String, + context: server_api::ServerRequestContext, + request: WorkingDirectoryRemovalRequest, +) -> ApiResult { + let source_actor = contract_workdir_removal_source_actor(&context)?; + cleanup_runtime_working_directory( + api, + ScopedRuntimeWorkingDirectoryPath { + workspace_id, + runtime_id, + working_directory_id, + }, + source_actor, + request, + ) + .await +} + +async fn cleanup_working_directory_contract( + api: WorkspaceApi, + workspace_id: String, + working_directory_id: String, + context: server_api::ServerRequestContext, + request: WorkingDirectoryRemovalRequest, +) -> ApiResult { + let source_actor = contract_workdir_removal_source_actor(&context)?; + cleanup_working_directory( + api, + ScopedWorkingDirectoryPath { + workspace_id, + working_directory_id, + }, + source_actor, + request, + ) + .await +} + +async fn cleanup_runtime_working_directory( + api: WorkspaceApi, + path: ScopedRuntimeWorkingDirectoryPath, + source_actor: String, + request: WorkingDirectoryRemovalRequest, +) -> ApiResult { validate_workspace_scope(&api, &path.workspace_id)?; require_active_workspace_runtime_binding(&api, &path.runtime_id).await?; let registered_runtime = registered_workdir_runtime_id(&api, &path.working_directory_id)?; @@ -11913,22 +16610,12 @@ async fn scoped_cleanup_runtime_working_directory( "Workdir does not belong to the requested Runtime".to_string(), ))); } - let source_actor = if let Some(Extension(source)) = worker_source { - format!("worker:{}:{}", source.runtime_id, source.worker_id) - } else if let Some(Extension(actor)) = request_actor { - format!("account:{}", actor.account_id) - } else { - return Err(ApiError::from(Error::WorkspacePermissionDenied( - "Workdir removal requires authenticated source authority".to_string(), - ))); - }; execute_workdir_removal( &api, &path.working_directory_id, &source_actor, &request.reason, ) - .map(Json) .map_err(ApiError::from) } @@ -12875,30 +17562,19 @@ async fn scoped_working_directory_detail( } } -async fn scoped_cleanup_working_directory( - State(api): State, - AxumPath(path): AxumPath, - worker_source: Option>, - request_actor: Option>, - Json(request): Json, -) -> ApiResult> { +async fn cleanup_working_directory( + api: WorkspaceApi, + path: ScopedWorkingDirectoryPath, + source_actor: String, + request: WorkingDirectoryRemovalRequest, +) -> ApiResult { validate_workspace_scope(&api, &path.workspace_id)?; - let source_actor = if let Some(Extension(source)) = worker_source { - format!("worker:{}:{}", source.runtime_id, source.worker_id) - } else if let Some(Extension(actor)) = request_actor { - format!("account:{}", actor.account_id) - } else { - return Err(ApiError::from(Error::WorkspacePermissionDenied( - "Workdir removal requires authenticated source authority".to_string(), - ))); - }; execute_workdir_removal( &api, &path.working_directory_id, &source_actor, &request.reason, ) - .map(Json) .map_err(ApiError::from) } @@ -12936,7 +17612,7 @@ fn workdir_rejection_failure_code(diagnostics: &[RuntimeDiagnostic]) -> String { diagnostics .iter() .find(|diagnostic| { - diagnostic.severity == DiagnosticSeverity::Error + diagnostic.severity == HostDiagnosticSeverity::Error && !diagnostic.code.is_empty() && diagnostic.code.len() <= 128 && diagnostic @@ -13098,7 +17774,7 @@ async fn create_workspace_working_directory( ), vec![RuntimeDiagnostic { code: "default_runtime_not_configured".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "Workspace default Runtime is not configured".to_string(), }], ) @@ -13161,6 +17837,7 @@ async fn create_workspace_working_directory( .expect("Repository Workdir detail has Runtime"), item: response.item, diagnostics: response.diagnostics, + http_status: StatusCode::OK.as_u16(), }), ) }); @@ -13197,7 +17874,7 @@ async fn create_workspace_working_directory( Error::UnknownRuntime(reserved.resolved_runtime_id), vec![RuntimeDiagnostic { code: "runtime_unavailable".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "Selected Runtime is not available".to_string(), }], )); @@ -13220,7 +17897,7 @@ async fn create_workspace_working_directory( }, vec![RuntimeDiagnostic { code: "runtime_unavailable".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "Selected Runtime is not available".to_string(), }], )); @@ -13262,7 +17939,7 @@ async fn create_workspace_working_directory( error.into_error(), vec![RuntimeDiagnostic { code: failure_code, - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "Selected Runtime could not inspect the reserved Workdir".to_string(), }], )); @@ -13293,7 +17970,7 @@ async fn create_workspace_working_directory( error.into_error(), vec![RuntimeDiagnostic { code: failure_code, - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "Selected Runtime rejected Workdir creation".to_string(), }], )); @@ -13314,13 +17991,13 @@ async fn create_workspace_working_directory( &now_registry_timestamp(), )?; if !diagnostics.iter().any(|diagnostic| { - diagnostic.severity == DiagnosticSeverity::Error && diagnostic.code == failure_code + diagnostic.severity == HostDiagnosticSeverity::Error && diagnostic.code == failure_code }) { diagnostics.insert( 0, RuntimeDiagnostic { code: failure_code.clone(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "Runtime did not create the reserved Workdir".to_string(), }, ); @@ -13356,6 +18033,7 @@ async fn create_workspace_working_directory( runtime_id: reserved.resolved_runtime_id, item: summary.into(), diagnostics: working_directory_diagnostics(result.diagnostics), + http_status: StatusCode::CREATED.as_u16(), }), )) } @@ -13678,7 +18356,7 @@ fn execute_reserved_workdir_removal_with_provider( .fail_workdir_removal_operation(&operation, category, retryable); } }; - if deleted.state != WorkerOperationState::Accepted + if deleted.state != InternalWorkerOperationState::Accepted && !runtime_reports_workdir_not_found(&deleted) { let category = if deleted @@ -13849,7 +18527,8 @@ async fn set_worker_retention( .map_err(ApiError::from)?; Ok(Json(WorkerRetentionResponse { workspace_id: api.config.workspace_id, - worker_ref, + runtime_id: worker_ref.runtime_id, + worker_id: worker_ref.worker_id, pinned, retention_state: retention_state.to_string(), })) @@ -13874,7 +18553,7 @@ fn build_runtime_cleanup_plan( if diagnostics.is_empty() { diagnostics.push(RuntimeDiagnostic { code: "workspace_cleanup_runtime_observation_unavailable".to_string(), - severity: DiagnosticSeverity::Warning, + severity: HostDiagnosticSeverity::Warning, message: sanitize_backend_error(&error.error.to_string()), }); } @@ -13981,17 +18660,19 @@ fn build_runtime_cleanup_plan( let running_linked = !linked_running_worker_ids.is_empty(); let observed_status = observed_workdirs .get(record.workdir_id.as_str()) - .map(|summary| CleanupWorkdirFileStatus::from_runtime(&summary.status)); + .map(|summary| cleanup_workdir_file_status_from_runtime(&summary.status)); let file_status = observed_status.unwrap_or_else(|| { - CleanupWorkdirFileStatus::from_registry(&record.materialization_status) + cleanup_workdir_file_status_from_registry(&record.materialization_status) }); let cleanliness = observed_workdirs .get(record.workdir_id.as_str()) - .map(|summary| CleanupWorkdirCleanliness::from_runtime(summary.cleanliness.as_deref())) - .unwrap_or_else(|| CleanupWorkdirCleanliness::from_registry(&record.cleanliness)); - let action = if file_status.is_record_only() { + .map(|summary| cleanup_workdir_cleanliness_from_runtime(summary.cleanliness.as_deref())) + .unwrap_or_else(|| cleanup_workdir_cleanliness_from_registry(&record.cleanliness)); + let action = if cleanup_workdir_file_status_is_record_only(file_status) { CleanupTargetKind::WorkdirRecordDelete - } else if file_status.is_corrupted() || cleanliness.is_clean() { + } else if cleanup_workdir_file_status_is_corrupted(file_status) + || cleanup_workdir_cleanliness_is_clean(cleanliness) + { CleanupTargetKind::WorkdirCleanCleanup } else { CleanupTargetKind::WorkdirDirtyDiscard @@ -14016,9 +18697,9 @@ fn build_runtime_cleanup_plan( repository_key, reason: if blocking_reason.is_some() { "Workdir cleanup is blocked until linked Worker state is safe".to_string() - } else if file_status.is_record_only() { + } else if cleanup_workdir_file_status_is_record_only(file_status) { "Not-found Workdir record can be deleted from the Backend registry".to_string() - } else if file_status.is_corrupted() { + } else if cleanup_workdir_file_status_is_corrupted(file_status) { "Corrupted Workdir can be deleted from Runtime storage and Backend registry" .to_string() } else if matches!(cleanliness, CleanupWorkdirCleanliness::Dirty) { @@ -14052,7 +18733,7 @@ fn build_runtime_cleanup_plan( digest, workers: worker_candidates, workdirs: workdir_candidates, - diagnostics, + diagnostics: diagnostics.into_iter().map(Into::into).collect(), }) } @@ -14345,101 +19026,6 @@ async fn scoped_reveal_runtime_trust_key( })) } -async fn scoped_revoke_runtime_trust_key( - State(api): State, - AxumPath(path): AxumPath, - Extension(actor): Extension, - Json(request): Json, -) -> std::result::Result { - validate_workspace_scope(&api, &path.workspace_id)?; - require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?; - let actor_account_id = actor.account_id.clone(); - if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID { - return Err(settings_bad_request( - "embedded_runtime_trust_managed_internally", - "the embedded Runtime trust key is managed by the embedded Runtime authority", - )); - } - if request.expected_revision == 0 { - return Err(settings_bad_request( - "invalid_runtime_binding_revision", - "expected_revision must be greater than zero", - )); - } - let now = Utc::now().to_rfc3339(); - let mutation = api - .store - .revoke_workspace_runtime_binding_key( - &path.workspace_id, - &path.runtime_id, - request.expected_revision, - &actor_account_id, - &now, - ) - .await; - let _ = match mutation { - Ok(result) => result, - Err(error) => { - if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await { - return Ok(response); - } - return Err(error.into()); - } - }; - api.runtime_binding_expectations - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&(path.workspace_id.clone(), path.runtime_id.clone())); - api.runtime_subscription_broker - .unregister_runtime(&path.runtime_id); - Ok( - Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?) - .into_response(), - ) -} - -async fn runtime_trust_conflict_response( - api: &WorkspaceApi, - path: &ScopedRuntimePath, - error: &Error, -) -> Option { - let kind = match error { - Error::RuntimeBindingRevisionConflict { .. } => RuntimeTrustConflictKind::StaleRevision, - Error::RuntimeBindingFingerprintConflict { .. } => { - RuntimeTrustConflictKind::FingerprintInUse - } - _ => return None, - }; - let current = api - .store - .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) - .await - .ok() - .flatten(); - Some( - ( - StatusCode::CONFLICT, - Json(RuntimeTrustConflictResponse { - error: kind, - message: match kind { - RuntimeTrustConflictKind::StaleRevision => { - "the Runtime trust binding changed; reload before retrying".to_string() - } - RuntimeTrustConflictKind::FingerprintInUse => { - "the public key is already bound to another Runtime in this Workspace" - .to_string() - } - }, - current_revision: current.as_ref().map(|binding| binding.binding_revision), - current_fingerprint: current - .as_ref() - .map(|binding| binding.public_key_fingerprint.clone()), - }), - ) - .into_response(), - ) -} - async fn scoped_remove_remote_runtime( State(api): State, AxumPath(path): AxumPath, @@ -14530,7 +19116,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 } @@ -14538,8 +19124,8 @@ async fn scoped_list_runtime_workers( async fn scoped_create_runtime_worker( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; create_runtime_worker(State(api), AxumPath(path.runtime_id), Json(request)).await } @@ -14547,8 +19133,8 @@ async fn scoped_create_runtime_worker( async fn scoped_sync_runtime_config_bundle( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; sync_runtime_config_bundle(State(api), AxumPath(path.runtime_id), Json(request)).await } @@ -14556,8 +19142,8 @@ async fn scoped_sync_runtime_config_bundle( async fn scoped_check_runtime_config_bundle( State(api): State, AxumPath(path): AxumPath, - Query(query): Query, -) -> ApiResult> { + Query(query): Query, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; check_runtime_config_bundle( State(api), @@ -14570,7 +19156,7 @@ async fn scoped_check_runtime_config_bundle( async fn scoped_get_runtime_worker( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; get_runtime_worker( State(api), @@ -14579,12 +19165,6 @@ async fn scoped_get_runtime_worker( .await } -#[derive(Debug, Default, Deserialize)] -struct RestoreTicketAssignmentQuery { - ticket_id: Option, - assignment_operation_id: Option, -} - async fn scoped_restore_runtime_worker( State(api): State, AxumPath(path): AxumPath, @@ -14703,31 +19283,6 @@ async fn scoped_execute_runtime_cleanup( Ok(Json(response)) } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct WorkerFileUploadQuery { - file_name: String, - media_type: String, - #[serde(default)] - upload_id: Option, -} - -#[derive(Debug, Serialize)] -struct WorkerFileUploadResponse { - file: protocol::UploadedFileRef, -} - -#[derive(Debug, Serialize)] -struct WorkerFileDeleteResponse { - deleted: bool, -} - -#[derive(Debug, Serialize, Deserialize)] -struct AttachmentUploadGrantResponse { - upload_id: String, - expires_at_ms: u64, -} - #[derive(Debug, Deserialize)] struct ScopedAttachmentUploadPath { workspace_id: String, @@ -14811,14 +19366,14 @@ async fn scoped_create_attachment_upload_grant( })) } -async fn scoped_upload_runtime_worker_file( - State(api): State, - Extension(actor): Extension, - AxumPath(path): AxumPath, - body: Bytes, -) -> ApiResult> { +async fn upload_runtime_worker_file_contract( + api: WorkspaceApi, + actor: RequestActor, + path: ScopedAttachmentUploadPath, + body: &[u8], +) -> ApiResult { validate_workspace_scope(&api, &path.workspace_id)?; - let body_sha256 = attachment_body_sha256(&body); + let body_sha256 = attachment_body_sha256(body); let grant = { let mut grants = api .attachment_upload_grants @@ -14838,7 +19393,7 @@ async fn scoped_upload_runtime_worker_file( ) .map_err(ApiError::from)? { - return Ok(Json(WorkerFileUploadResponse { file })); + return Ok(WorkerFileUploadResponse { file }); } grant.clone() }; @@ -14855,7 +19410,7 @@ async fn scoped_upload_runtime_worker_file( &worker, &grant.file_name, &grant.media_type, - &body, + body, Some(&upload_context), ); let mut grants = api @@ -14879,7 +19434,7 @@ async fn scoped_upload_runtime_worker_file( match uploaded { Ok(file) => { current.state = AttachmentUploadGrantState::Completed(file.clone()); - Ok(Json(WorkerFileUploadResponse { file })) + Ok(WorkerFileUploadResponse { file }) } Err(error) => { current.state = AttachmentUploadGrantState::Pending; @@ -14888,11 +19443,6 @@ async fn scoped_upload_runtime_worker_file( } } -#[derive(Debug, Serialize)] -struct AttachmentUploadCancelResponse { - cancelled: bool, -} - async fn scoped_cancel_attachment_upload( State(api): State, Extension(actor): Extension, @@ -14958,8 +19508,8 @@ async fn scoped_delete_runtime_worker_uploaded_file( async fn scoped_send_runtime_worker_input( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; send_runtime_worker_input( State(api), @@ -14972,8 +19522,8 @@ async fn scoped_send_runtime_worker_input( async fn scoped_runtime_worker_completions( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; runtime_worker_completions( State(api), @@ -14986,8 +19536,8 @@ async fn scoped_runtime_worker_completions( async fn scoped_stop_runtime_worker( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; stop_runtime_worker( State(api), @@ -15000,8 +19550,8 @@ async fn scoped_stop_runtime_worker( async fn scoped_cancel_runtime_worker( State(api): State, AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; cancel_runtime_worker( State(api), @@ -15033,15 +19583,11 @@ 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 } -async fn get_auth_config(State(api): State) -> ApiResult> { - Ok(Json(auth_public_config(&api.config))) -} - async fn post_auth_bootstrap_user( State(api): State, Json(request): Json, @@ -15099,10 +19645,10 @@ async fn post_passkey_registration_options( })) } -async fn post_passkey_registration_complete( - State(api): State, - Json(request): Json, -) -> ApiResult { +fn complete_passkey_registration( + api: &ServerAuthApi, + request: PasskeyRegistrationCompleteRequest, +) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { let challenge = api .store .consume_auth_challenge_by_id( @@ -15165,7 +19711,7 @@ async fn post_passkey_registration_complete( created_at: crate::auth::now_rfc3339(), last_used_at: None, })?; - issue_browser_session_response(&api, user) + issue_browser_session(&api, user) } async fn post_passkey_login_options( @@ -15219,10 +19765,10 @@ async fn post_passkey_login_options( })) } -async fn post_passkey_login_complete( - State(api): State, - Json(request): Json, -) -> ApiResult { +fn complete_passkey_login( + api: &ServerAuthApi, + request: PasskeyLoginCompleteRequest, +) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { let challenge = api .store .consume_auth_challenge_by_id( @@ -15300,10 +19846,13 @@ async fn post_passkey_login_complete( created_at: stored.created_at, last_used_at: Some(crate::auth::now_rfc3339()), })?; - issue_browser_session_response(&api, user) + issue_browser_session(&api, user) } -fn issue_browser_session_response(api: &ServerAuthApi, user: UserRecord) -> ApiResult { +fn issue_browser_session( + api: &ServerAuthApi, + user: UserRecord, +) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { let session_token = mint_secret("yoi_sess"); api.store.create_browser_session(&BrowserSessionRecord { session_id: new_id("session"), @@ -15314,29 +19863,17 @@ fn issue_browser_session_response(api: &ServerAuthApi, user: UserRecord) -> ApiR revoked_at: None, })?; let auth = auth_public_config(&api.config); - let mut headers = HeaderMap::new(); - headers.insert( - SET_COOKIE, - session_set_cookie( - session_cookie_policy(&auth), - &session_token, - 14 * 24 * 60 * 60, - ) - .parse() - .map_err(|error| { - auth_error( - "invalid_session_cookie", - &format!("failed to build session cookie: {error}"), - ) - })?, + let cookie = session_set_cookie( + session_cookie_policy(&auth), + &session_token, + 14 * 24 * 60 * 60, ); Ok(( - headers, - Json(AuthUserResponse { + AuthUserResponse { user: user_response(user), - }), - ) - .into_response()) + }, + server_api::SetCookieHeader::new(cookie), + )) } async fn post_device_login_start( @@ -15387,12 +19924,11 @@ async fn post_device_login_start( .into()) } -async fn post_device_login_approve( - State(api): State, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult> { - let actor = require_actor(&api, &headers).await?; +fn approve_device_login( + api: &ServerAuthApi, + actor: RequestActor, + request: DeviceLoginApproveRequest, +) -> ApiResult { let flow = api .store .get_device_login_flow_by_user_code(&request.user_code.trim().to_ascii_uppercase())? @@ -15441,10 +19977,10 @@ async fn post_device_login_approve( ) .into()); } - Ok(Json(DeviceLoginApproveResponse { + Ok(DeviceLoginApproveResponse { status: DeviceLoginApprovalStatus::Approved, user: actor.user(), - })) + }) } async fn post_device_login_poll( @@ -15488,44 +20024,32 @@ async fn post_device_login_poll( })) } -async fn get_auth_whoami( - State(api): State, - headers: HeaderMap, -) -> ApiResult> { - Ok(Json(WhoamiResponse { - actor: resolve_actor(&api, &headers).await?, - })) -} - -async fn post_auth_logout( - State(api): State, - headers: HeaderMap, -) -> ApiResult { +fn logout_browser_session( + api: &ServerAuthApi, + cookie: Option, +) -> ApiResult<(LogoutResponse, server_api::SetCookieHeader)> { let auth = auth_public_config(&api.config); + let mut headers = HeaderMap::new(); + if let Some(cookie) = cookie { + let value = cookie.parse::().map_err(|_| { + auth_error( + "invalid_session_cookie", + "request cookie header could not be represented", + ) + })?; + headers.insert(axum::http::header::COOKIE, value); + } if let Some(session_token) = parse_cookie(&headers, &auth.cookie_name) { let _ = api .store .revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?; } - let mut response_headers = HeaderMap::new(); - response_headers.insert( - SET_COOKIE, - session_set_cookie(session_cookie_policy(&auth), "", 0) - .parse() - .map_err(|error| { - auth_error( - "invalid_session_cookie", - &format!("failed to build logout cookie: {error}"), - ) - })?, - ); Ok(( - response_headers, - Json(LogoutResponse { + LogoutResponse { status: LogoutStatus::LoggedOut, - }), - ) - .into_response()) + }, + server_api::SetCookieHeader::new(session_set_cookie(session_cookie_policy(&auth), "", 0)), + )) } fn request_origin(headers: &HeaderMap) -> Option { @@ -15704,12 +20228,10 @@ async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult, -) -> ApiResult> { - let cookie_name = auth_public_config(&api.config).cookie_name; - let actor = resolve_request_actor(api.store.as_ref(), &headers, &cookie_name).await?; +async fn workspace_response( + api: &WorkspaceApi, + actor: Option, +) -> ApiResult { let schema_version = api.store.schema_version().await?; let stored = api .store @@ -15721,7 +20243,7 @@ async fn get_workspace( .is_some_and(|actor| stored.owner_account_id == actor.account_id); let companion_status = api.companion.status(); let companion_console = companion_console_extension_point(&companion_status); - Ok(Json(WorkspaceResponse { + Ok(WorkspaceResponse { workspace_id: stored.workspace_id, display_name: stored.display_name, record_authority: "server_db".to_string(), @@ -15747,7 +20269,7 @@ async fn get_workspace( }, companion_console, }, - })) + }) } fn companion_console_extension_point( @@ -15804,7 +20326,7 @@ async fn list_tickets( record_authority, } = api .authority - .list_ticket_page(crate::records::TicketListPageRequest { + .list_ticket_page(crate::records::TicketListProjectionRequest { states, limit: Some(limit), cursor: query.cursor, @@ -15828,15 +20350,15 @@ async fn get_ticket( async fn list_objectives( State(api): State, - Query(query): Query, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult> { let limit = query.limit.unwrap_or(api.config.max_records).min(1000); let ProjectRecordList { items, invalid_records, record_authority, } = api.authority.list_objectives(limit)?; - Ok(Json(ListResponse { + Ok(Json(server_api::ObjectiveListResponse { workspace_id: api.config.workspace_id, limit, items, @@ -15916,10 +20438,10 @@ async fn repository_log( async fn list_hosts( State(api): State, -) -> ApiResult>> { +) -> ApiResult>> { let limit = api.config.max_records.min(200); let runtime_hosts = api.runtime.list_hosts(limit); - Ok(Json(RuntimeListResponse { + Ok(Json(InternalRuntimeListResponse { workspace_id: api.config.workspace_id, limit, items: runtime_hosts.items, @@ -15930,10 +20452,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(server_api::ListResponse { + Ok(Json(server_api::RuntimeListResponse { workspace_id: api.config.workspace_id, limit, items: runtimes.items.into_iter().map(Into::into).collect(), @@ -15944,7 +20466,7 @@ async fn list_runtimes( async fn list_workers( State(api): State, -) -> ApiResult>> { +) -> ApiResult> { workers_response(api).map(Json) } @@ -16165,7 +20687,7 @@ async fn execute_runtime_removal( .await; diagnostics.push(settings_diagnostic( "runtime_removal_active_worker_blocked", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, format!( "Remote Runtime '{}' has {worker_count} active Worker(s); stop and remove them before removing the Runtime.", operation.runtime_id @@ -16904,6 +21426,7 @@ async fn create_workspace_worker_inner( alias, working_directory_id: selection.working_directory_id, relative_cwd: selection.relative_cwd, + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }) }) .collect::>>()?; @@ -16990,7 +21513,7 @@ fn record_browser_worker_spawn( result: WorkerSpawnResult, assignment: Option<&WorkerTicketAssignmentRequest>, ) -> ApiResult { - if result.state != WorkerOperationState::Accepted { + if result.state != InternalWorkerOperationState::Accepted { return Err(worker_create_not_accepted_error( requested_runtime_id.clone(), result.diagnostics, @@ -17006,7 +21529,7 @@ fn record_browser_worker_spawn( fn browser_worker_response_from_summary( api: &WorkspaceApi, - worker: WorkerSummary, + worker: InternalWorkerSummary, display_name: String, diagnostics: Vec, assignment: Option<&WorkerTicketAssignmentRequest>, @@ -17092,7 +21615,7 @@ fn browser_worker_response_from_summary( fn finalize_spawned_worker_workdir_attachments( api: &WorkspaceApi, - worker: &WorkerSummary, + worker: &InternalWorkerSummary, worker_record: &WorkerRegistryRecord, compensation: &WorkerSpawnCompensationContext<'_>, ) -> ApiResult<()> { @@ -17157,6 +21680,7 @@ fn finalize_spawned_worker_workdir_attachments( worker_record, attachment.alias.as_str(), &summary.working_directory_id, + workdir_source_capabilities(&workdir_record), None, ), )?; @@ -17165,101 +21689,192 @@ fn finalize_spawned_worker_workdir_attachments( Ok(()) } -async fn scoped_post_internal_runtime_resource_fetch( - State(api): State, - AxumPath(workspace_id): AxumPath, - headers: HeaderMap, - request: Request, +fn backend_resource_request_from_api( + request: server_api::RuntimeResourceFetchRequest, +) -> BackendResourceFetchRequest { + let handle = request.handle; + BackendResourceFetchRequest { + handle: worker_runtime::resource::BackendResourceHandle { + kind: match handle.kind { + server_api::RuntimeResourceKind::ProfileSourceArchive => { + worker_runtime::resource::BackendResourceKind::ProfileSourceArchive + } + server_api::RuntimeResourceKind::RepositorySshAccess => { + worker_runtime::resource::BackendResourceKind::RepositorySshAccess + } + }, + workspace_id: handle.workspace_id, + scope_id: handle.scope_id, + runtime_id: handle.runtime_id, + worker_id: handle.worker_id, + resource_id: handle.resource_id, + digest: handle.digest, + operation: match handle.operation { + server_api::RuntimeResourceOperation::FetchArchive => { + worker_runtime::resource::BackendResourceOperation::FetchArchive + } + server_api::RuntimeResourceOperation::FetchOnce => { + worker_runtime::resource::BackendResourceOperation::FetchOnce + } + }, + expires_at_unix_seconds: handle.expires_at_unix_seconds, + nonce: handle.nonce, + revision: handle.revision, + generation: handle.generation, + max_bytes: handle.max_bytes, + content_type: handle.content_type, + redaction: match handle.redaction { + server_api::RuntimeResourceRedactionPolicy::RuntimeInternalOnly => { + worker_runtime::resource::ResourceRedactionPolicy::RuntimeInternalOnly + } + }, + audit_correlation_id: handle.audit_correlation_id, + profile_source_graph: handle.profile_source_graph.map(|graph| { + worker_runtime::profile_archive::ProfileSourceGraphSummary { + source_count: graph.source_count, + total_source_bytes: graph.total_source_bytes, + entrypoints: graph.entrypoints, + import_count: graph.import_count, + } + }), + }, + runtime_id: request.runtime_id, + worker_id: request.worker_id, + audit_correlation_id: request.audit_correlation_id, + } +} + +fn runtime_resource_error_to_api( + error: worker_runtime::resource::BackendResourceError, +) -> server_api::RuntimeResourceFetchApiError { + match error { + worker_runtime::resource::BackendResourceError::Expired => { + server_api::RuntimeResourceFetchApiError::Expired + } + worker_runtime::resource::BackendResourceError::Unauthorized { message } => { + server_api::RuntimeResourceFetchApiError::Unauthorized { message } + } + worker_runtime::resource::BackendResourceError::UnsupportedKind => { + server_api::RuntimeResourceFetchApiError::UnsupportedKind + } + worker_runtime::resource::BackendResourceError::MissingResource => { + server_api::RuntimeResourceFetchApiError::MissingResource + } + worker_runtime::resource::BackendResourceError::DigestMismatch { expected, actual } => { + server_api::RuntimeResourceFetchApiError::DigestMismatch { expected, actual } + } + worker_runtime::resource::BackendResourceError::Oversized { + max_bytes, + actual_bytes, + } => server_api::RuntimeResourceFetchApiError::Oversized { + max_bytes, + actual_bytes, + }, + worker_runtime::resource::BackendResourceError::ContentTypeMismatch { + expected, + actual, + } => server_api::RuntimeResourceFetchApiError::ContentTypeMismatch { expected, actual }, + worker_runtime::resource::BackendResourceError::Timeout => { + server_api::RuntimeResourceFetchApiError::Timeout + } + worker_runtime::resource::BackendResourceError::Transport { message } => { + server_api::RuntimeResourceFetchApiError::Transport { message } + } + worker_runtime::resource::BackendResourceError::InvalidResponse { message } => { + server_api::RuntimeResourceFetchApiError::InvalidResponse { message } + } + } +} + +fn runtime_resource_response_to_api( + mut response: worker_runtime::resource::BackendResourceFetchResponse, +) -> server_api::RuntimeResourceFetchResponse { + server_api::RuntimeResourceFetchResponse { + kind: match response.kind { + worker_runtime::resource::BackendResourceKind::ProfileSourceArchive => { + server_api::RuntimeResourceKind::ProfileSourceArchive + } + worker_runtime::resource::BackendResourceKind::RepositorySshAccess => { + server_api::RuntimeResourceKind::RepositorySshAccess + } + }, + resource_id: response.resource_id.clone(), + digest: response.digest.clone(), + content_type: response.content_type.clone(), + bytes: std::mem::take(&mut response.bytes), + audit_correlation_id: response.audit_correlation_id.clone(), + } +} + +async fn runtime_resource_fetch_contract( + api: &WorkspaceApi, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::RuntimeResourceFetchRequest, ) -> std::result::Result< - Json, - (StatusCode, Json), + server_api::RuntimeResourceFetchResponse, + server_api::RuntimeResourceFetchApiError, > { if workspace_id != api.workspace_id() { - return Err(( - StatusCode::NOT_FOUND, - Json(BackendResourceError::MissingResource), - )); + return Err(server_api::RuntimeResourceFetchApiError::MissingResource); } - let proof = headers - .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) - .and_then(|value| value.to_str().ok()) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - ( - StatusCode::UNAUTHORIZED, - Json(BackendResourceError::Unauthorized { - message: "Runtime request proof is required".to_owned(), - }), - ) - })?; - let verified_source = request - .extensions() - .get::() - .cloned(); - let method = request.method().as_str().to_owned(); - let path = signed_request_target(request.uri()).to_owned(); - let body = axum::body::to_bytes(request.into_body(), 16 * 1024 * 1024) - .await - .map_err(|error| { - ( - StatusCode::BAD_REQUEST, - Json(BackendResourceError::InvalidResponse { - message: error.to_string(), - }), - ) - })?; - let source = if let Some(source) = verified_source { + let source = if let Some(source) = context.runtime_source { source } else { - crate::worker_source::verify_runtime_request_source_proof( - &api, + let proof = context + .transport_headers + .iter() + .find(|(name, _)| { + name.eq_ignore_ascii_case(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + }) + .and_then(|(_, value)| std::str::from_utf8(value).ok()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| server_api::RuntimeResourceFetchApiError::Unauthorized { + message: "Runtime request proof is required".to_owned(), + })?; + let body = serde_json::to_vec(&request).map_err(|error| { + server_api::RuntimeResourceFetchApiError::InvalidResponse { + message: error.to_string(), + } + })?; + let path = format!("/api/runtime/v1/workspaces/{workspace_id}/resources/fetch"); + let source = crate::worker_source::verify_runtime_request_source_proof( + api, proof, &workspace_id, worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION, - &method, + "POST", &path, &worker_runtime::auth::request_body_digest(&body), ) .await - .map_err(|_| { - ( - StatusCode::UNAUTHORIZED, - Json(BackendResourceError::Unauthorized { - message: "Runtime request proof is invalid".to_owned(), - }), - ) - })? + .map_err(|_| server_api::RuntimeResourceFetchApiError::Unauthorized { + message: "Runtime request proof is invalid".to_owned(), + })?; + server_api::ServerRuntimeSource { + runtime_id: source.runtime_id, + worker_id: source.worker_id, + } }; if source.worker_id.is_some() { - return Err(( - StatusCode::UNAUTHORIZED, - Json(BackendResourceError::Unauthorized { - message: "Worker-scoped proof cannot fetch Runtime resources".to_owned(), - }), - )); + return Err(server_api::RuntimeResourceFetchApiError::Unauthorized { + message: "Worker-scoped proof cannot fetch Runtime resources".to_owned(), + }); } - let request: BackendResourceFetchRequest = serde_json::from_slice(&body).map_err(|error| { - ( - StatusCode::BAD_REQUEST, - Json(BackendResourceError::InvalidResponse { - message: error.to_string(), - }), - ) - })?; if request.runtime_id != source.runtime_id { - return Err(( - StatusCode::UNAUTHORIZED, - Json(BackendResourceError::Unauthorized { - message: "Runtime request proof subject does not match the request".to_owned(), - }), - )); + return Err(server_api::RuntimeResourceFetchApiError::Unauthorized { + message: "Runtime request proof subject does not match the request".to_owned(), + }); } - api.resource_broker + let request = backend_resource_request_from_api(request); + let response = api + .resource_broker .fetch_resource(request) - .map(Json) - .map_err(|error| (backend_resource_error_status(&error), Json(error))) + .map_err(runtime_resource_error_to_api)?; + Ok(runtime_resource_response_to_api(response)) } +#[cfg(test)] fn backend_resource_error_status(error: &BackendResourceError) -> StatusCode { match error { BackendResourceError::Expired => StatusCode::GONE, @@ -17304,13 +21919,6 @@ async fn post_companion_cancel( Ok(Json(api.companion.cancel(request))) } -#[derive(Debug, Serialize)] -struct WorkerShowProjection { - #[serde(flatten)] - worker: server_api::WorkerSummary, - updated_at: String, -} - fn resolve_workspace_worker_reference( api: &WorkspaceApi, runtime_id: &str, @@ -17339,7 +21947,7 @@ fn resolve_workspace_worker_reference( async fn get_runtime_worker( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, -) -> ApiResult> { +) -> ApiResult> { let worker_ref = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let worker = api .runtime @@ -17355,7 +21963,10 @@ async fn get_runtime_worker( let updated_at = record.updated_at.clone(); let worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs); let worker = project_workspace_worker(&api, worker)?; - Ok(Json(WorkerShowProjection { worker, updated_at })) + Ok(Json(server_api::RuntimeWorkerShowResponse { + worker, + updated_at, + })) } async fn restore_runtime_worker( @@ -17389,16 +22000,6 @@ async fn restore_runtime_worker( })) } -#[derive(Debug, Serialize, Deserialize)] -pub struct RuntimeConfigBundleSyncRequest { - pub bundle: ConfigBundle, -} - -#[derive(Debug, Deserialize)] -struct RuntimeConfigBundleAvailabilityQuery { - digest: String, -} - fn reject_workdir_for_embedded_runtime(runtime_id: &str, has_workdir: bool) -> ApiResult<()> { if runtime_id != EMBEDDED_WORKER_RUNTIME_ID || !has_workdir { return Ok(()); @@ -17411,7 +22012,7 @@ fn reject_workdir_for_embedded_runtime(runtime_id: &str, has_workdir: bool) -> A }, vec![RuntimeDiagnostic { code: "embedded_worker_workdir_unsupported".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "Choose a non-embedded Runtime for workspace-file Workers; embedded Workers are no-workdir Workspace-API workers.".to_string(), }], )) @@ -17421,7 +22022,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) => ( @@ -17438,7 +22039,7 @@ async fn list_runtime_workers( ), }; let items = project_observed_workspace_workers(&api, runtime_workers.items)?; - Ok(Json(server_api::ListResponse { + Ok(Json(server_api::RuntimeWorkerListResponse { workspace_id: api.workspace_id().to_string(), limit, items, @@ -17481,7 +22082,7 @@ struct WorkerSpawnCompensationContext<'a> { fn finalize_worker_spawn_stage( api: &WorkspaceApi, - worker: &WorkerSummary, + worker: &InternalWorkerSummary, context: &WorkerSpawnCompensationContext<'_>, stage: WorkerSpawnFinalizeStage, result: ApiResult, @@ -17496,7 +22097,7 @@ fn finalize_worker_spawn_stage( .unwrap_or("none"); let mut diagnostics = vec![RuntimeDiagnostic { code: format!("worker_spawn_finalize_{}_failed", stage.as_str()), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: format!( "Worker spawn finalize failed at stage `{}` for Runtime Worker {}:{} (operation `{operation_id}`): {source_message}", stage.as_str(), @@ -17511,7 +22112,7 @@ fn finalize_worker_spawn_stage( if !compensation_failed { diagnostics.push(RuntimeDiagnostic { code: "worker_spawn_compensated".to_string(), - severity: DiagnosticSeverity::Info, + severity: HostDiagnosticSeverity::Info, message: format!( "Removed Runtime Worker {}:{} and rolled back Backend spawn state", worker.worker.runtime_id, worker.worker.worker_id @@ -17567,10 +22168,10 @@ fn api_error_with_additional_diagnostics( fn release_worker_workdir_attachment_reservations( api: &WorkspaceApi, - reservations: &[(String, String, String)], + reservations: &[(String, String, String, workdir::WorkdirSessionCapabilities)], ) -> Vec { let mut diagnostics = Vec::new(); - for (_, workdir_id, reservation_id) in reservations { + for (_, workdir_id, reservation_id, _) in reservations { if let Err(error) = api.store.release_worker_workdir_attachment_reservation( &api.config.workspace_id, workdir_id, @@ -17594,9 +22195,9 @@ fn compensate_failed_workspace_worker_create( reservation_worker_id: WorkerId, create_fingerprint: &str, failure_phase: &str, - worker: Option<&WorkerSummary>, + worker: Option<&InternalWorkerSummary>, context: &WorkerSpawnCompensationContext<'_>, - attachment_reservations: &[(String, String, String)], + attachment_reservations: &[(String, String, String, workdir::WorkdirSessionCapabilities)], ) -> Vec { let mut diagnostics = Vec::new(); let reserved_worker_ref = @@ -17672,7 +22273,7 @@ fn compensate_failed_workspace_worker_create( fn compensate_failed_worker_spawn( api: &WorkspaceApi, - worker: &WorkerSummary, + worker: &InternalWorkerSummary, context: &WorkerSpawnCompensationContext<'_>, ) -> Vec { let (runtime_deleted, mut diagnostics) = @@ -17786,7 +22387,7 @@ fn lifecycle_failure_detail( fn spawn_compensation_diagnostic(code: &str, message: String) -> RuntimeDiagnostic { RuntimeDiagnostic { code: code.to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message, } } @@ -17806,8 +22407,9 @@ fn runtime_diagnostics_message(diagnostics: &[RuntimeDiagnostic]) -> String { async fn create_runtime_worker( State(api): State, AxumPath(runtime_id): AxumPath, - Json(mut request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { + let mut request = worker_spawn_request_from_api(request)?; validate_worker_initial_submit(&request.initial_submit)?; if let Some(assignment) = request.ticket_assignment.as_ref() && let Some(worker) = existing_lifecycle_assignment_worker(&api, assignment, &runtime_id)? @@ -17820,12 +22422,12 @@ async fn create_runtime_worker( &worker.worker.worker_id, )?; accept_queued_ticket_after_worker_spawn(&api, assignment)?; - return Ok(Json(WorkerSpawnResult { - state: WorkerOperationState::Accepted, + return Ok(Json(worker_spawn_result_to_api(WorkerSpawnResult { + state: InternalWorkerOperationState::Accepted, worker: Some(worker), acceptance_evidence: Vec::new(), diagnostics: Vec::new(), - })); + })?)); } let lifecycle_assignment = request.ticket_assignment.clone(); validate_ticket_assignment_spawn(&api, &runtime_id, &request)?; @@ -17963,26 +22565,32 @@ async fn create_runtime_worker( } } } - Ok(Json(result)) + Ok(Json(worker_spawn_result_to_api(result)?)) } async fn sync_runtime_config_bundle( State(api): State, AxumPath(runtime_id): AxumPath, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { + let bundle = serde_json::from_value::(request.bundle) + .map_err(|error| invalid_contract_value("Runtime ConfigBundle", error))?; let result = api .runtime - .sync_config_bundle(&runtime_id, request.bundle) + .sync_config_bundle(&runtime_id, bundle) .map_err(|err| err.into_error())?; - Ok(Json(result)) + Ok(Json(runtime_config_bundle_result_to_api( + result.state, + result.availability, + result.diagnostics, + )?)) } async fn check_runtime_config_bundle( State(api): State, AxumPath((runtime_id, bundle_id)): AxumPath<(String, String)>, - Query(query): Query, -) -> ApiResult> { + Query(query): Query, +) -> ApiResult> { let result = api .runtime .check_config_bundle( @@ -17993,20 +22601,25 @@ async fn check_runtime_config_bundle( }, ) .map_err(|err| err.into_error())?; - Ok(Json(result)) + Ok(Json(runtime_config_bundle_result_to_api( + result.state, + result.availability, + result.diagnostics, + )?)) } async fn send_runtime_worker_input( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { + let request = worker_input_request_from_api(request)?; let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api .runtime .send_input(&worker, request) .map_err(|err| err.into_error())?; - Ok(Json(result)) + Ok(Json(worker_input_result_to_api(result))) } async fn delete_runtime_worker_uploaded_file( @@ -18023,21 +22636,23 @@ async fn delete_runtime_worker_uploaded_file( async fn runtime_worker_completions( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { + let request = worker_completions_request_from_api(request)?; let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api .runtime .worker_completions(&worker, request) .map_err(|err| err.into_error())?; - Ok(Json(result)) + Ok(Json(worker_completions_result_to_api(result)?)) } async fn stop_runtime_worker( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { + let request = worker_lifecycle_request_from_api(request); let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api .runtime @@ -18053,20 +22668,21 @@ async fn stop_runtime_worker( { sync_linked_workdir_after_worker_stop(&api, &worker.runtime_id, &record)?; } - Ok(Json(result)) + Ok(Json(worker_lifecycle_result_to_api(result))) } async fn cancel_runtime_worker( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { + let request = worker_lifecycle_request_from_api(request); let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api .runtime .cancel_worker(&worker, request) .map_err(|err| err.into_error())?; - Ok(Json(result)) + Ok(Json(worker_lifecycle_result_to_api(result))) } fn authenticated_browser_input_source(actor: &RequestActor) -> protocol::AuthenticatedInputSource { @@ -18549,14 +23165,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(server_api::ListResponse { + Ok(Json(server_api::HostWorkerListResponse { workspace_id: api.workspace_id().to_string(), limit, items, @@ -18571,7 +23187,7 @@ async fn list_host_workers( fn project_workspace_worker( api: &WorkspaceApi, - summary: WorkerSummary, + summary: InternalWorkerSummary, ) -> ApiResult { let resource_key = api .store @@ -18610,7 +23226,7 @@ fn project_workspace_worker( fn project_observed_workspace_workers( api: &WorkspaceApi, - workers: Vec, + workers: Vec, ) -> ApiResult> { let workdirs = api .store @@ -18629,9 +23245,7 @@ fn project_observed_workspace_workers( .collect() } -fn workers_response( - api: WorkspaceApi, -) -> ApiResult> { +fn workers_response(api: WorkspaceApi) -> ApiResult { let limit = api.config.max_records.min(200); let (_, projections) = api .store @@ -18653,7 +23267,7 @@ fn workers_response( ); items.push(project_workspace_worker(&api, summary)?); } - Ok(server_api::ListResponse { + Ok(server_api::WorkerListResponse { workspace_id: api.config.workspace_id, limit, items, @@ -18665,7 +23279,7 @@ fn workers_response( 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 @@ -18707,6 +23321,7 @@ async fn workspace_runtime_resources_response( runtime_binding_summary(binding, verifications.get(&binding.runtime_id)) }), }, + http_status: StatusCode::OK.as_u16(), } }) .collect::>(); @@ -18739,7 +23354,7 @@ async fn workspace_runtime_resources_response( diagnostics: vec![ settings_diagnostic( "registered_runtime_unavailable", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "The registered Runtime is not present in the active Runtime registry.", ) .into(), @@ -18756,10 +23371,11 @@ async fn workspace_runtime_resources_response( verifications.get(&binding.runtime_id), )), }, + http_status: StatusCode::OK.as_u16(), }); } - Ok(server_api::ListResponse { + Ok(server_api::WorkspaceRuntimeListResponse { workspace_id: workspace_id.to_string(), limit, items, @@ -18877,6 +23493,7 @@ async fn workspace_runtime_detail( token_ref_configured: false, binding: Some(runtime_binding_summary(&binding, None)), }, + http_status: StatusCode::OK.as_u16(), }); } let mut resource = resource.ok_or_else(|| Error::UnknownRuntime(runtime_id.to_string()))?; @@ -19356,7 +23973,7 @@ enum WorkerRegistryDisplayNamePolicy { fn record_worker_summary( api: &WorkspaceApi, - worker: &WorkerSummary, + worker: &InternalWorkerSummary, display_name: &str, profile: Option, display_name_policy: WorkerRegistryDisplayNamePolicy, @@ -19437,8 +24054,8 @@ fn record_worker_summary( .unwrap_or(record)) } -fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary { - WorkerSummary { +fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> InternalWorkerSummary { + InternalWorkerSummary { worker: record.worker.clone(), host_id: "backend-registry".to_string(), display_name: record.display_name.clone(), @@ -19450,24 +24067,24 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary last_seen_at: Some(record.updated_at.clone()), pinned: record.retention_state == "pinned", retention_state: record.retention_state.clone(), - capabilities: WorkerCapabilitySummary { + capabilities: InternalWorkerCapabilitySummary { can_stop: false, can_spawn_followup: false, }, - workspace: WorkerWorkspaceSummary { + workspace: InternalWorkerWorkspaceSummary { visibility: "backend_registry".to_string(), identity: record.workspace_id.clone(), workspace_id: Some(record.workspace_id.clone()), }, profile: record.profile.clone(), - implementation: WorkerImplementationSummary { + implementation: InternalWorkerImplementationSummary { kind: "backend_worker_registry".to_string(), display_hint: "Missing Worker".to_string(), }, workdir_attachments: Vec::new(), diagnostics: vec![RuntimeDiagnostic { code: "backend_worker_missing".to_string(), - severity: DiagnosticSeverity::Info, + severity: HostDiagnosticSeverity::Info, message: "Worker is preserved in the Backend registry but the Runtime did not find it by id" .to_string(), @@ -19477,7 +24094,7 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary fn worker_summary_from_projection( projection: &crate::store::WorkerRegistryProjectionRecord, -) -> Option { +) -> Option { let observation = projection.observation.as_ref()?; let mut summary = worker_summary_from_registry(&projection.registry); let observed = observation.availability @@ -19507,7 +24124,7 @@ fn worker_summary_from_projection( } else { vec![RuntimeDiagnostic { code: "worker_observation_unavailable".to_string(), - severity: DiagnosticSeverity::Info, + severity: HostDiagnosticSeverity::Info, message: "The Worker remains in the Backend catalog, but its Runtime observation is currently unavailable.".to_string(), }] }; @@ -19515,11 +24132,11 @@ fn worker_summary_from_projection( } fn merge_worker_registry_projection( - live: Option<&WorkerSummary>, + live: Option<&InternalWorkerSummary>, record: &WorkerRegistryRecord, links: Vec, workdirs: &[WorkdirRegistryRecord], -) -> WorkerSummary { +) -> InternalWorkerSummary { let mut summary = live .cloned() .unwrap_or_else(|| worker_summary_from_registry(record)); @@ -19556,7 +24173,7 @@ fn merge_worker_registry_projection( fn sync_worker_observation( api: &WorkspaceApi, - worker: &WorkerSummary, + worker: &InternalWorkerSummary, ) -> ApiResult { let record = record_worker_summary( api, @@ -19578,11 +24195,14 @@ fn sync_worker_observation( && link.alias == attachment.alias.as_str() && link.workdir_id == working_directory.working_directory_id }) { + let capabilities = + ticket_target_capabilities_for_workdir(api, &record.worker, &workdir_record)?; link_worker_to_workdir( api, &record, attachment.alias.as_str(), &working_directory.working_directory_id, + capabilities, None, )?; } @@ -20016,6 +24636,7 @@ fn link_worker_to_workdir( worker_record: &WorkerRegistryRecord, alias: &str, workdir_id: &str, + capabilities: workdir::WorkdirSessionCapabilities, reservation_id: Option<&str>, ) -> ApiResult<()> { let timestamp = now_registry_timestamp(); @@ -20024,6 +24645,7 @@ fn link_worker_to_workdir( worker: worker_record.worker.clone(), workdir_id: workdir_id.to_string(), alias: alias.to_string(), + capabilities, linked_at: timestamp, unlinked_at: None, }; @@ -20072,7 +24694,7 @@ fn validate_working_directory_claims_for_browser( }, vec![RuntimeDiagnostic { code: "working_directory_relative_cwd_invalid".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "relative_cwd must be a relative path without parent traversal".to_string(), }], )); @@ -20664,7 +25286,7 @@ fn worker_create_not_accepted_error( ) -> ApiError { diagnostics.push(settings_diagnostic( "workspace_worker_create_not_accepted", - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, "Runtime did not accept worker creation; see diagnostics for sanitized Runtime compatibility details.", )); ApiError::with_diagnostics( @@ -20682,7 +25304,7 @@ fn settings_bad_request(code: &'static str, message: &'static str) -> ApiError { Error::InvalidInput(format!("{code}: {message}")), vec![settings_diagnostic( code, - DiagnosticSeverity::Error, + HostDiagnosticSeverity::Error, message, )], ) @@ -20690,7 +25312,7 @@ fn settings_bad_request(code: &'static str, message: &'static str) -> ApiError { fn settings_diagnostic( code: impl Into, - severity: DiagnosticSeverity, + severity: HostDiagnosticSeverity, message: impl Into, ) -> RuntimeDiagnostic { RuntimeDiagnostic { @@ -20716,7 +25338,7 @@ fn repository_lookup(result: std::result::Result) - }, vec![RuntimeDiagnostic { code: "repository_not_configured".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message, }], ) @@ -20733,7 +25355,7 @@ fn repository_lookup(result: std::result::Result) - }, vec![RuntimeDiagnostic { code: "repository_provider_unsupported".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message, }], ) @@ -20748,7 +25370,7 @@ fn repository_lookup(result: std::result::Result) - }, vec![RuntimeDiagnostic { code: "repository_evidence_invalid".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message, }], ) @@ -20908,7 +25530,7 @@ fn workspace_id_mismatch_error() -> ApiError { Error::WorkspaceIdMismatch, vec![RuntimeDiagnostic { code: "workspace_id_mismatch".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message, }], ) @@ -20938,12 +25560,12 @@ impl From for ApiError { let diagnostics = match &error { Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic { code: code.clone(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(message), }], Error::WorkspaceSigningIdentity { code, message } => vec![RuntimeDiagnostic { code: code.clone(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(message), }], Error::Ticket(ticket_error) => vec![RuntimeDiagnostic { @@ -20968,6 +25590,12 @@ impl From for ApiError { ticket::TicketError::InvalidTargetSelector { .. } => { "ticket_target_selector_invalid" } + ticket::TicketError::DuplicateTargetRepository(_) => { + "ticket_target_repository_duplicate" + } + ticket::TicketError::InvalidReadWriteTargetCount(_) => { + "ticket_target_read_write_count_invalid" + } ticket::TicketError::TargetAuthorityUnavailable => { "ticket_target_authority_unavailable" } @@ -20978,7 +25606,7 @@ impl From for ApiError { | ticket::TicketError::Sqlite(_) => "ticket_backend_error", } .to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: sanitize_backend_error(&ticket_error.to_string()), }], _ => Vec::new(), @@ -21181,8 +25809,8 @@ mod tests { use worker_runtime::working_directory::WorkingDirectoryMaterializer; use crate::hosts::{ - TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement, - WorkerSpawnIntent, + InternalWorkerOperationState, TicketWorkerRole, WorkerInputKind, + WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, }; use crate::store::{ AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord, @@ -21405,6 +26033,7 @@ mod tests { worker: worker.clone(), workdir_id: grant.workdir_id.clone(), alias: "expired-external".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, linked_at: created_at, unlinked_at: None, }; @@ -21818,6 +26447,7 @@ mod tests { worker: worker.clone(), workdir_id: "analysis-repository".to_string(), alias: "repository".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, linked_at: TEST_CREATED_AT.to_string(), unlinked_at: None, }; @@ -21826,13 +26456,23 @@ mod tests { worker: worker.clone(), workdir_id: grant.working_directory_id.clone(), alias: "session-logs".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: TEST_CREATED_AT.to_string(), unlinked_at: None, }; api.store.attach_worker_workdir(&repository_link).unwrap(); api.store.attach_worker_workdir(&external_link).unwrap(); sync_runtime_worker_workdir_attachments(&api, &worker).unwrap(); - + let links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap(); + assert_eq!(links.len(), 2); + assert!( + links.iter().all(|link| { + link.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY + }) + ); let provider_root = tempfile::tempdir().unwrap(); fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap(); let local_session = workdir::LocalWorkdirSession::external_read_only( @@ -21985,10 +26625,11 @@ mod tests { #[derive(Clone, Default)] struct WorkdirlessFixtureRuntime { - workers: Arc>>, + workers: Arc>>, spawn_requests: Arc>>, reject_next_spawn: Arc>, workdir_repositories: Arc>>, + logical_attachments: Arc>>>, } impl WorkdirlessFixtureRuntime { @@ -22015,6 +26656,15 @@ mod tests { .insert(working_directory_id.to_string(), repository_id.to_string()); } + fn logical_attachments(&self, worker_id: &str) -> Vec { + self.logical_attachments + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(worker_id) + .cloned() + .unwrap_or_default() + } + fn configured_workdir_status( &self, working_directory_id: &str, @@ -22060,8 +26710,8 @@ mod tests { fn worker_summary( binding: WorkerCreateBinding, request: &WorkerSpawnRequest, - ) -> WorkerSummary { - WorkerSummary { + ) -> InternalWorkerSummary { + InternalWorkerSummary { worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, binding.worker_id.to_string()), host_id: "workdirless-host".to_string(), display_name: request @@ -22079,7 +26729,7 @@ mod tests { }, singleton_key: None, tags: Vec::new(), - workspace: WorkerWorkspaceSummary { + workspace: InternalWorkerWorkspaceSummary { visibility: "workspace".to_string(), identity: TEST_WORKSPACE_ID.to_string(), workspace_id: Some(TEST_WORKSPACE_ID.to_string()), @@ -22089,11 +26739,11 @@ mod tests { last_seen_at: None, pinned: false, retention_state: "normal".to_string(), - implementation: WorkerImplementationSummary { + implementation: InternalWorkerImplementationSummary { kind: "fixture".to_string(), display_hint: "Workdirless fixture".to_string(), }, - capabilities: WorkerCapabilitySummary { + capabilities: InternalWorkerCapabilitySummary { can_stop: true, can_spawn_followup: false, }, @@ -22108,13 +26758,13 @@ mod tests { Self::RUNTIME_ID } - fn runtime_summary(&self, _limit: usize) -> crate::hosts::RuntimeSummary { - crate::hosts::RuntimeSummary { + fn runtime_summary(&self, _limit: usize) -> crate::hosts::InternalRuntimeSummary { + crate::hosts::InternalRuntimeSummary { runtime_id: Self::RUNTIME_ID.to_string(), label: "Workdirless Runtime".to_string(), kind: "fixture".to_string(), status: "active".to_string(), - source: crate::hosts::RuntimeSourceSummary::remote_http(), + source: crate::hosts::InternalRuntimeSourceSummary::remote_http(), host_ids: vec!["workdirless-host".to_string()], worker_creation_available: true, os: "linux".to_string(), @@ -22123,14 +26773,14 @@ mod tests { } } - fn list_hosts(&self, _limit: usize) -> crate::hosts::RuntimeList { + fn list_hosts(&self, _limit: usize) -> crate::hosts::RuntimeList { crate::hosts::RuntimeList { items: Vec::new(), diagnostics: Vec::new(), } } - fn list_workers(&self, limit: usize) -> crate::hosts::RuntimeList { + fn list_workers(&self, limit: usize) -> crate::hosts::RuntimeList { crate::hosts::RuntimeList { items: self .workers @@ -22162,7 +26812,7 @@ mod tests { working_directory_id: &str, ) -> crate::hosts::RuntimeWorkingDirectoryResult { crate::hosts::RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, working_directory: Some(self.configured_workdir_status(working_directory_id)), diagnostics: Vec::new(), } @@ -22173,7 +26823,7 @@ mod tests { _working_directory_id: &str, ) -> crate::hosts::RuntimeWorkingDirectoryResult { crate::hosts::RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, working_directory: None, diagnostics: Vec::new(), } @@ -22195,12 +26845,12 @@ mod tests { .unwrap_or_else(std::sync::PoisonError::into_inner), ) { return WorkerSpawnResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), diagnostics: vec![RuntimeDiagnostic { code: "fixture_runtime_rejected".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "fixture rejected Worker create".to_string(), }], }; @@ -22222,7 +26872,7 @@ mod tests { .unwrap_or_else(std::sync::PoisonError::into_inner) .push(worker.clone()); WorkerSpawnResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: Some(worker), acceptance_evidence: Vec::new(), diagnostics: Vec::new(), @@ -22235,7 +26885,7 @@ mod tests { _request: WorkerLifecycleRequest, ) -> WorkerLifecycleResult { WorkerLifecycleResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id), diagnostics: Vec::new(), } @@ -22247,7 +26897,7 @@ mod tests { _request: WorkerLifecycleRequest, ) -> WorkerLifecycleResult { WorkerLifecycleResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id), diagnostics: Vec::new(), } @@ -22261,20 +26911,49 @@ mod tests { let previous_len = workers.len(); workers.retain(|worker| worker.worker.worker_id != worker_id); crate::hosts::WorkerDeleteResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id), deleted: workers.len() != previous_len, diagnostics: Vec::new(), } } + fn restore_worker(&self, worker_id: &str) -> InternalWorkerRestoreResult { + let worker = self.worker(worker_id).worker; + InternalWorkerRestoreResult { + state: if worker.is_some() { + server_api::WorkerRestoreState::Accepted + } else { + server_api::WorkerRestoreState::Rejected + }, + worker, + diagnostics: Vec::new(), + } + } + fn replace_worker_workspace_api( &self, worker_id: &str, _workspace_api: WorkspaceApiRef, ) -> crate::hosts::WorkerWorkspaceApiResult { crate::hosts::WorkerWorkspaceApiResult { - state: WorkerOperationState::Accepted, + state: InternalWorkerOperationState::Accepted, + worker: self.worker(worker_id).worker, + diagnostics: Vec::new(), + } + } + + fn replace_worker_workdir_attachments( + &self, + worker_id: &str, + attachments: Vec, + ) -> crate::hosts::WorkerWorkspaceApiResult { + self.logical_attachments + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(worker_id.to_string(), attachments); + crate::hosts::WorkerWorkspaceApiResult { + state: InternalWorkerOperationState::Accepted, worker: self.worker(worker_id).worker, diagnostics: Vec::new(), } @@ -22359,10 +27038,10 @@ mod tests { #[test] fn workspace_deletion_execution_is_server_owned_and_polling_is_read_only() { let source = include_str!("server.rs"); - let start = handler_source(source, "start_server_workspace_deletion"); + let start = handler_source(source, "workspace_deletion_start"); assert!(start.contains("schedule_workspace_deletion")); assert!(!start.contains("execute_workspace_deletion(&request")); - let poll = handler_source(source, "get_server_workspace_deletion"); + let poll = handler_source(source, "workspace_deletion_get"); assert!(!poll.contains("execute_workspace_deletion")); assert!(source.contains("api.recover_workspace_deletions().await?")); } @@ -22400,9 +27079,10 @@ mod tests { .find(&format!("async fn {name}")) .unwrap_or_else(|| panic!("missing handler {name}")); let tail = &source[start..]; - let end = tail[1..] - .find("\nasync fn ") - .map(|offset| offset + 1) + let end = ["\nasync fn ", "\n async fn "] + .into_iter() + .filter_map(|prefix| tail[1..].find(prefix).map(|offset| offset + 1)) + .min() .unwrap_or(tail.len()); &tail[..end] } @@ -22629,6 +27309,113 @@ mod tests { )); } + #[tokio::test] + async fn generated_attachment_upload_route_preserves_the_host_specific_body_limit() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let successful_body = vec![0, 255, 1, 2]; + let successful_sha256 = attachment_body_sha256(&successful_body); + let successful_file = completed_upload_file(&successful_sha256); + api.attachment_upload_grants.lock().unwrap().insert( + "completed".to_string(), + AttachmentUploadGrant { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-1".to_string(), + worker_id: "worker-1".to_string(), + account_id: format!("account-{TEST_WORKSPACE_ID}"), + file_name: successful_file.file_name.clone(), + media_type: successful_file.media_type.clone(), + expires_at_ms: attachment_upload_now_ms().unwrap() + 60_000, + state: AttachmentUploadGrantState::Completed(successful_file.clone()), + }, + ); + let router = generated_workspace_contract_router(ServerApiContractService::Workspace(api)) + .layer(Extension(test_owner_actor())); + let uri = format!( + "/api/w/{TEST_WORKSPACE_ID}/runtimes/runtime-1/workers/worker-1/attachment-uploads/unknown" + ); + + for body in [ + Vec::new(), + vec![0, 1, 2], + vec![0; MAX_WORKER_FILE_UPLOAD_BYTES], + ] { + let response = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(&uri) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "bodies through the exact route limit must reach upload grant validation" + ); + } + + let completed_uri = format!( + "/api/w/{TEST_WORKSPACE_ID}/runtimes/runtime-1/workers/worker-1/attachment-uploads/completed" + ); + let response = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(&completed_uri) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(successful_body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let uploaded: WorkerFileUploadResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(uploaded.file, successful_file); + + let response = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(completed_uri) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(vec![9, 9, 9])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + + let response = router + .oneshot( + Request::builder() + .method("PUT") + .uri(uri) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(vec![0; MAX_WORKER_FILE_UPLOAD_BYTES + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/json" + ); + let error: server_api::RepositoryApiError = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(error.error, "Payload Too Large"); + } + #[tokio::test] async fn command_session_survives_attachment_refresh_until_worker_revocation() { let directory = tempfile::tempdir().unwrap(); @@ -23021,7 +27808,7 @@ mod tests { message: "sqlite error: FOREIGN KEY constraint failed".to_string(), diagnostics: vec![RuntimeDiagnostic { code: "ticket_backend_error".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "FOREIGN KEY constraint failed".to_string(), }], }; @@ -23157,7 +27944,7 @@ mod tests { StatusCode::INTERNAL_SERVER_ERROR, ), ] { - let response = WorkdirOperationApiError::Provider(WorkdirTransportError { + let response = InternalWorkdirOperationError::Provider(WorkdirTransportError { code, message: "safe provider message".to_string(), }) @@ -23193,7 +27980,7 @@ mod tests { assert_eq!(local, remote); for public in [local, remote] { - let response = WorkdirOperationApiError::Provider(public).into_response(); + let response = InternalWorkdirOperationError::Provider(public).into_response(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let decoded: WorkdirTransportError = serde_json::from_slice(&body).unwrap(); @@ -23214,7 +28001,7 @@ mod tests { &RuntimeWorkerRef::new("runtime", "worker"), error, ); - let response = WorkdirOperationApiError::Provider(public).into_response(); + let response = InternalWorkdirOperationError::Provider(public).into_response(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let text = String::from_utf8(body.to_vec()).unwrap(); assert!(!text.contains("/host/private")); @@ -23226,7 +28013,7 @@ mod tests { &RuntimeWorkerRef::new("runtime", "worker"), workdir::WorkdirError::OperationFailed, ); - let response = WorkdirOperationApiError::Provider(remote).into_response(); + let response = InternalWorkdirOperationError::Provider(remote).into_response(); assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); } @@ -23270,6 +28057,7 @@ mod tests { worker: worker.worker.clone(), workdir_id: workdir.workdir_id.clone(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "4".to_string(), unlinked_at: None, }; @@ -23636,7 +28424,7 @@ mod tests { .unwrap(); let mut create_input = ticket::NewTicket::new("Foreign repository target"); - create_input.repository_id = Some("foreign".to_string()); + set_test_ticket_target(&mut create_input, "foreign", "HEAD"); assert!( validate_ticket_repository_operation( &api, @@ -23671,7 +28459,7 @@ mod tests { let mut foreign_repository = api.config.repositories[0].clone(); foreign_repository.id = "foreign".to_string(); - let workdir_flow_launch = WorkerSpawnRequest { + let mut workdir_flow_launch = WorkerSpawnRequest { requested_worker_name: Some("cross-workspace-workdir".to_string()), intent: WorkerSpawnIntent::WorkspaceCoding, acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { @@ -23699,7 +28487,7 @@ mod tests { resolved_control_operation: None, }; assert!( - api.validate_worker_spawn_repository_scope(&workdir_flow_launch) + api.validate_worker_spawn_repository_scope("runtime-1", &mut workdir_flow_launch) .is_err() ); @@ -23974,53 +28762,41 @@ mod tests { #[tokio::test] async fn workspace_worker_endpoint_finalizes_ticket_assignment() { - let workspace = tempfile::tempdir().unwrap(); - init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; - let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Assigned Ticket"); - input.workflow_state = Some(TicketWorkflowState::Queued); - let ticket = backend.create(input).unwrap(); - assign_test_orchestrator(&api, &ticket.id); - let response = create_workspace_worker( - State(api.clone()), - HeaderMap::new(), - Json(CreateWorkspaceWorkerRequest { - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - display_name: "Assigned Coder".to_string(), - profile: Some("builtin:coder".to_string()), - ticket_assignment: Some(CreateWorkspaceWorkerTicketAssignmentRequest { - ticket_id: ticket.id.clone(), - operation_id: "workspace-endpoint-assignment".to_string(), - }), - initial_submit: vec![Segment::Flow { - selector: "builtin:coder-review".to_string(), - }], - workdir_attachments: Vec::new(), - control_operation_id: None, - }), - ) - .await - .unwrap() - .0; + let fixture = guarded_spawn_fixture().await; + let operation_id = "workspace-endpoint-assignment"; + let response = + post_guarded_spawn(&fixture, guarded_spawn_payload(&fixture, operation_id)).await; + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "unexpected guarded spawn response: {}", + String::from_utf8_lossy(&bytes) + ); + let response: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap(); assert_eq!( - api.authority.ticket(&ticket.id).unwrap().state, + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .state, TicketWorkflowState::InProgress.as_str() ); - let current = api + let current = fixture + .api .store - .get_current_ticket_coder_assignment(&api.config.workspace_id, &ticket.id) + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) .unwrap() .unwrap(); let response_ref = RuntimeWorkerRef::new(&response.runtime_id, &response.worker_id); assert_eq!(current.worker, response_ref); - let operation = api + let operation = fixture + .api .store - .get_ticket_assignment_operation( - &api.config.workspace_id, - "workspace-endpoint-assignment", - ) + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, operation_id) .unwrap() .unwrap(); assert_eq!(operation.assignment_id, Some(current.assignment_id)); @@ -24155,6 +28931,7 @@ mod tests { worker: worker.clone(), workdir_id: workdir_id.to_string(), alias: "checkout".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: TEST_CREATED_AT.to_string(), unlinked_at: None, }) @@ -24313,7 +29090,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(stopped.state, WorkerOperationState::Accepted); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); let Json(restored) = scoped_start_workspace_orchestrator( State(api.clone()), AxumPath(ScopedWorkspacePath { @@ -24409,41 +29186,51 @@ mod tests { controller: RuntimeWorkerRef, ticket_id: String, workdir_id: String, + read_only_workdir_id: String, } async fn guarded_spawn_fixture() -> GuardedSpawnFixture { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; + let api = test_api_with_docs_repository(workspace.path()).await; register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; let runtime = WorkdirlessFixtureRuntime::default(); let workdir_id = "guarded-existing-workdir".to_string(); + let read_only_workdir_id = "guarded-docs-workdir".to_string(); let repository_id = test_repository_id(&api); + let docs_repository_id = test_repository_id_by_key(&api, "docs"); + assert_ne!(repository_id, docs_repository_id); runtime.set_workdir_repository(&workdir_id, &repository_id); + runtime.set_workdir_repository(&read_only_workdir_id, &docs_repository_id); api.runtime.register_or_replace(runtime.clone()); let now = now_registry_timestamp(); - api.store - .upsert_workdir_registry(&WorkdirRegistryRecord { - workspace_id: TEST_WORKSPACE_ID.to_string(), - workdir_id: workdir_id.clone(), - display_name: None, - source: WorkdirRegistrySource::Repository { - runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), - repository_id: repository_id.clone(), - }, - creation_selector: Some("develop".to_string()), - creation_ref: None, - creation_tree: None, - current_selector: Some("develop".to_string()), - current_ref: None, - current_tree: None, - observed_at_epoch_seconds: None, - materialization_status: "present".to_string(), - cleanliness: "clean".to_string(), - created_at: now.clone(), - updated_at: now, - }) - .unwrap(); + for (workdir_id, repository_id) in [ + (&workdir_id, &repository_id), + (&read_only_workdir_id, &docs_repository_id), + ] { + api.store + .upsert_workdir_registry(&WorkdirRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: workdir_id.clone(), + display_name: None, + source: WorkdirRegistrySource::Repository { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), + repository_id: repository_id.clone(), + }, + creation_selector: Some("develop".to_string()), + creation_ref: None, + creation_tree: None, + current_selector: Some("develop".to_string()), + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".to_string(), + cleanliness: "clean".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + } let Json(controller) = create_workspace_worker( State(api.clone()), HeaderMap::new(), @@ -24462,13 +29249,21 @@ mod tests { let controller = RuntimeWorkerRef::new(controller.runtime_id, controller.worker_id); let mut input = ticket::NewTicket::new("Guarded attachment spawn"); input.workflow_state = Some(TicketWorkflowState::Queued); - input.repository_id = Some(repository_id); - input.ref_selector = Some("develop".to_string()); + input.targets = vec![ + test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), + ]; let ticket_id = browser_ticket_backend(&api) .unwrap() .create(input) .unwrap() .id; + assert_eq!( + validated_ticket_implementation_targets(&api, &ticket_id) + .unwrap() + .len(), + 2 + ); assign_test_orchestrator(&api, &ticket_id); GuardedSpawnFixture { _workspace: workspace, @@ -24477,6 +29272,7 @@ mod tests { controller, ticket_id, workdir_id, + read_only_workdir_id, } } @@ -24493,11 +29289,17 @@ mod tests { { "kind": "flow", "selector": "builtin:coder-review" }, { "kind": "text", "content": "Implement guarded attachment Ticket." }, ], - "workdir_attachments": [{ - "alias": "workdir", - "working_directory_id": fixture.workdir_id, - "relative_cwd": "crates/yoi", - }], + "workdir_attachments": [ + { + "alias": "workdir", + "working_directory_id": fixture.workdir_id, + "relative_cwd": "crates/yoi", + }, + { + "alias": "docs", + "working_directory_id": fixture.read_only_workdir_id, + } + ], "control_operation_id": operation_id, }) } @@ -24506,7 +29308,7 @@ mod tests { #[derive(Clone)] struct StrictRemoteSpawnState { - workdir_status: worker_runtime::catalog::WorkingDirectoryStatus, + workdir_statuses: Arc>, create_requests: Arc>>, } @@ -24531,18 +29333,36 @@ mod tests { AxumPath(working_directory_id): AxumPath, ) -> Json { assert_strict_remote_request_headers(&headers); - assert_eq!( - state.workdir_status.summary.working_directory_id, - working_directory_id - ); - Json(json!({ "working_directory": state.workdir_status })) + let workdir_status = state + .workdir_statuses + .iter() + .find(|status| status.summary.working_directory_id == working_directory_id) + .cloned() + .expect("strict Runtime received an unknown Workdir request"); + Json(json!({ "working_directory": workdir_status })) } fn strict_remote_worker_response( state: &StrictRemoteSpawnState, request: &runtime_api::CreateWorkerRequest, ) -> Value { - let attachment = &request.workdir_attachments[0]; + let workdir_attachments = request + .workdir_attachments + .iter() + .map(|attachment| { + let workdir_status = state + .workdir_statuses + .iter() + .find(|status| { + status.summary.working_directory_id == attachment.working_directory_id + }) + .expect("strict Runtime response requires a registered Workdir"); + json!({ + "alias": attachment.alias, + "working_directory": workdir_status, + }) + }) + .collect::>(); let profile_source = match &request.profile_source { runtime_api::ProfileSourceArchiveSource::Embedded { archive } => { archive.reference.clone() @@ -24556,10 +29376,7 @@ mod tests { "status": "idle", "execution_metadata_available": true, "workspace_id": TEST_WORKSPACE_ID, - "workdir_attachments": [{ - "alias": attachment.alias, - "working_directory": state.workdir_status, - }], + "workdir_attachments": workdir_attachments, "profile": request.profile, "display_name": request.display_name, "profile_source": profile_source, @@ -24582,14 +29399,32 @@ mod tests { let request: runtime_api::CreateWorkerRequest = serde_json::from_value(raw.clone()).unwrap(); assert!(request.workdir_attachment_requests.is_empty()); - assert_eq!(request.workdir_attachments.len(), 1); - let attachment = &request.workdir_attachments[0]; - assert_eq!(attachment.alias.as_str(), "workdir"); + assert_eq!(request.workdir_attachments.len(), 2); + let writable = request + .workdir_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "workdir") + .unwrap(); + assert_eq!(writable.relative_cwd.as_deref(), Some("crates/yoi")); assert_eq!( - attachment.working_directory_id, - state.workdir_status.summary.working_directory_id + writable.capabilities, + workdir::WorkdirSessionCapabilities::ALL ); - assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi")); + let read_only = request + .workdir_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "docs") + .unwrap(); + assert_eq!(read_only.relative_cwd, None); + assert_eq!( + read_only.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + for attachment in &request.workdir_attachments { + assert!(state.workdir_statuses.iter().any(|status| { + status.summary.working_directory_id == attachment.working_directory_id + })); + } state .create_requests .lock() @@ -24664,14 +29499,20 @@ mod tests { guarded_spawn_artifact_counts(&fixture.api, &fixture.workdir_id), (0, 0, 0) ); - assert!( - fixture - .api - .store - .list_workdir_worker_links(TEST_WORKSPACE_ID, &fixture.workdir_id) - .unwrap() - .is_empty() + assert_eq!( + guarded_spawn_artifact_counts(&fixture.api, &fixture.read_only_workdir_id), + (0, 0, 0) ); + for workdir_id in [&fixture.workdir_id, &fixture.read_only_workdir_id] { + assert!( + fixture + .api + .store + .list_workdir_worker_links(TEST_WORKSPACE_ID, workdir_id) + .unwrap() + .is_empty() + ); + } assert!( fixture .api @@ -24700,7 +29541,7 @@ mod tests { } #[tokio::test] - async fn guarded_spawn_http_boundary_preserves_canonical_initial_attachment() { + async fn guarded_spawn_http_boundary_preserves_multi_target_capabilities() { let fixture = guarded_spawn_fixture().await; let payload = guarded_spawn_payload(&fixture, "guarded-success"); assert!(payload.get("working_directory").is_none()); @@ -24715,53 +29556,96 @@ mod tests { String::from_utf8_lossy(&bytes) ); let body: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(body.worker.workdir_attachments.len(), 1); - assert_eq!(body.worker.workdir_attachments[0].alias, "workdir"); - assert_eq!( - body.worker.workdir_attachments[0] - .working_directory - .working_directory_id, - fixture.workdir_id - ); + assert_eq!(body.worker.workdir_attachments.len(), 2); + assert!(body.worker.workdir_attachments.iter().any(|attachment| { + attachment.alias == "workdir" + && attachment.working_directory.working_directory_id == fixture.workdir_id + })); + assert!(body.worker.workdir_attachments.iter().any(|attachment| { + attachment.alias == "docs" + && attachment.working_directory.working_directory_id == fixture.read_only_workdir_id + })); let requests = fixture.runtime.spawn_requests(); assert_eq!(requests.len(), 1); - assert_eq!(requests[0].resolved_workdir_attachments.len(), 1); - let attachment = &requests[0].resolved_workdir_attachments[0]; - assert_eq!(attachment.alias.as_str(), "workdir"); - assert_eq!(attachment.working_directory_id, fixture.workdir_id); - assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi")); - assert!( - fixture - .api - .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) - .unwrap() - .is_some() - ); + assert_eq!(requests[0].resolved_workdir_attachments.len(), 2); + let writable = requests[0] + .resolved_workdir_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "workdir") + .unwrap(); + assert_eq!(writable.working_directory_id, fixture.workdir_id); + assert_eq!(writable.relative_cwd.as_deref(), Some("crates/yoi")); assert_eq!( - fixture - .api - .store - .list_workdir_worker_links(TEST_WORKSPACE_ID, &fixture.workdir_id) - .unwrap() - .len(), - 1 + writable.capabilities, + workdir::WorkdirSessionCapabilities::ALL ); + let read_only = requests[0] + .resolved_workdir_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "docs") + .unwrap(); + assert_eq!(read_only.working_directory_id, fixture.read_only_workdir_id); + assert_eq!( + read_only.capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + let worker = fixture + .api + .store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .unwrap() + .worker; + let links = fixture + .api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap(); + assert_eq!(links.len(), 2); + assert!(links.iter().any(|link| { + link.workdir_id == fixture.workdir_id + && link.capabilities == workdir::WorkdirSessionCapabilities::ALL + })); + assert!(links.iter().any(|link| { + link.workdir_id == fixture.read_only_workdir_id + && link.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY + })); + } + + #[tokio::test] + async fn guarded_spawn_rejects_missing_target_before_runtime_side_effects() { + let fixture = guarded_spawn_fixture().await; + let mut payload = guarded_spawn_payload(&fixture, "guarded-missing-target"); + payload["workdir_attachments"] + .as_array_mut() + .unwrap() + .retain(|attachment| attachment["alias"] != "docs"); + + let response = post_guarded_spawn(&fixture, payload).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(fixture.runtime.spawn_requests().is_empty()); + assert_guarded_spawn_rolled_back(&fixture); } #[tokio::test(flavor = "multi_thread")] async fn guarded_spawn_crosses_strict_remote_runtime_http_boundary() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let api = test_api(workspace.path()).await; + let api = test_api_with_docs_repository(workspace.path()).await; register_test_runtime(&api, STRICT_REMOTE_RUNTIME_ID).await; let workdir_id = "guarded-strict-remote-workdir".to_string(); + let read_only_workdir_id = "guarded-strict-remote-docs-workdir".to_string(); let repository_id = test_repository_id(&api); + let docs_repository_id = test_repository_id_by_key(&api, "docs"); + assert_ne!(repository_id, docs_repository_id); let mut workdir_status = WorkdirlessFixtureRuntime::workdir_status(&workdir_id); workdir_status.summary.repository_id = repository_id.clone(); + let mut read_only_workdir_status = + WorkdirlessFixtureRuntime::workdir_status(&read_only_workdir_id); + read_only_workdir_status.summary.repository_id = docs_repository_id.clone(); let strict_state = StrictRemoteSpawnState { - workdir_status, + workdir_statuses: Arc::new(vec![workdir_status, read_only_workdir_status]), create_requests: Arc::new(Mutex::new(Vec::new())), }; let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -24801,28 +29685,33 @@ mod tests { .unwrap(), ); let now = now_registry_timestamp(); - api.store - .upsert_workdir_registry(&WorkdirRegistryRecord { - workspace_id: TEST_WORKSPACE_ID.to_string(), - workdir_id: workdir_id.clone(), - display_name: None, - source: WorkdirRegistrySource::Repository { - runtime_id: STRICT_REMOTE_RUNTIME_ID.to_string(), - repository_id: repository_id.clone(), - }, - creation_selector: Some("develop".to_string()), - creation_ref: None, - creation_tree: None, - current_selector: Some("develop".to_string()), - current_ref: None, - current_tree: None, - observed_at_epoch_seconds: None, - materialization_status: "present".to_string(), - cleanliness: "clean".to_string(), - created_at: now.clone(), - updated_at: now, - }) - .unwrap(); + for (workdir_id, repository_id) in [ + (&workdir_id, &repository_id), + (&read_only_workdir_id, &docs_repository_id), + ] { + api.store + .upsert_workdir_registry(&WorkdirRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: workdir_id.clone(), + display_name: None, + source: WorkdirRegistrySource::Repository { + runtime_id: STRICT_REMOTE_RUNTIME_ID.to_string(), + repository_id: repository_id.clone(), + }, + creation_selector: Some("develop".to_string()), + creation_ref: None, + creation_tree: None, + current_selector: Some("develop".to_string()), + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".to_string(), + cleanliness: "clean".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + } let Json(controller) = create_workspace_worker( State(api.clone()), HeaderMap::new(), @@ -24841,8 +29730,10 @@ mod tests { let controller = RuntimeWorkerRef::new(controller.runtime_id, controller.worker_id); let mut ticket = ticket::NewTicket::new("Strict remote guarded attachment spawn"); ticket.workflow_state = Some(TicketWorkflowState::Queued); - ticket.repository_id = Some(repository_id); - ticket.ref_selector = Some("develop".to_string()); + ticket.targets = vec![ + test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), + ]; let ticket_id = browser_ticket_backend(&api) .unwrap() .create(ticket) @@ -24861,11 +29752,17 @@ mod tests { { "kind": "flow", "selector": "builtin:coder-review" }, { "kind": "text", "content": "Implement strict remote attachment Ticket." }, ], - "workdir_attachments": [{ - "alias": "workdir", - "working_directory_id": workdir_id, - "relative_cwd": "crates/yoi", - }], + "workdir_attachments": [ + { + "alias": "workdir", + "working_directory_id": workdir_id, + "relative_cwd": "crates/yoi", + }, + { + "alias": "docs", + "working_directory_id": read_only_workdir_id, + } + ], "control_operation_id": "guarded-strict-remote-success", }); assert!(payload.get("working_directory").is_none()); @@ -24893,14 +29790,15 @@ mod tests { ); let body: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap(); assert_eq!(body.worker.runtime_id, STRICT_REMOTE_RUNTIME_ID); - assert_eq!(body.worker.workdir_attachments.len(), 1); - assert_eq!(body.worker.workdir_attachments[0].alias.as_str(), "workdir"); - assert_eq!( - body.worker.workdir_attachments[0] - .working_directory - .working_directory_id, - workdir_id - ); + assert_eq!(body.worker.workdir_attachments.len(), 2); + assert!(body.worker.workdir_attachments.iter().any(|attachment| { + attachment.alias.as_str() == "workdir" + && attachment.working_directory.working_directory_id == workdir_id + })); + assert!(body.worker.workdir_attachments.iter().any(|attachment| { + attachment.alias.as_str() == "docs" + && attachment.working_directory.working_directory_id == read_only_workdir_id + })); let requests = strict_state .create_requests .lock() @@ -24909,22 +29807,28 @@ mod tests { assert!(requests[0].get("working_directory").is_none()); assert_eq!( requests[0]["workdir_attachments"].as_array().unwrap().len(), - 1 + 2 ); drop(requests); - assert!( - api.store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id) - .unwrap() - .is_some() - ); - assert_eq!( - api.store - .list_workdir_worker_links(TEST_WORKSPACE_ID, &workdir_id) - .unwrap() - .len(), - 1 - ); + let worker = api + .store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id) + .unwrap() + .unwrap() + .worker; + let links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap(); + assert_eq!(links.len(), 2); + assert!(links.iter().any(|link| { + link.workdir_id == workdir_id + && link.capabilities == workdir::WorkdirSessionCapabilities::ALL + })); + assert!(links.iter().any(|link| { + link.workdir_id == read_only_workdir_id + && link.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY + })); runtime_server.abort(); } @@ -25226,7 +30130,8 @@ mod tests { .await .unwrap(); assert_eq!(known.items.len(), 1); - assert_eq!(known.items[0].subject, generic_ref); + assert_eq!(known.items[0].subject.runtime_id, generic_ref.runtime_id); + assert_eq!(known.items[0].subject.worker_id, generic_ref.worker_id); assert_eq!(known.items[0].permissions, ["observe"]); let Json(sessions) = scoped_list_worker_observation_sessions( @@ -25238,31 +30143,29 @@ mod tests { ) .await .unwrap(); - assert!( - sessions["sessions"] - .as_array() - .unwrap() - .iter() - .any(|session| { - session["subject"]["kind"] == "runtime_worker" - && session["subject"]["runtime_id"] == generic.runtime_id - && session["subject"]["worker_id"] == generic.worker_id - }) - ); + assert!(sessions.sessions.iter().any(|session| { + matches!( + &session.subject, + server_api::WorkerObservationSubjectRef::RuntimeWorker { + runtime_id, + worker_id, + } if runtime_id == &generic.runtime_id && worker_id == &generic.worker_id + ) + })); let Json(capture) = scoped_capture_worker_observation_session( State(api.clone()), AxumPath(ScopedWorkspacePath { workspace_id: workspace_id.clone(), }), observation_headers.clone(), - Json(WorkerObservationSubjectRef::RuntimeWorker { + Json(server_api::WorkerObservationSubjectRef::RuntimeWorker { runtime_id: generic.runtime_id.clone(), worker_id: generic.worker_id.clone(), }), ) .await .unwrap(); - assert!(capture["session"]["entries"].is_array()); + assert!(!capture.segment_id.is_empty()); let revoked = api .store @@ -25279,7 +30182,7 @@ mod tests { workspace_id: workspace_id.clone(), }), observation_headers.clone(), - Json(WorkerObservationSubjectRef::RuntimeWorker { + Json(server_api::WorkerObservationSubjectRef::RuntimeWorker { runtime_id: generic.runtime_id.clone(), worker_id: generic.worker_id.clone(), }), @@ -25309,7 +30212,7 @@ mod tests { ) .await .unwrap(); - assert!(unauthorized["sessions"].as_array().unwrap().is_empty()); + assert!(unauthorized.sessions.is_empty()); let Json(existing) = scoped_start_workspace_orchestrator( State(api.clone()), @@ -25401,6 +30304,7 @@ mod tests { worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "7"), workdir_id: "managed".to_string(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "3".to_string(), unlinked_at: None, }) @@ -25883,7 +30787,7 @@ mod tests { fn unrecoverable_pending_workspace_restore_is_typed_for_replacement() { let diagnostics = [RuntimeDiagnostic { code: "embedded_worker_execution_rejected".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "Restore Errored: pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config".to_string(), }]; @@ -25894,7 +30798,7 @@ mod tests { fn workdir_runtime_miss_uses_exact_typed_code() { let typed_not_found = [RuntimeDiagnostic { code: "working_directory_not_found".to_string(), - severity: DiagnosticSeverity::Warning, + severity: HostDiagnosticSeverity::Warning, message: "missing".to_string(), }]; assert_eq!( @@ -25905,7 +30809,7 @@ mod tests { let unrelated = [RuntimeDiagnostic { code: "some_other_not_found".to_string(), - severity: DiagnosticSeverity::Warning, + severity: HostDiagnosticSeverity::Warning, message: "not a typed workdir miss".to_string(), }]; assert_eq!(workdir_status_from_runtime_miss(&unrelated), "unknown"); @@ -27518,6 +32422,255 @@ mod tests { assert_ne!(same_origin.status(), StatusCode::FORBIDDEN); } + #[tokio::test] + async fn generated_passkey_routes_complete_registration_login_and_logout_cookie_lifecycle() { + use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + cookie_name, + } = &mut api.config.auth; + *rp_id = "workspace.test".to_string(); + *origin = "https://workspace.test".to_string(); + *public_base_url = origin.clone(); + let origin = origin.clone(); + let cookie_name = cookie_name.clone(); + let app = generated_auth_contract_router(ServerApiContractService::Workspace(api)); + let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let authenticator_origin = url::Url::parse(&origin).unwrap(); + + let registration_options_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/registration/options") + .header(ORIGIN, &origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&PasskeyRegistrationOptionsRequest { + handle: "generated-passkey-user".to_string(), + display_name: Some("Generated Passkey User".to_string()), + browser_origin: None, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(registration_options_response.status(), StatusCode::OK); + let registration_options: PasskeyRegistrationOptionsResponse = serde_json::from_slice( + &to_bytes(registration_options_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + let registration_credential = authenticator + .do_registration( + authenticator_origin.clone(), + registration_options.public_key, + ) + .unwrap(); + + let registration_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/registration/complete") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&PasskeyRegistrationCompleteRequest { + challenge_id: registration_options.challenge_id, + credential: registration_credential, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(registration_response.status(), StatusCode::OK); + let registration_cookie = registration_response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(registration_cookie.starts_with(&format!("{cookie_name}="))); + assert!(registration_cookie.contains("; Secure")); + assert!(registration_cookie.contains("; HttpOnly; SameSite=Lax")); + let registration_body: AuthUserResponse = serde_json::from_slice( + &to_bytes(registration_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(registration_body.user.handle, "generated-passkey-user"); + let registration_cookie_pair = registration_cookie.split(';').next().unwrap().to_string(); + + let registered_whoami = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, ®istration_cookie_pair) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(registered_whoami.status(), StatusCode::OK); + let registered_whoami: Value = serde_json::from_slice( + &to_bytes(registered_whoami.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + registered_whoami["actor"]["handle"], + "generated-passkey-user" + ); + + let login_options_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/login/options") + .header(ORIGIN, &origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&PasskeyLoginOptionsRequest { + handle: Some("generated-passkey-user".to_string()), + browser_origin: None, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login_options_response.status(), StatusCode::OK); + let login_options: PasskeyLoginOptionsResponse = serde_json::from_slice( + &to_bytes(login_options_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + let login_credential = authenticator + .do_authentication(authenticator_origin, login_options.public_key) + .unwrap(); + + let login_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/login/complete") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&PasskeyLoginCompleteRequest { + challenge_id: login_options.challenge_id, + credential: login_credential, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login_response.status(), StatusCode::OK); + let login_cookie = login_response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(login_cookie.starts_with(&format!("{cookie_name}="))); + assert!(login_cookie.contains("; Secure")); + assert!(login_cookie.contains("; HttpOnly; SameSite=Lax")); + let login_body: AuthUserResponse = serde_json::from_slice( + &to_bytes(login_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(login_body.user, registration_body.user); + let login_cookie_pair = login_cookie.split(';').next().unwrap().to_string(); + + let logged_in_whoami = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, &login_cookie_pair) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logged_in_whoami.status(), StatusCode::OK); + let logged_in_whoami: Value = serde_json::from_slice( + &to_bytes(logged_in_whoami.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + logged_in_whoami["actor"]["handle"], + "generated-passkey-user" + ); + + let logout_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/logout") + .header(axum::http::header::COOKIE, &login_cookie_pair) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout_response.status(), StatusCode::OK); + let logout_cookie = logout_response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(logout_cookie.starts_with(&format!("{cookie_name}="))); + assert!(logout_cookie.contains("Max-Age=0")); + + let logged_out_whoami = app + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, login_cookie_pair) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logged_out_whoami.status(), StatusCode::OK); + let logged_out_whoami: Value = serde_json::from_slice( + &to_bytes(logged_out_whoami.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert!(logged_out_whoami["actor"].is_null()); + } + #[tokio::test] async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() { for (scheme, secure) in [("http", false), ("https", true)] { @@ -27538,15 +32691,30 @@ mod tests { .unwrap(); let auth_api = ServerAuthApi::from(&api); - let login = issue_browser_session_response(&auth_api, user).unwrap(); - let login_cookie = login.headers().get(SET_COOKIE).unwrap().to_str().unwrap(); + let (_, login) = issue_browser_session(&auth_api, user).unwrap(); + let login_cookie = login.expose(); assert_eq!(login_cookie.contains("; Secure"), secure, "{scheme}"); assert!(login_cookie.contains("; HttpOnly; SameSite=Lax")); - let logout = post_auth_logout(State(auth_api), HeaderMap::new()) + let app = generated_auth_contract_router(ServerApiContractService::Workspace(api)); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/logout") + .header(axum::http::header::COOKIE, login_cookie) + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); - let logout_cookie = logout.headers().get(SET_COOKIE).unwrap().to_str().unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let logout_cookie = response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); assert_eq!(logout_cookie.contains("; Secure"), secure, "{scheme}"); assert!(logout_cookie.contains("Max-Age=0")); assert!(logout_cookie.contains("; HttpOnly; SameSite=Lax")); @@ -27877,7 +33045,7 @@ mod tests { .unwrap(); assert_eq!( existing.state, - WorkerOperationState::Accepted, + InternalWorkerOperationState::Accepted, "diagnostics: {:?}", existing.diagnostics ); @@ -27961,10 +33129,9 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); - let repository_id = test_repository_id(&api); + let repository_key = "test-repository"; let mut input = ticket::NewTicket::new("Validated target"); - input.repository_id = Some(repository_id.clone()); - input.ref_selector = Some("develop".to_owned()); + set_test_ticket_target(&mut input, repository_key, "develop"); let ticket_ref = backend.create(input).unwrap(); let request = ticket::TicketMarkReady { operation_key: "ready-server-test".to_owned(), @@ -27977,10 +33144,13 @@ mod tests { .unwrap(); assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); assert_eq!( - ready.meta.repository_id.as_deref(), - Some(repository_id.as_str()) + ready.meta.targets, + vec![test_ticket_target( + repository_key, + "develop", + TicketTargetAccess::ReadWrite, + )] ); - assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop")); assert_eq!( backend .mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request) @@ -27995,9 +33165,12 @@ mod tests { backend.edit_item( TicketIdOrSlug::Id(ticket_ref.id.clone()), ticket::TicketItemEdit { - target: Some(ticket::TicketTargetEdit::Set { - repository_id: TEST_REPOSITORY_ID.to_owned(), - ref_selector: Some("other".to_owned()), + targets: Some(ticket::TicketTargetsEdit::Set { + targets: vec![test_ticket_target( + "test-repository", + "other", + TicketTargetAccess::ReadWrite, + )], }), ..Default::default() }, @@ -28006,7 +33179,7 @@ mod tests { )); let mut missing = ticket::NewTicket::new("Missing target"); - missing.repository_id = Some("unknown".to_owned()); + set_test_ticket_target(&mut missing, "unknown", "develop"); assert!(backend.create(missing).is_err()); assert!(matches!( backend.set_workflow_state( @@ -28017,6 +33190,29 @@ mod tests { )); } + #[tokio::test] + async fn merge_request_target_rejects_read_only_repository() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api_with_docs_repository(dir.path()).await; + let writable_repository_id = test_repository_id(&api); + let docs_repository_id = test_repository_id_by_key(&api, "docs"); + let mut input = ticket::NewTicket::new("Read-only MR target"); + input.workflow_state = Some(TicketWorkflowState::Queued); + input.targets = vec![ + test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), + ]; + let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); + + require_ticket_read_write_target(&api, &ticket.id, &writable_repository_id, "develop") + .unwrap(); + let error = + require_ticket_read_write_target(&api, &ticket.id, &docs_repository_id, "develop") + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); + } + #[test] fn worker_source_actor_roles_use_canonical_vocabulary() { assert_eq!(worker_source_actor_role(true, false), "coder"); @@ -28148,8 +33344,7 @@ mod tests { execution.take_inputs(); let mut input = ticket::NewTicket::new("Bounded notification"); - input.repository_id = Some(test_repository_id(&api)); - input.ref_selector = Some("develop".to_owned()); + set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); let ticket_id = TicketIdOrSlug::Id(ticket.id.clone()); @@ -28245,6 +33440,403 @@ mod tests { } } + struct ManualCoderAssignmentFixture { + _workspace: tempfile::TempDir, + api: WorkspaceApi, + runtime: WorkdirlessFixtureRuntime, + ticket_id: String, + worker: RuntimeWorkerRef, + main_workdir_id: String, + docs_workdir_id: String, + } + + async fn manual_coder_assignment_fixture() -> ManualCoderAssignmentFixture { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api_with_docs_repository(workspace.path()).await; + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let Json(created) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), + display_name: "Manual Coder candidate".to_string(), + profile: Some("builtin:coder".to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id); + let main_workdir_id = "manual-main-workdir".to_string(); + let docs_workdir_id = "manual-docs-workdir".to_string(); + let now = now_registry_timestamp(); + for (workdir_id, repository_id, alias) in [ + ( + &main_workdir_id, + test_repository_id(&api), + "checkout".to_string(), + ), + ( + &docs_workdir_id, + test_repository_id_by_key(&api, "docs"), + "docs".to_string(), + ), + ] { + api.store + .upsert_workdir_registry(&WorkdirRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: workdir_id.clone(), + display_name: None, + source: WorkdirRegistrySource::Repository { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), + repository_id, + }, + creation_selector: Some("develop".to_string()), + creation_ref: None, + creation_tree: None, + current_selector: Some("develop".to_string()), + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".to_string(), + cleanliness: "clean".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + api.store + .attach_worker_workdir(&WorkerWorkdirLinkRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: worker.clone(), + workdir_id: workdir_id.clone(), + alias, + capabilities: workdir::WorkdirSessionCapabilities::ALL, + linked_at: now.clone(), + unlinked_at: None, + }) + .unwrap(); + } + sync_runtime_worker_workdir_attachments(&api, &worker).unwrap(); + let mut input = ticket::NewTicket::new("Manual multi-target Coder"); + input.workflow_state = Some(TicketWorkflowState::Ready); + input.targets = vec![ + test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), + ]; + let ticket_id = browser_ticket_backend(&api) + .unwrap() + .create(input) + .unwrap() + .id; + ManualCoderAssignmentFixture { + _workspace: workspace, + api, + runtime, + ticket_id, + worker, + main_workdir_id, + docs_workdir_id, + } + } + + fn manual_coder_assignment_request( + fixture: &ManualCoderAssignmentFixture, + operation_id: &str, + ) -> server_api::SetTicketRoleAssignmentRequest { + server_api::SetTicketRoleAssignmentRequest { + operation_id: operation_id.to_string(), + principal: server_api::TicketAssignmentPrincipal::Worker { + runtime_id: fixture.worker.runtime_id.clone(), + worker_id: fixture.worker.worker_id.clone(), + }, + expected_assignment_id: None, + } + } + + #[tokio::test] + async fn manual_coder_assignment_validates_and_rebinds_all_ticket_targets() { + let fixture = manual_coder_assignment_fixture().await; + let Json(response) = scoped_set_ticket_assignment( + State(fixture.api.clone()), + AxumPath(( + TEST_WORKSPACE_ID.to_string(), + fixture.ticket_id.clone(), + "coder".to_string(), + )), + Json(manual_coder_assignment_request( + &fixture, + "manual-multi-target-success", + )), + ) + .await + .unwrap(); + + assert_eq!( + response.assignment.unwrap().principal, + server_api::TicketAssignmentPrincipal::Worker { + runtime_id: fixture.worker.runtime_id.clone(), + worker_id: fixture.worker.worker_id.clone(), + } + ); + assert_eq!( + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .state, + TicketWorkflowState::InProgress.as_str() + ); + let links = fixture + .api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap(); + assert_eq!(links.len(), 2); + assert_eq!( + links + .iter() + .find(|link| link.workdir_id == fixture.main_workdir_id) + .unwrap() + .capabilities, + workdir::WorkdirSessionCapabilities::ALL + ); + assert_eq!( + links + .iter() + .find(|link| link.workdir_id == fixture.docs_workdir_id) + .unwrap() + .capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + fixture.api.runtime.worker(&fixture.worker).unwrap().state, + "idle", + "manual assignment must restore the live Worker after capability binding" + ); + let runtime_attachments = fixture + .runtime + .logical_attachments(&fixture.worker.worker_id); + assert_eq!(runtime_attachments.len(), 2); + assert_eq!( + runtime_attachments + .iter() + .find(|attachment| attachment.alias.as_str() == "docs") + .unwrap() + .capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY, + "the restored live Worker must not retain its pre-assignment ALL capability" + ); + } + + #[tokio::test] + async fn manual_coder_assignment_rejects_incomplete_or_mismatched_workdirs_before_state_change() + { + for case in ["missing", "undeclared", "selector"] { + let fixture = manual_coder_assignment_fixture().await; + match case { + "missing" => { + fixture + .api + .store + .detach_worker_workdir( + TEST_WORKSPACE_ID, + &fixture.worker, + Some(&fixture.docs_workdir_id), + TEST_CREATED_AT, + ) + .unwrap(); + } + "undeclared" => { + fixture + .api + .store + .upsert_repository(&RepositoryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + repository_id: "undeclared-repository".to_string(), + repository_key: "undeclared".to_string(), + kind: "git".to_string(), + provider: Some("git".to_string()), + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, + uri: fixture + .api + .config + .workspace_execution_root + .display() + .to_string(), + }, + default_ref: Some("develop".to_string()), + source_revision: 1, + source_fingerprint: "sha256:undeclared".to_string(), + observed_status: server_api::RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .unwrap(); + let mut docs = fixture + .api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &fixture.docs_workdir_id) + .unwrap() + .unwrap(); + docs.source = WorkdirRegistrySource::Repository { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), + repository_id: "undeclared-repository".to_string(), + }; + fixture.api.store.upsert_workdir_registry(&docs).unwrap(); + } + "selector" => { + let mut docs = fixture + .api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &fixture.docs_workdir_id) + .unwrap() + .unwrap(); + docs.creation_selector = Some("other".to_string()); + fixture.api.store.upsert_workdir_registry(&docs).unwrap(); + } + _ => unreachable!(), + } + + let error = scoped_set_ticket_assignment( + State(fixture.api.clone()), + AxumPath(( + TEST_WORKSPACE_ID.to_string(), + fixture.ticket_id.clone(), + "coder".to_string(), + )), + Json(manual_coder_assignment_request( + &fixture, + &format!("manual-invalid-{case}"), + )), + ) + .await + .unwrap_err() + .into_response(); + assert_eq!(error.status(), StatusCode::BAD_REQUEST, "case={case}"); + assert_eq!( + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .state, + TicketWorkflowState::Ready.as_str(), + "case={case}" + ); + assert!( + fixture + .api + .store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &fixture.ticket_id, + TicketAssignmentRole::Coder, + ) + .unwrap() + .is_none(), + "case={case}" + ); + assert!( + fixture + .api + .store + .get_ticket_assignment_operation( + TEST_WORKSPACE_ID, + &format!("manual-invalid-{case}"), + ) + .unwrap() + .is_none(), + "case={case}" + ); + } + } + + #[tokio::test] + async fn manual_coder_assignment_failure_restores_worker_and_attachment_capabilities() { + let fixture = manual_coder_assignment_fixture().await; + rusqlite::Connection::open(&fixture.api.config.database_path) + .unwrap() + .execute_batch( + r#" + CREATE TRIGGER fail_manual_coder_assignment + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.role = 'coder' + BEGIN + SELECT RAISE(ABORT, 'injected manual Coder assignment failure'); + END; + "#, + ) + .unwrap(); + + assert!( + scoped_set_ticket_assignment( + State(fixture.api.clone()), + AxumPath(( + TEST_WORKSPACE_ID.to_string(), + fixture.ticket_id.clone(), + "coder".to_string(), + )), + Json(manual_coder_assignment_request( + &fixture, + "manual-assignment-failure", + )), + ) + .await + .is_err() + ); + assert_eq!( + fixture + .api + .authority + .ticket(&fixture.ticket_id) + .unwrap() + .state, + TicketWorkflowState::Ready.as_str() + ); + assert!( + fixture + .api + .store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &fixture.ticket_id, + TicketAssignmentRole::Coder, + ) + .unwrap() + .is_none() + ); + let links = fixture + .api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap(); + assert!( + links + .iter() + .all(|link| { link.capabilities == workdir::WorkdirSessionCapabilities::ALL }) + ); + let runtime_attachments = fixture + .runtime + .logical_attachments(&fixture.worker.worker_id); + assert_eq!(runtime_attachments.len(), 2); + assert!(runtime_attachments.iter().all(|attachment| { + attachment.capabilities == workdir::WorkdirSessionCapabilities::ALL + })); + assert_eq!( + fixture.api.runtime.worker(&fixture.worker).unwrap().state, + "idle" + ); + } + #[tokio::test] async fn role_assignment_endpoint_replays_same_operation_result() { let dir = tempfile::tempdir().unwrap(); @@ -28253,9 +33845,9 @@ mod tests { .unwrap() .create(ticket::NewTicket::new("Idempotent assignment")) .unwrap(); - let request = || SetTicketRoleAssignmentRequest { + let request = || server_api::SetTicketRoleAssignmentRequest { operation_id: "same-role-operation".to_string(), - principal: TicketAssignmentPrincipal::WorkspaceAgent { + principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key: "workspace-orchestrator".to_string(), }, expected_assignment_id: None, @@ -28308,9 +33900,9 @@ mod tests { ticket_id.clone(), "coder".to_string(), )), - Json(SetTicketRoleAssignmentRequest { + Json(server_api::SetTicketRoleAssignmentRequest { operation_id: "invalid-workspace-agent-coder".to_string(), - principal: TicketAssignmentPrincipal::WorkspaceAgent { + principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key: "workspace-orchestrator".to_string(), }, expected_assignment_id: None, @@ -28351,7 +33943,7 @@ mod tests { let coder = read .assignments .iter() - .find(|assignment| assignment.role == TicketAssignmentRole::Coder) + .find(|assignment| assignment.role == server_api::TicketAssignmentRole::Coder) .unwrap(); assert_eq!(coder.assignment_id, assignment.assignment_id); let Json(detail) = browser_ticket_detail(&api, &ticket_id).unwrap(); @@ -28372,7 +33964,7 @@ mod tests { ticket_id.clone(), "coder".to_string(), )), - Query(ClearTicketRoleAssignmentQuery { + Query(server_api::ClearTicketRoleAssignmentQuery { operation_id: Some("clear-stale".to_string()), assignment_id: Some("stale-assignment".to_string()), }), @@ -28389,7 +33981,7 @@ mod tests { ticket_id.clone(), "coder".to_string(), )), - Query(ClearTicketRoleAssignmentQuery { + Query(server_api::ClearTicketRoleAssignmentQuery { operation_id: Some("clear-current".to_string()), assignment_id: Some("assignment-api-1".to_string()), }), @@ -28404,7 +33996,7 @@ mod tests { ticket_id, "coder".to_string(), )), - Query(ClearTicketRoleAssignmentQuery { + Query(server_api::ClearTicketRoleAssignmentQuery { operation_id: Some("clear-current".to_string()), assignment_id: Some("assignment-api-1".to_string()), }), @@ -28495,7 +34087,7 @@ mod tests { }) }; let request = || { - Json(CancelTicketImplementationRequest { + Json(server_api::CancelTicketImplementationRequest { operation_id: "cancel-implementation-operation".to_string(), assignment_id: "cancelled-assignment".to_string(), reason: "redo with the corrected design".to_string(), @@ -28844,8 +34436,8 @@ mod tests { let planning = scoped_transition_ticket_state( State(api.clone()), AxumPath(path.clone()), - Json(BrowserTransitionTicketStateRequest { - state: TicketWorkflowState::InProgress, + Json(server_api::BrowserTransitionTicketStateRequest { + state: server_api::BrowserTicketWorkflowState::Inprogress, reason: None, body: None, author: None, @@ -28873,8 +34465,8 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), id: ready.id.clone(), }), - Json(BrowserTransitionTicketStateRequest { - state: TicketWorkflowState::InProgress, + Json(server_api::BrowserTransitionTicketStateRequest { + state: server_api::BrowserTicketWorkflowState::Inprogress, reason: None, body: None, author: None, @@ -28898,8 +34490,7 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Queue role gate"); input.workflow_state = Some(TicketWorkflowState::Ready); - input.repository_id = Some(test_repository_id(&api)); - input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = backend.create(input).unwrap(); let path = (TEST_WORKSPACE_ID.to_string(), ticket.id.clone()); @@ -28909,7 +34500,7 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), id: ticket.id.clone(), }), - Json(BrowserQueueTicketRequest {}), + Json(server_api::BrowserQueueTicketRequest {}), ) .await .unwrap_err() @@ -28961,13 +34552,11 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut first_input = ticket::NewTicket::new("First cycle Ticket"); first_input.workflow_state = Some(TicketWorkflowState::Ready); - first_input.repository_id = Some(test_repository_id(&api)); - first_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut first_input, "test-repository", "develop"); let first = backend.create(first_input).unwrap(); let mut second_input = ticket::NewTicket::new("Second cycle Ticket"); second_input.workflow_state = Some(TicketWorkflowState::Ready); - second_input.repository_id = Some(test_repository_id(&api)); - second_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut second_input, "test-repository", "develop"); let second = backend.create(second_input).unwrap(); for (ticket_id, target) in [ (first.id.clone(), second.id.clone()), @@ -29014,13 +34603,11 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut dependency_input = ticket::NewTicket::new("Invalid target dependency"); dependency_input.workflow_state = Some(TicketWorkflowState::Ready); - dependency_input.repository_id = Some(test_repository_id(&api)); - dependency_input.ref_selector = Some("missing-ref".to_string()); + set_test_ticket_target(&mut dependency_input, "test-repository", "missing-ref"); let dependency = backend.create(dependency_input).unwrap(); let mut root_input = ticket::NewTicket::new("Queue root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some(test_repository_id(&api)); - root_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut root_input, "test-repository", "develop"); let root = backend.create(root_input).unwrap(); backend .add_ticket_relation( @@ -29068,13 +34655,11 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut dependency_input = ticket::NewTicket::new("Ready dependency"); dependency_input.workflow_state = Some(TicketWorkflowState::Ready); - dependency_input.repository_id = Some(test_repository_id(&api)); - dependency_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut dependency_input, "test-repository", "develop"); let dependency = backend.create(dependency_input).unwrap(); let mut root_input = ticket::NewTicket::new("Queue root"); root_input.workflow_state = Some(TicketWorkflowState::Ready); - root_input.repository_id = Some(test_repository_id(&api)); - root_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut root_input, "test-repository", "develop"); let root = backend.create(root_input).unwrap(); backend .add_ticket_relation( @@ -29427,8 +35012,7 @@ mod tests { let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Recover queued work"); input.workflow_state = Some(TicketWorkflowState::Queued); - input.repository_id = Some(test_repository_id(&api)); - input.ref_selector = Some("HEAD".to_owned()); + set_test_ticket_target(&mut input, "test-repository", "HEAD"); let ticket_ref = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket_ref.id); *api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone()); @@ -29552,533 +35136,60 @@ mod tests { } #[tokio::test] - async fn worker_spawn_and_restore_assignment_operations_are_idempotent() { + async fn runtime_worker_ticket_assignment_rejects_missing_target_attachments() { let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); - let mut first_ticket_input = ticket::NewTicket::new("Spawn assignment"); - first_ticket_input.workflow_state = Some(TicketWorkflowState::Queued); - let first_ticket = backend.create(first_ticket_input).unwrap(); - assign_test_orchestrator(&api, &first_ticket.id); - let request = WorkerSpawnRequest { + let mut input = ticket::NewTicket::new("Unsafe direct Runtime assignment"); + input.workflow_state = Some(TicketWorkflowState::Queued); + set_test_ticket_target(&mut input, "test-repository", "develop"); + let ticket = backend.create(input).unwrap(); + assign_test_orchestrator(&api, &ticket.id); + let request = server_api::RuntimeWorkerSpawnRequest { requested_worker_name: Some("assigned-spawn".to_string()), - intent: WorkerSpawnIntent::TicketRole { - ticket_id: first_ticket.id.clone(), - role: TicketWorkerRole::Coder, + intent: server_api::RuntimeWorkerSpawnIntent::TicketRole { + ticket_id: ticket.id.clone(), + role: server_api::RuntimeTicketWorkerRole::Coder, }, - acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments: 1, }, - profile: ProfileSelector::Builtin("builtin:coder".to_string()), - ticket_assignment: Some(crate::hosts::WorkerTicketAssignmentRequest { - ticket_id: first_ticket.id.clone(), - operation_id: "spawn-assignment-operation".to_string(), + profile: server_api::RuntimeProfileSelector::Builtin("builtin:coder".to_string()), + ticket_assignment: Some(server_api::RuntimeWorkerTicketAssignmentRequest { + ticket_id: ticket.id.clone(), + operation_id: "unsafe-runtime-assignment".to_string(), }), - initial_submit: vec![Segment::Flow { - selector: "builtin:coder-review".to_string(), - }], + initial_submit: vec![ + serde_json::to_value(Segment::Flow { + selector: "builtin:coder-review".to_string(), + }) + .unwrap(), + ], workdir_attachment_requests: Vec::new(), - resolved_workdir_attachment_requests: Vec::new(), - resolved_workdir_attachments: Vec::new(), - resolved_config_bundle: None, - resolved_worker_observation_enabled: false, - resolved_worker_observation_grants: Vec::new(), - resolved_workspace_api: None, - resolved_memory_settings: None, - resolved_control_operation: None, }; - let Json(first) = scoped_create_runtime_worker( + + let error = scoped_create_runtime_worker( State(api.clone()), AxumPath(ScopedRuntimePath { workspace_id: TEST_WORKSPACE_ID.to_string(), runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), }), - Json(request.clone()), - ) - .await - .unwrap(); - let first_worker = first.worker.unwrap(); - let Json(projected) = scoped_list_ticket_assignments( - State(api.clone()), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - id: first_ticket.id.clone(), - }), - ) - .await - .unwrap(); - assert_eq!( - projected.assignments[0] - .principal - .worker() - .map(|worker| worker.worker_id), - Some(first_worker.worker.worker_id.clone()) - ); - assert_eq!( - backend - .show(first_ticket.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::InProgress - ); - let Json(retried) = scoped_create_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - }), - Json(request.clone()), - ) - .await - .unwrap(); - assert_eq!( - retried.worker.unwrap().worker.worker_id, - first_worker.worker.worker_id - ); - assert_eq!( - api.store - .list_ticket_coder_assignment_events(TEST_WORKSPACE_ID, &first_ticket.id, 10,) - .unwrap() - .len(), - 1 - ); - - let current = api - .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &first_ticket.id) - .unwrap() - .unwrap(); - api.store - .clear_current_ticket_worker_assignment( - TEST_WORKSPACE_ID, - &first_ticket.id, - Some(¤t.assignment_id), - "spawn-unassign-operation", - "spawn-unassign-event", - "test-user", - TEST_CREATED_AT, - ) - .unwrap(); - api.runtime - .stop_worker( - &first_worker.worker, - WorkerLifecycleRequest { - reason: Some("restore assignment test".to_string()), - ticket_assignment: None, - }, - ) - .unwrap(); - let mut second_ticket_input = ticket::NewTicket::new("Restore assignment"); - second_ticket_input.workflow_state = Some(TicketWorkflowState::Queued); - let second_ticket = backend.create(second_ticket_input).unwrap(); - assign_test_orchestrator(&api, &second_ticket.id); - let _ = scoped_restore_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimeWorkerPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - worker: RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - first_worker.worker.worker_id.clone(), - ), - }), - Query(RestoreTicketAssignmentQuery { - ticket_id: Some(second_ticket.id.clone()), - assignment_operation_id: Some("restore-assignment-operation".to_string()), - }), - ) - .await - .unwrap(); - let Json(retried_restore) = scoped_restore_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimeWorkerPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - worker: RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - first_worker.worker.worker_id.clone(), - ), - }), - Query(RestoreTicketAssignmentQuery { - ticket_id: Some(second_ticket.id.clone()), - assignment_operation_id: Some("restore-assignment-operation".to_string()), - }), - ) - .await - .unwrap(); - assert_eq!(retried_restore.worker_id, first_worker.worker.worker_id); - assert_eq!( - retried_restore.result.state, - server_api::WorkerRestoreState::Accepted - ); - let restored_assignment = api - .store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &second_ticket.id) - .unwrap() - .unwrap(); - assert_eq!( - restored_assignment.worker.worker_id, - first_worker.worker.worker_id - ); - assert_eq!( - backend - .show(second_ticket.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::InProgress - ); - - api.store - .clear_current_ticket_worker_assignment( - TEST_WORKSPACE_ID, - &second_ticket.id, - Some(&restored_assignment.assignment_id), - "restore-clear-operation", - "restore-clear-event", - "test", - TEST_CREATED_AT, - ) - .unwrap(); - let mut pending_request = WorkerSpawnRequest { - intent: WorkerSpawnIntent::TicketRole { - ticket_id: second_ticket.id.clone(), - role: TicketWorkerRole::Coder, - }, - ticket_assignment: Some(crate::hosts::WorkerTicketAssignmentRequest { - ticket_id: second_ticket.id.clone(), - operation_id: "pending-spawn-operation".to_string(), - }), - ..request - }; - pending_request.resolved_workspace_api = - Some(test_worker_workspace_api(EMBEDDED_WORKER_RUNTIME_ID)); - let (_, pending_fingerprint) = crate::hosts::worker_spawn_idempotency(&pending_request) - .unwrap() - .unwrap(); - api.store - .reserve_ticket_assignment_operation( - TEST_WORKSPACE_ID, - "pending-spawn-operation", - &second_ticket.id, - EMBEDDED_WORKER_RUNTIME_ID, - None, - &pending_fingerprint, - TEST_CREATED_AT, - ) - .unwrap(); - let current_memory_settings = api - .config_store - .get_workspace_memory_settings(TEST_WORKSPACE_ID) - .unwrap(); - let reservation = api - .config_store - .reserve_worker_create( - TEST_WORKSPACE_ID, - EMBEDDED_WORKER_RUNTIME_ID, - "pending-spawn-operation", - &pending_fingerprint, - ¤t_memory_settings, - ) - .unwrap(); - pending_request.resolved_memory_settings = Some(reservation.memory_settings.clone()); - let spawned_before_backend_failure = api - .runtime - .spawn_worker( - EMBEDDED_WORKER_RUNTIME_ID, - WorkerCreateBinding { - worker_id: reservation.worker_id, - create_fingerprint: reservation.create_fingerprint, - }, - pending_request.clone(), - ) - .unwrap() - .worker - .unwrap(); - assert!( - api.store - .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "pending-spawn-operation") - .unwrap() - .is_some_and(|operation| operation.worker.is_none()) - ); - let worker_count_before_retry = api.runtime.list_workers(100).items.len(); - let Json(reconciled) = scoped_create_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - }), - Json(pending_request), - ) - .await - .unwrap(); - assert_eq!( - reconciled.worker.unwrap().worker.worker_id, - spawned_before_backend_failure.worker.worker_id - ); - assert_eq!( - api.runtime.list_workers(100).items.len(), - worker_count_before_retry, - "retrying a reserved lifecycle operation must not spawn another Worker" - ); - assert!( - api.store - .get_ticket_assignment_operation(TEST_WORKSPACE_ID, "pending-spawn-operation") - .unwrap() - .and_then(|operation| operation.assignment_id) - .is_some() - ); - } - - #[tokio::test] - async fn worker_spawn_finalize_failure_reports_stage_and_compensation_outcomes() { - let dir = tempfile::tempdir().unwrap(); - let api = test_api(dir.path()).await; - register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; - api.runtime - .register_or_replace(WorkdirlessFixtureRuntime::default()); - let mut ticket_input = ticket::NewTicket::new("Compensation test Ticket"); - ticket_input.workflow_state = Some(TicketWorkflowState::InProgress); - let ticket_id = browser_ticket_backend(&api) - .unwrap() - .create(ticket_input) - .unwrap() - .id; - assign_test_orchestrator(&api, &ticket_id); - let assignment = crate::hosts::WorkerTicketAssignmentRequest { - ticket_id: ticket_id.clone(), - operation_id: "compensation-test-operation".to_string(), - }; - let request = WorkerSpawnRequest { - intent: WorkerSpawnIntent::TicketRole { - ticket_id: ticket_id.clone(), - role: TicketWorkerRole::Coder, - }, - requested_worker_name: Some("Compensation test Worker".to_string()), - acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { - expected_segments: 1, - }, - profile: ProfileSelector::Builtin("builtin:coder".to_string()), - ticket_assignment: Some(assignment.clone()), - initial_submit: vec![Segment::Flow { - selector: "builtin:coder-review".to_string(), - }], - workdir_attachment_requests: Vec::new(), - resolved_workdir_attachment_requests: Vec::new(), - resolved_workdir_attachments: Vec::new(), - resolved_config_bundle: None, - resolved_worker_observation_enabled: false, - resolved_worker_observation_grants: Vec::new(), - resolved_workspace_api: None, - resolved_memory_settings: None, - resolved_control_operation: None, - }; - let Json(created) = scoped_create_runtime_worker( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), - }), Json(request), ) .await - .unwrap(); - let mut worker = created.worker.unwrap(); - assert!( - api.store - .get_worker_registry(TEST_WORKSPACE_ID, &worker.worker) - .unwrap() - .is_some() - ); - - let spawned_workdir_ids = vec![ - "spawned-workdir-left".to_string(), - "spawned-workdir-right".to_string(), - ]; - for workdir_id in &spawned_workdir_ids { - seed_cleanup_workdir_for_runtime( - &api, - workdir_id, - WorkdirlessFixtureRuntime::RUNTIME_ID, - "present", - "clean", - ); - } - worker.workdir_attachments = spawned_workdir_ids - .iter() - .enumerate() - .map(|(index, workdir_id)| { - let mut working_directory = WorkdirlessFixtureRuntime::workdir_status(workdir_id); - if index == 1 { - working_directory.summary.display_name = - Some("inject-registry-upsert-failure".to_string()); - } - WorkingDirectoryAttachmentStatus { - alias: workdir::WorkdirAttachmentAlias::new(if index == 0 { - "left" - } else { - "right" - }) - .unwrap(), - working_directory, - } - }) - .collect(); - let worker_record = api - .store - .get_worker_registry(TEST_WORKSPACE_ID, &worker.worker) - .unwrap() - .unwrap(); - rusqlite::Connection::open(&api.config.database_path) - .unwrap() - .execute_batch( - r#" - CREATE TABLE spawn_finalize_attachment_audit(alias TEXT NOT NULL); - CREATE TRIGGER audit_spawn_finalize_attachment - AFTER INSERT ON worker_workdir_links - BEGIN - INSERT INTO spawn_finalize_attachment_audit(alias) VALUES (NEW.alias); - END; - CREATE TRIGGER fail_second_spawn_workdir_upsert - BEFORE UPDATE ON workdir_registry - WHEN NEW.display_name = 'inject-registry-upsert-failure' - BEGIN - SELECT RAISE(ABORT, 'injected second Workdir registry failure'); - END; - "#, - ) - .unwrap(); - - let context = WorkerSpawnCompensationContext { - assignment: Some(&assignment), - spawned_workdir_ids: &spawned_workdir_ids, - }; - let error = - finalize_spawned_worker_workdir_attachments(&api, &worker, &worker_record, &context) - .unwrap_err(); - assert!(matches!( - error.error, - Error::RuntimeOperationFailed { ref code, .. } - if code == "worker_spawn_finalize_workdir_registry_failed" - )); - assert!(error.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "worker_spawn_finalize_workdir_registry_failed" - && diagnostic.message.contains(&worker.worker.runtime_id) - && diagnostic.message.contains(&worker.worker.worker_id) - })); - let audit_connection = rusqlite::Connection::open(&api.config.database_path).unwrap(); - let mut audit_statement = audit_connection - .prepare("SELECT alias FROM spawn_finalize_attachment_audit ORDER BY rowid") - .unwrap(); - let finalized_aliases = audit_statement - .query_map([], |row| row.get::<_, String>(0)) - .unwrap() - .collect::, _>>() - .unwrap(); - assert_eq!(finalized_aliases, ["left"]); - assert!( - error - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "worker_spawn_compensated"), - "{:?}", - error.diagnostics - ); - assert!(matches!( - api.runtime.worker(&worker.worker), - Err(RuntimeRegistryError::UnknownWorker { .. }) - )); - assert!( - api.store - .get_worker_registry(TEST_WORKSPACE_ID, &worker.worker) - .unwrap() - .is_none() - ); - assert!( - api.store - .get_ticket_assignment_operation(TEST_WORKSPACE_ID, &assignment.operation_id) - .unwrap() - .is_none() - ); - assert!( - api.store - .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id) - .unwrap() - .is_none() - ); - - for workdir_id in &spawned_workdir_ids { - assert!( - api.store - .get_workdir_registry(TEST_WORKSPACE_ID, workdir_id) - .unwrap() - .is_none(), - "spawn compensation must clean every created Workdir: {workdir_id}" - ); - } - - let missing_summary_workdir_ids = vec![ - "missing-summary-workdir-left".to_string(), - "missing-summary-workdir-right".to_string(), - ]; - for workdir_id in &missing_summary_workdir_ids { - seed_cleanup_workdir_for_runtime( - &api, - workdir_id, - WorkdirlessFixtureRuntime::RUNTIME_ID, - "present", - "clean", - ); - } - let missing_summary_context = WorkerSpawnCompensationContext { - assignment: None, - spawned_workdir_ids: &missing_summary_workdir_ids, - }; - let reservation_worker_id = WorkerId::now_v7(); - let _ = compensate_failed_workspace_worker_create( - &api, - EMBEDDED_WORKER_RUNTIME_ID, - reservation_worker_id, - "missing-summary-fingerprint", - "runtime_spawn_missing_summary", - None, - &missing_summary_context, - &[], - ); - for workdir_id in &missing_summary_workdir_ids { - assert!( - api.store - .get_workdir_registry(TEST_WORKSPACE_ID, workdir_id) - .unwrap() - .is_none(), - "missing-summary compensation must clean every created Workdir: {workdir_id}" - ); - } - - let mut residual_worker = worker.clone(); - residual_worker.worker.runtime_id = "missing-runtime".to_string(); - let residual_context = WorkerSpawnCompensationContext { - assignment: None, - spawned_workdir_ids: &[], - }; - let residual_error = finalize_worker_spawn_stage::<()>( - &api, - &residual_worker, - &residual_context, - WorkerSpawnFinalizeStage::WorkerRegistry, - Err(Error::RegistryInconsistency("injected registry failure".to_string()).into()), - ) .unwrap_err(); - assert!(residual_error.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "worker_spawn_compensation_runtime_delete_failed" - && diagnostic.message.contains("missing-runtime") - })); + assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST); assert!( - !residual_error - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "worker_spawn_compensated") + api.store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + assert_eq!( + backend.show(ticket.id.into()).unwrap().meta.workflow_state, + TicketWorkflowState::Queued ); - assert!(residual_error.error.to_string().contains("residual state")); } #[tokio::test] @@ -30128,8 +35239,7 @@ mod tests { }; let mut related_input = ticket::NewTicket::new("Related Browser Ticket"); related_input.workflow_state = Some(TicketWorkflowState::Ready); - related_input.repository_id = Some(test_repository_id(&api)); - related_input.ref_selector = Some("develop".to_string()); + set_test_ticket_target(&mut related_input, "test-repository", "develop"); let related_ticket_id = browser_ticket_backend(&api) .unwrap() .create(related_input) @@ -30151,15 +35261,18 @@ mod tests { let Json(edited) = scoped_edit_ticket_item( State(api.clone()), AxumPath(path()), - Json(BrowserEditTicketRequest { + Json(server_api::BrowserEditTicketRequest { title: Some("Browser Ticket API edited".to_string()), body: Some("Updated from the Browser API.".to_string()), old_string: None, new_string: None, replace_all: false, - target: Some(TicketTargetEdit::Set { - repository_id: "test-repository".to_string(), - ref_selector: Some("develop".to_string()), + target: Some(server_api::BrowserTicketTargetEdit::Set { + targets: vec![test_ticket_target( + "test-repository", + "develop", + TicketTargetAccess::ReadWrite, + )], }), author: Some("browser-user".to_string()), }), @@ -30168,8 +35281,14 @@ mod tests { .unwrap(); assert_eq!(edited.title, "Browser Ticket API edited"); assert_eq!(edited.body, "Updated from the Browser API."); - assert_eq!(edited.repository_key.as_deref(), Some("test-repository")); - assert_eq!(edited.ref_selector.as_deref(), Some("develop")); + assert_eq!( + edited.targets, + vec![test_ticket_target( + "test-repository", + "develop", + TicketTargetAccess::ReadWrite, + )] + ); assert!(edited.assignments.is_empty()); assert!(edited.assignment_diagnostics.is_empty()); assert_eq!(edited.relations.outgoing.len(), 1); @@ -30179,8 +35298,8 @@ mod tests { let Json(commented) = scoped_append_ticket_event( State(api.clone()), AxumPath(path()), - Json(BrowserAppendTicketEventRequest { - role: BrowserTicketThreadRole::Comment, + Json(server_api::BrowserAppendTicketEventRequest { + role: server_api::BrowserTicketThreadRole::Comment, body: "API comment".to_string(), author: Some("browser-user".to_string()), }), @@ -30194,7 +35313,7 @@ mod tests { let Json(ready) = scoped_mark_ticket_ready_from_browser( State(api.clone()), AxumPath(path()), - Json(TicketMarkReadyRequest { + Json(InternalTicketMarkReadyRequest { operation_key: "browser-ready".to_owned(), reason: Some("intake complete".to_owned()), intake_summary: None, @@ -30220,7 +35339,7 @@ mod tests { let Json(queue_outcome) = scoped_queue_ticket( State(api.clone()), AxumPath(path()), - Json(BrowserQueueTicketRequest {}), + Json(server_api::BrowserQueueTicketRequest {}), ) .await .unwrap(); @@ -30238,7 +35357,7 @@ mod tests { let Json(closed) = scoped_close_ticket( State(api), AxumPath(path()), - Json(BrowserCloseTicketRequest { + Json(server_api::BrowserCloseTicketRequest { resolution: "Closed through the Browser API.".to_string(), }), ) @@ -30449,34 +35568,30 @@ mod tests { .unwrap_err(); assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); - let denied = scoped_revoke_runtime_trust_key( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_owned(), - runtime_id: "runtime-a".to_owned(), - }), - Extension(non_owner), - Json(RevokeRuntimeTrustKeyRequest { + let contract_service = ServerApiContractService::Workspace(api.clone()); + let denied = ::runtime_trust_key_revoke( + &contract_service, + non_owner, + TEST_WORKSPACE_ID.to_owned(), + "runtime-a".to_owned(), + server_api::RevokeRuntimeTrustKeyRequest { expected_revision: 1, - }), + }, ) .await .unwrap_err(); - assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); - let response = scoped_revoke_runtime_trust_key( - State(api.clone()), - AxumPath(ScopedRuntimePath { - workspace_id: TEST_WORKSPACE_ID.to_owned(), - runtime_id: "runtime-a".to_owned(), - }), - Extension(owner), - Json(RevokeRuntimeTrustKeyRequest { + assert_eq!(denied.error, "Forbidden"); + ::runtime_trust_key_revoke( + &contract_service, + owner, + TEST_WORKSPACE_ID.to_owned(), + "runtime-a".to_owned(), + server_api::RevokeRuntimeTrustKeyRequest { expected_revision: 1, - }), + }, ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); let binding = api .store .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-a") @@ -30597,6 +35712,35 @@ mod tests { test_api_with_recording_backend(workspace_root).await.0 } + async fn test_api_with_docs_repository(workspace_root: impl Into) -> WorkspaceApi { + let workspace_root = workspace_root.into(); + let docs_root = workspace_root.join("docs-repository"); + fs::create_dir_all(&docs_root).unwrap(); + init_clean_git_workspace(&docs_root); + let mut config = test_server_config(&workspace_root); + let mut docs = config.repositories[0].clone(); + let docs_source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, + uri: docs_root.display().to_string(), + }; + docs.id = "docs".to_string(); + docs.repository_key = "docs".to_string(); + docs.source_fingerprint = + crate::repository_source::repository_source_fingerprint(&docs_source); + docs.source = docs_source; + docs.path = Some(docs_root); + docs.default_selector = Some("develop".to_string()); + config.repositories.push(docs); + let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + WorkspaceApi::new_with_execution_backend( + config, + Arc::new(store), + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap() + } + fn test_owner_actor() -> RequestActor { RequestActor { user_id: "owner-user".to_string(), @@ -30608,13 +35752,41 @@ mod tests { } fn test_repository_id(api: &WorkspaceApi) -> String { + test_repository_id_by_key(api, "test-repository") + } + + fn test_repository_id_by_key(api: &WorkspaceApi, repository_key: &str) -> String { api.store - .get_repository_by_key(TEST_WORKSPACE_ID, "test-repository") + .get_repository_by_key(TEST_WORKSPACE_ID, repository_key) .unwrap() .expect("test Repository exists") .repository_id } + fn test_ticket_target( + repository_key: impl Into, + ref_selector: impl Into, + access: TicketTargetAccess, + ) -> ticket::TicketTarget { + ticket::TicketTarget { + repository_key: repository_key.into(), + ref_selector: Some(ref_selector.into()), + access, + } + } + + fn set_test_ticket_target( + input: &mut ticket::NewTicket, + repository_key: impl Into, + ref_selector: impl Into, + ) { + input.targets = vec![test_ticket_target( + repository_key, + ref_selector, + TicketTargetAccess::ReadWrite, + )]; + } + fn set_test_default_runtime(api: &WorkspaceApi, runtime_id: &str) { let current = api .config_store @@ -31018,7 +36190,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(stopped.state, WorkerOperationState::Accepted); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); let worker_root = temp .path() .join(".test-embedded-runtime-store/workers") @@ -31702,7 +36874,7 @@ mod tests { deleted, &[RuntimeDiagnostic { code: "working_directory_not_found".to_string(), - severity: DiagnosticSeverity::Warning, + severity: HostDiagnosticSeverity::Warning, message: "missing".to_string(), }], ) @@ -31728,7 +36900,7 @@ mod tests { unknown, &[RuntimeDiagnostic { code: "runtime_unavailable".to_string(), - severity: DiagnosticSeverity::Warning, + severity: HostDiagnosticSeverity::Warning, message: "temporarily unavailable".to_string(), }], ) @@ -31775,6 +36947,7 @@ mod tests { worker: RuntimeWorkerRef::new("runtime-test", runtime_worker_id.to_string()), workdir_id: workdir_id.to_string(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: now_registry_timestamp(), unlinked_at: None, }) @@ -31935,7 +37108,7 @@ mod tests { } fn workdir_removal_result( - state: WorkerOperationState, + state: InternalWorkerOperationState, summary: Option, diagnostics: Vec, ) -> crate::hosts::RuntimeWorkingDirectoryResult { @@ -31983,11 +37156,11 @@ mod tests { let (clean_operation, clean_summary) = reserve_removal_fixture(&api, "clean-provider"); let clean_provider = FakeWorkdirRemovalProvider::new( workdir_removal_result( - WorkerOperationState::Accepted, + InternalWorkerOperationState::Accepted, Some(clean_summary), Vec::new(), ), - workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), ); let removed = execute_reserved_workdir_removal_with_provider( &api, @@ -32014,16 +37187,20 @@ mod tests { let (missing_operation, _) = reserve_removal_fixture(&api, "provider-not-found"); let not_found = RuntimeDiagnostic { code: "working_directory_not_found".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "missing".to_string(), }; let missing_provider = FakeWorkdirRemovalProvider::new( workdir_removal_result( - WorkerOperationState::Rejected, + InternalWorkerOperationState::Rejected, None, vec![not_found.clone()], ), - workdir_removal_result(WorkerOperationState::Rejected, None, vec![not_found]), + workdir_removal_result( + InternalWorkerOperationState::Rejected, + None, + vec![not_found], + ), ); let removed = execute_reserved_workdir_removal_with_provider( &api, @@ -32041,15 +37218,15 @@ mod tests { let (unknown_operation, _) = reserve_removal_fixture(&api, "provider-unknown"); let unknown_provider = FakeWorkdirRemovalProvider::new( workdir_removal_result( - WorkerOperationState::Accepted, + InternalWorkerOperationState::Accepted, None, vec![RuntimeDiagnostic { code: "working_directory_provider_timeout".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "timeout".to_string(), }], ), - workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), ); let unknown = execute_reserved_workdir_removal_with_provider( &api, @@ -32067,11 +37244,11 @@ mod tests { dirty_summary.cleanliness = Some("dirty".to_string()); let dirty_provider = FakeWorkdirRemovalProvider::new( workdir_removal_result( - WorkerOperationState::Accepted, + InternalWorkerOperationState::Accepted, Some(dirty_summary), Vec::new(), ), - workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), ); let dirty = execute_reserved_workdir_removal_with_provider( &api, @@ -32085,11 +37262,11 @@ mod tests { let clean_retry_provider = FakeWorkdirRemovalProvider::new( workdir_removal_result( - WorkerOperationState::Accepted, + InternalWorkerOperationState::Accepted, Some(clean_retry_summary), Vec::new(), ), - workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), ); let removed_after_retry = execute_reserved_workdir_removal_with_provider( &api, @@ -32111,11 +37288,11 @@ mod tests { corrupted_summary.cleanliness = Some("unknown".to_string()); let corrupted_provider = FakeWorkdirRemovalProvider::new( workdir_removal_result( - WorkerOperationState::Accepted, + InternalWorkerOperationState::Accepted, Some(corrupted_summary), Vec::new(), ), - workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), ); let corrupted = execute_reserved_workdir_removal_with_provider( &api, @@ -32134,16 +37311,16 @@ mod tests { reserve_removal_fixture(&api, "provider-unsupported"); let unsupported_provider = FakeWorkdirRemovalProvider::new( workdir_removal_result( - WorkerOperationState::Accepted, + InternalWorkerOperationState::Accepted, Some(unsupported_summary), Vec::new(), ), workdir_removal_result( - WorkerOperationState::Unsupported, + InternalWorkerOperationState::Unsupported, None, vec![RuntimeDiagnostic { code: "working_directory_unsupported".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "unsupported".to_string(), }], ), @@ -32185,11 +37362,11 @@ mod tests { let provider = Arc::new( FakeWorkdirRemovalProvider::new( workdir_removal_result( - WorkerOperationState::Accepted, + InternalWorkerOperationState::Accepted, Some(clean_summary), Vec::new(), ), - workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), ) .with_observation_delay(std::time::Duration::from_millis(100)), ); @@ -32239,22 +37416,22 @@ mod tests { #[test] fn only_exact_provider_not_found_is_removal_evidence() { let not_found = crate::hosts::RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![RuntimeDiagnostic { code: "working_directory_not_found".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "missing".to_string(), }], }; assert!(runtime_reports_workdir_not_found(¬_found)); let unknown = crate::hosts::RuntimeWorkingDirectoryResult { - state: WorkerOperationState::Rejected, + state: InternalWorkerOperationState::Rejected, working_directory: None, diagnostics: vec![RuntimeDiagnostic { code: "working_directory_provider_timeout".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "timeout".to_string(), }], }; @@ -32677,7 +37854,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(stopped.state, WorkerOperationState::Accepted); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); let worker_root = workspace .path() .join(".test-embedded-runtime-store/workers") @@ -33622,9 +38799,11 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let etag = response.headers().get(ETAG).unwrap().clone(); assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-cache"); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let contract_bundle: server_api::WorkspaceRuntimeConfigResponse = + serde_json::from_slice(&body).unwrap(); let bundle: worker_runtime::config_bundle::ConfigBundle = - serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) - .unwrap(); + serde_json::from_value(serde_json::to_value(contract_bundle).unwrap()).unwrap(); assert!(bundle.profile_source_archive.is_some()); assert_eq!( etag.to_str().unwrap(), @@ -33797,12 +38976,12 @@ mod tests { let diagnostics = vec![ RuntimeDiagnostic { code: "runtime_capacity_warning".to_string(), - severity: DiagnosticSeverity::Warning, + severity: HostDiagnosticSeverity::Warning, message: "capacity is low".to_string(), }, RuntimeDiagnostic { code: "runtime_capacity_unavailable".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "capacity is exhausted".to_string(), }, ]; @@ -33864,7 +39043,7 @@ mod tests { .unwrap(); let diagnostics = vec![RuntimeDiagnostic { code: "runtime_capacity_unavailable".to_string(), - severity: DiagnosticSeverity::Error, + severity: HostDiagnosticSeverity::Error, message: "capacity is exhausted".to_string(), }]; @@ -34893,7 +40072,7 @@ mod tests { "unsupported-runtime".to_string(), vec![settings_diagnostic( "remote_runtime_unsupported", - DiagnosticSeverity::Warning, + HostDiagnosticSeverity::Warning, "Remote Runtime provisioning is unsupported by this v0 worker launch path.", )], ) @@ -34923,24 +40102,18 @@ mod tests { let Json(runtime_response) = create_runtime_worker( State(api.clone()), AxumPath(WorkdirlessFixtureRuntime::RUNTIME_ID.to_string()), - Json(WorkerSpawnRequest { - intent: WorkerSpawnIntent::WorkspaceCompanion, + Json(server_api::RuntimeWorkerSpawnRequest { + intent: server_api::RuntimeWorkerSpawnIntent::WorkspaceCompanion, requested_worker_name: Some("Runtime Worker".to_string()), - acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + acceptance: server_api::RuntimeWorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments: 0, }, - profile: ProfileSelector::Builtin("builtin:companion".to_string()), + profile: server_api::RuntimeProfileSelector::Builtin( + "builtin:companion".to_string(), + ), ticket_assignment: None, initial_submit: Vec::new(), workdir_attachment_requests: Vec::new(), - resolved_workdir_attachment_requests: Vec::new(), - resolved_workdir_attachments: Vec::new(), - resolved_config_bundle: None, - resolved_worker_observation_enabled: false, - resolved_worker_observation_grants: Vec::new(), - resolved_workspace_api: None, - resolved_memory_settings: None, - resolved_control_operation: None, }), ) .await @@ -35721,7 +40894,7 @@ mod tests { .runtime .sync_config_bundle("embedded-worker-runtime", bundle) .expect("sync config bundle"); - assert_eq!(synced.state, WorkerOperationState::Accepted); + assert_eq!(synced.state, InternalWorkerOperationState::Accepted); assert!(store_root.exists(), "fs-store root should be created"); let spawned = api @@ -35755,7 +40928,7 @@ mod tests { }, ) .expect("spawn worker"); - assert_eq!(spawned.state, WorkerOperationState::Accepted); + assert_eq!(spawned.state, InternalWorkerOperationState::Accepted); let worker_id = spawned.worker.expect("created worker").worker.worker_id; let worker_ref = RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id); let sent = api @@ -35769,7 +40942,7 @@ mod tests { }, ) .expect("send input"); - assert_eq!(sent.state, WorkerOperationState::Accepted); + assert_eq!(sent.state, InternalWorkerOperationState::Accepted); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); loop { @@ -35824,7 +40997,7 @@ mod tests { }, ) .expect("stale worker input is projected as an operation result"); - assert_eq!(rejected_input.state, WorkerOperationState::Rejected); + assert_eq!(rejected_input.state, InternalWorkerOperationState::Rejected); } #[tokio::test] @@ -36050,6 +41223,7 @@ mod tests { worker: RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id), workdir_id: workdir_id.to_string(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "2".to_string(), unlinked_at: None, }) @@ -36404,7 +41578,7 @@ mod tests { let spawned = api .spawn_workspace_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn_request) .unwrap(); - assert_eq!(spawned.state, WorkerOperationState::Accepted); + assert_eq!(spawned.state, InternalWorkerOperationState::Accepted); let worker_id = spawned.worker.unwrap().worker.worker_id; let (_, projections) = api .store @@ -36446,6 +41620,7 @@ mod tests { worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, &worker_id), workdir_id: workdir_id.to_string(), alias: "attachment".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "1".to_string(), unlinked_at: None, }) @@ -36981,6 +42156,55 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); assert_eq!(shown["linked_tickets"], json!(["00000000001J3"])); } + #[tokio::test] + async fn generated_config_source_routes_preserve_nested_entry_paths() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let source_tree_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); + let initial = get_json(app.clone(), &source_tree_path).await; + let revision = initial["snapshot"]["revision"].as_u64().unwrap(); + let digest = initial["snapshot"]["digest"].as_str().unwrap(); + + let committed = request_json( + app.clone(), + "POST", + &format!("{source_tree_path}/commit"), + Some(json!({ + "base_revision": revision, + "base_digest": digest, + "changes": [{ + "kind": "create", + "path": "notes/readme.txt", + "content_type": "text", + "content": "nested entry" + }], + "entrypoints": ["main.dcdl"] + })), + StatusCode::CREATED, + ) + .await; + let committed_revision = committed["snapshot"]["revision"].as_u64().unwrap(); + + let revision_snapshot = get_json( + app.clone(), + &format!("{source_tree_path}/revisions/{committed_revision}"), + ) + .await; + assert_eq!(revision_snapshot["revision"], committed_revision); + assert_eq!( + revision_snapshot["entries"]["notes/readme.txt"]["content"], + "nested entry" + ); + + let entry = get_json( + app, + &format!("{source_tree_path}/entries/notes%2Freadme.txt"), + ) + .await; + assert_eq!(entry["path"], "notes/readme.txt"); + assert_eq!(entry["content"], "nested entry"); + } + #[tokio::test] async fn profile_settings_are_read_only_virtual_config_projection() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index c27422561..5b41cb671 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 65; +const LATEST_SCHEMA_VERSION: i64 = 66; 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"; @@ -46,6 +46,10 @@ const MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME: &str = const EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME: &str = "typed client-hosted External Workdir grants"; const EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME: &str = "durable External Workdir expiry cleanup state"; +const TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME: &str = + "Ticket target collection and Workdir attachment capabilities"; +const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; +const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS worker_registry_observations ( workspace_id TEXT NOT NULL, @@ -166,6 +170,11 @@ const MIGRATIONS: &[Migration] = &[ name: EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME, apply: migrate_external_workdir_cleanup_v64_to_v65, }, + Migration { + version: 66, + name: TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME, + apply: migrate_ticket_targets_and_workdir_capabilities_v65_to_v66, + }, ]; #[derive(Clone, Copy)] @@ -873,6 +882,7 @@ pub struct WorkerWorkdirLinkRecord { pub worker: RuntimeWorkerRef, pub workdir_id: String, pub alias: String, + pub capabilities: workdir::WorkdirSessionCapabilities, pub linked_at: String, pub unlinked_at: Option, } @@ -1721,6 +1731,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { &self, record: &WorkerWorkdirLinkRecord, ) -> Result; + fn replace_worker_workdir_link_capabilities( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + active_links: &[WorkerWorkdirLinkRecord], + ) -> Result<()>; fn detach_worker_workdir( &self, workspace_id: &str, @@ -6848,23 +6864,29 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ))); } - let (state, repository_id, ref_selector): (String, Option, Option) = tx - .query_row( - "SELECT workflow_state, repository_id, ref_selector FROM typed_tickets - WHERE workspace_id = ?1 AND ticket_id = ?2", - params![record.workspace_id, record.ticket_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - )?; + let state: String = tx.query_row( + "SELECT workflow_state FROM typed_tickets + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![record.workspace_id, record.ticket_id], + |row| row.get(0), + )?; if state != "ready" { return Err(Error::TicketAssignmentConflict(format!( "manual Coder assignment requires ready Ticket; current state is `{state}`" ))); } - if repository_id.as_deref().is_none_or(str::is_empty) - || ref_selector.as_deref().is_none_or(str::is_empty) - { + let (target_count, read_write_count): (i64, i64) = tx.query_row( + "SELECT COUNT(*), + COALESCE(SUM(CASE WHEN access = 'read_write' THEN 1 ELSE 0 END), 0) + FROM typed_ticket_targets + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![record.workspace_id, record.ticket_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if target_count == 0 || read_write_count != 1 { return Err(Error::TicketAssignmentConflict( - "manual Coder assignment requires a valid repository/ref target".to_string(), + "manual Coder assignment requires exactly one read_write repository target" + .to_string(), )); } let unresolved_blockers: i64 = tx.query_row( @@ -8034,9 +8056,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } tx.execute( r#"INSERT INTO worker_workdir_links ( - workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL) + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) ON CONFLICT(workspace_id, worker_id, workdir_id, alias) DO UPDATE SET + capabilities = excluded.capabilities, linked_at = excluded.linked_at, unlinked_at = NULL"#, params![ @@ -8045,6 +8068,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.worker.worker_id, record.workdir_id, record.alias, + encode_workdir_link_capabilities(record.capabilities)?, record.linked_at, ], ) @@ -8118,7 +8142,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let active_for_alias = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND alias = ?4 AND unlinked_at IS NULL"#, @@ -8131,8 +8155,26 @@ impl ControlPlaneStore for SqliteWorkspaceStore { read_worker_workdir_link_record, ) .optional()?; - if let Some(active) = active_for_alias { + if let Some(mut active) = active_for_alias { if active.workdir_id == record.workdir_id { + let capabilities = active.capabilities.intersection(record.capabilities); + if capabilities != active.capabilities { + tx.execute( + r#"UPDATE worker_workdir_links + SET capabilities = ?1 + WHERE workspace_id = ?2 AND runtime_id = ?3 AND worker_id = ?4 + AND workdir_id = ?5 AND alias = ?6 AND unlinked_at IS NULL"#, + params![ + encode_workdir_link_capabilities(capabilities)?, + record.workspace_id, + record.worker.runtime_id, + record.worker.worker_id, + record.workdir_id, + record.alias, + ], + )?; + active.capabilities = capabilities; + } tx.commit()?; return Ok(active); } @@ -8143,7 +8185,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let active_for_workdir = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#, params![record.workspace_id, record.workdir_id], @@ -8158,9 +8200,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } let write = tx.execute( r#"INSERT INTO worker_workdir_links ( - workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL) + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) ON CONFLICT(workspace_id, worker_id, workdir_id, alias) DO UPDATE SET + capabilities = excluded.capabilities, linked_at = excluded.linked_at, unlinked_at = NULL"#, params![ @@ -8169,6 +8212,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.worker.worker_id, record.workdir_id, record.alias, + encode_workdir_link_capabilities(record.capabilities)?, record.linked_at, ], ); @@ -8186,6 +8230,92 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn replace_worker_workdir_link_capabilities( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + active_links: &[WorkerWorkdirLinkRecord], + ) -> Result<()> { + self.with_conn(|conn| { + let tx = rusqlite::Transaction::new_unchecked( + conn, + rusqlite::TransactionBehavior::Immediate, + )?; + let mut stmt = tx.prepare( + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at + FROM worker_workdir_links + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND unlinked_at IS NULL"#, + )?; + let persisted = stmt + .query_map( + params![workspace_id, worker.runtime_id, worker.worker_id], + read_worker_workdir_link_record, + )? + .collect::, _>>()?; + drop(stmt); + + let mut persisted_identity = persisted + .iter() + .map(|link| (link.alias.as_str(), link.workdir_id.as_str())) + .collect::>(); + persisted_identity.sort_unstable(); + let mut requested_identity = active_links + .iter() + .map(|link| { + if link.workspace_id != workspace_id + || link.worker != *worker + || link.unlinked_at.is_some() + { + return Err(Error::WorkdirAttachmentConflict( + "Workdir capability replacement contains a foreign or inactive attachment" + .to_string(), + )); + } + workdir::WorkdirAttachmentAlias::new(link.alias.clone()).map_err(|error| { + Error::WorkdirAttachmentConflict(format!( + "invalid Workdir attachment alias `{}`: {error}", + link.alias + )) + })?; + Ok((link.alias.as_str(), link.workdir_id.as_str())) + }) + .collect::>>()?; + requested_identity.sort_unstable(); + if persisted_identity != requested_identity { + return Err(Error::WorkdirAttachmentConflict(format!( + "Worker {}:{} attachments changed during capability replacement", + worker.runtime_id, worker.worker_id + ))); + } + + for link in active_links { + let changed = tx.execute( + r#"UPDATE worker_workdir_links + SET capabilities = ?1 + WHERE workspace_id = ?2 AND runtime_id = ?3 AND worker_id = ?4 + AND workdir_id = ?5 AND alias = ?6 AND unlinked_at IS NULL"#, + params![ + encode_workdir_link_capabilities(link.capabilities)?, + workspace_id, + worker.runtime_id, + worker.worker_id, + link.workdir_id, + link.alias, + ], + )?; + if changed != 1 { + return Err(Error::WorkdirAttachmentConflict(format!( + "Worker {}:{} attachment `{}` changed during capability replacement", + worker.runtime_id, worker.worker_id, link.alias + ))); + } + } + tx.commit()?; + Ok(()) + }) + } + fn detach_worker_workdir( &self, workspace_id: &str, @@ -8200,7 +8330,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )?; let active = tx .query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND (?4 IS NULL OR workdir_id = ?4) AND unlinked_at IS NULL"#, @@ -8264,7 +8394,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL ORDER BY linked_at DESC"#, @@ -8285,7 +8415,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL ORDER BY linked_at DESC"#, @@ -8306,7 +8436,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at FROM worker_workdir_links WHERE workspace_id = ?1 AND workdir_id = ?2 ORDER BY linked_at DESC, rowid DESC @@ -8993,16 +9123,47 @@ fn read_device_login_flow_record( }) } +fn encode_workdir_link_capabilities( + capabilities: workdir::WorkdirSessionCapabilities, +) -> Result<&'static str> { + if capabilities == workdir::WorkdirSessionCapabilities::ALL { + Ok("all") + } else if capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY { + Ok("read_only") + } else { + Err(Error::InvalidInput( + "Workdir attachment capabilities must be exactly all or read_only".to_string(), + )) + } +} + +fn decode_workdir_link_capabilities( + value: &str, + column: usize, +) -> rusqlite::Result { + match value { + "all" => Ok(workdir::WorkdirSessionCapabilities::ALL), + "read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY), + _ => Err(rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + format!("invalid Worker Workdir link capabilities {value:?}").into(), + )), + } +} + fn read_worker_workdir_link_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { + let capabilities = decode_workdir_link_capabilities(&row.get::<_, String>(5)?, 5)?; Ok(WorkerWorkdirLinkRecord { workspace_id: row.get(0)?, worker: RuntimeWorkerRef::new(row.get::<_, String>(1)?, row.get::<_, String>(2)?), workdir_id: row.get(3)?, alias: row.get(4)?, - linked_at: row.get(5)?, - unlinked_at: row.get(6)?, + capabilities, + linked_at: row.get(6)?, + unlinked_at: row.get(7)?, }) } @@ -9913,13 +10074,27 @@ fn read_workdir_registry_record( fn prepare_connection(conn: &Connection) -> Result<()> { configure_sqlite(conn)?; - ticket::migrate_sqlite_ticket_schema(conn) - .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; - merge_request::migrate(conn).map_err(|error| { - Error::Store(format!("Merge Request schema verification failed: {error}")) - })?; - apply_migrations(conn) - .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; + let workspace_schema_version = current_schema_version(conn)?; + if workspace_schema_version == 0 { + ticket::migrate_sqlite_ticket_schema(conn) + .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; + merge_request::migrate(conn).map_err(|error| { + Error::Store(format!("Merge Request schema verification failed: {error}")) + })?; + apply_migrations(conn) + .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; + } else { + // Workspace migrations own cross-domain table rebuilds. Apply them before + // component verification so the Ticket crate sees its new canonical target + // schema and migration marker rather than rejecting the legacy singular shape. + apply_migrations(conn) + .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; + ticket::migrate_sqlite_ticket_schema(conn) + .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; + merge_request::migrate(conn).map_err(|error| { + Error::Store(format!("Merge Request schema verification failed: {error}")) + })?; + } validate_workspace_resource_references(conn)?; verify_workspace_resource_constraints(conn) } @@ -11354,8 +11529,499 @@ fn migrate_external_workdir_cleanup_v64_to_v65(conn: &Connection) -> Result<()> Ok(()) } +fn migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 65 { + return Err(Error::Store(format!( + "expected schema version 65 before {TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME} migration, found {current}" + ))); + } + apply_ticket_targets_and_workdir_capabilities_schema(conn) +} + +fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Result<()> { + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + let legacy_alter_table_enabled = + conn.query_row("PRAGMA legacy_alter_table", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + if !legacy_alter_table_enabled { + conn.execute_batch("PRAGMA legacy_alter_table = ON;")?; + } + + let result = (|| { + let transaction = + rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let db: &Connection = &transaction; + let ticket_columns = table_columns(db, "typed_tickets")? + .into_iter() + .collect::>(); + let has_repository_id = ticket_columns.contains("repository_id"); + let has_ref_selector = ticket_columns.contains("ref_selector"); + if has_repository_id != has_ref_selector { + return Err(Error::Store( + "typed_tickets has only one legacy singular target column".to_string(), + )); + } + + let ticket_migration_history = { + let mut statement = + db.prepare("SELECT version, name FROM ticket_schema_migrations ORDER BY version")?; + statement + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()? + }; + let expected_ticket_history = vec![( + TICKET_SCHEMA_VERSION_WITH_TARGETS - 1, + TICKET_SCHEMA_BASELINE_NAME.to_string(), + )]; + if !has_repository_id || ticket_migration_history != expected_ticket_history { + return Err(Error::Store(format!( + "schema-65 Ticket authority is not the canonical version {} singular-target baseline: columns={ticket_columns:?}, history={ticket_migration_history:?}", + TICKET_SCHEMA_VERSION_WITH_TARGETS - 1 + ))); + } + + if has_repository_id { + if table_exists(db, "typed_ticket_targets")? { + return Err(Error::Store( + "legacy singular Ticket targets coexist with typed_ticket_targets".to_string(), + )); + } + let incomplete_targets: i64 = db.query_row( + "SELECT COUNT(*) FROM typed_tickets + WHERE (repository_id IS NULL AND ref_selector IS NOT NULL) + OR (repository_id IS NOT NULL AND trim(repository_id) = '') + OR (ref_selector IS NOT NULL AND trim(ref_selector) = '') + OR (repository_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM repositories AS repository + WHERE repository.workspace_id = typed_tickets.workspace_id + AND repository.repository_id = typed_tickets.repository_id + ))", + [], + |row| row.get(0), + )?; + if incomplete_targets != 0 { + return Err(Error::Store(format!( + "cannot migrate {incomplete_targets} Ticket(s) with incomplete singular targets" + ))); + } + let mixed_version_attributes: i64 = db.query_row( + "SELECT COUNT(*) FROM typed_ticket_event_attributes + WHERE key IN ('targets', 'fingerprint_version')", + [], + |row| row.get(0), + )?; + if mixed_version_attributes != 0 { + return Err(Error::Store(format!( + "cannot migrate schema-65 Ticket evidence containing {mixed_version_attributes} canonical target attribute(s)" + ))); + } + let incomplete_event_targets: i64 = db.query_row( + "SELECT COUNT(*) + FROM typed_ticket_event_attributes AS attribute + WHERE attribute.key IN ('repository_id', 'ref_selector') + AND NOT EXISTS ( + SELECT 1 FROM typed_ticket_event_attributes AS counterpart + WHERE counterpart.workspace_id = attribute.workspace_id + AND counterpart.ticket_id = attribute.ticket_id + AND counterpart.event_index = attribute.event_index + AND counterpart.key = CASE attribute.key + WHEN 'repository_id' THEN 'ref_selector' + ELSE 'repository_id' + END + )", + [], + |row| row.get(0), + )?; + if incomplete_event_targets != 0 { + return Err(Error::Store(format!( + "cannot migrate {incomplete_event_targets} incomplete legacy Ticket target evidence attribute(s)" + ))); + } + let legacy_event_targets = { + let mut statement = db.prepare( + "SELECT attribute.workspace_id, attribute.ticket_id, attribute.event_index, + repository.repository_key, selector.value + FROM typed_ticket_event_attributes AS attribute + LEFT JOIN typed_ticket_event_attributes AS selector + ON selector.workspace_id = attribute.workspace_id + AND selector.ticket_id = attribute.ticket_id + AND selector.event_index = attribute.event_index + AND selector.key = 'ref_selector' + LEFT JOIN repositories AS repository + ON repository.workspace_id = attribute.workspace_id + AND repository.repository_id = attribute.value + WHERE attribute.key = 'repository_id' + ORDER BY attribute.workspace_id, attribute.ticket_id, attribute.event_index", + )?; + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + )) + })? + .collect::>>()? + }; + for (_, ticket_id, _, repository_key, ref_selector) in &legacy_event_targets { + if repository_key.is_none() || ref_selector.is_none() { + return Err(Error::Store(format!( + "cannot migrate legacy target evidence for Ticket {ticket_id:?}" + ))); + } + } + db.execute_batch( + r#" + CREATE TEMP TABLE typed_ticket_targets_v66 AS + SELECT ticket.workspace_id, ticket.ticket_id, 0 AS ordinal, + repository.repository_key, + ticket.ref_selector, 'read_write' AS access + FROM typed_tickets AS ticket + JOIN repositories AS repository + ON repository.workspace_id = ticket.workspace_id + AND repository.repository_id = ticket.repository_id + WHERE ticket.repository_id IS NOT NULL; + ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66; + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + created_at, updated_at, assignee, readiness, workflow_state, + workflow_state_explicit, queued_by, queued_at, resolution + ) + SELECT workspace_id, ticket_id, slug, title, status, kind, priority, body, + created_at, updated_at, assignee, readiness, workflow_state, + workflow_state_explicit, queued_by, queued_at, resolution + FROM typed_tickets_legacy_v66; + DROP TABLE typed_tickets_legacy_v66; + CREATE INDEX idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); + CREATE INDEX idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; + CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_key) + REFERENCES repositories(workspace_id, repository_key) ON DELETE RESTRICT + ); + INSERT INTO typed_ticket_targets ( + workspace_id, ticket_id, ordinal, repository_key, ref_selector, access + ) + SELECT workspace_id, ticket_id, ordinal, repository_key, ref_selector, access + FROM typed_ticket_targets_v66; + DROP TABLE typed_ticket_targets_v66; + CREATE INDEX typed_ticket_targets_workspace_repository + ON typed_ticket_targets(workspace_id, repository_key, ticket_id); + "#, + )?; + for (workspace_id, ticket_id, event_index, repository_key, ref_selector) in + legacy_event_targets + { + let repository_key = repository_key.ok_or_else(|| { + Error::Store(format!( + "legacy target evidence for Ticket {ticket_id:?} has no repository key" + )) + })?; + let ref_selector = ref_selector.ok_or_else(|| { + Error::Store(format!( + "legacy target evidence for Ticket {ticket_id:?} has no ref selector" + )) + })?; + let targets = serde_json::to_string(&[serde_json::json!({ + "repository_key": repository_key, + "ref_selector": ref_selector, + "access": "read_write", + })]) + .map_err(|error| { + Error::Store(format!( + "serialize migrated Ticket target evidence: {error}" + )) + })?; + db.execute( + "INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES (?1, ?2, ?3, 'targets', ?4)", + params![workspace_id, ticket_id, event_index, targets], + )?; + db.execute( + "INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES (?1, ?2, ?3, 'fingerprint_version', '1')", + params![workspace_id, ticket_id, event_index], + )?; + } + db.execute( + "DELETE FROM typed_ticket_event_attributes + WHERE key IN ('repository_id', 'ref_selector')", + [], + )?; + } + + if column_exists(db, "worker_workdir_links", "capabilities")? { + return Err(Error::Store( + "schema-65 Worker Workdir links already contain schema-66 capabilities".to_string(), + )); + } + let unmappable_links: i64 = db.query_row( + "SELECT COUNT(*) + FROM worker_workdir_links AS link + LEFT JOIN workdir_registry AS registry + ON registry.workspace_id = link.workspace_id + AND registry.workdir_id = link.workdir_id + WHERE registry.workdir_id IS NULL + OR registry.source_kind NOT IN ('repository', 'external_grant')", + [], + |row| row.get(0), + )?; + if unmappable_links != 0 { + return Err(Error::Store(format!( + "cannot migrate {unmappable_links} Workdir attachment link(s) without a supported registered source" + ))); + } + db.execute_batch( + r#" + CREATE TEMP TABLE worker_workdir_links_v66 AS + SELECT link.workspace_id, link.runtime_id, link.worker_id, + link.workdir_id, link.alias, + CASE registry.source_kind + WHEN 'repository' THEN 'all' + WHEN 'external_grant' THEN 'read_only' + END AS capabilities, + link.linked_at, link.unlinked_at + FROM worker_workdir_links AS link + JOIN workdir_registry AS registry + ON registry.workspace_id = link.workspace_id + AND registry.workdir_id = link.workdir_id; + ALTER TABLE worker_workdir_links RENAME TO worker_workdir_links_legacy_v66; + CREATE TABLE worker_workdir_links ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + capabilities TEXT NOT NULL CHECK (capabilities IN ('all', 'read_only')), + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links ( + workspace_id, runtime_id, worker_id, workdir_id, alias, + capabilities, linked_at, unlinked_at + ) + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, + capabilities, linked_at, unlinked_at + FROM worker_workdir_links_v66; + DROP TABLE worker_workdir_links_legacy_v66; + DROP TABLE worker_workdir_links_v66; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal + BEFORE INSERT ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal + BEFORE UPDATE ON worker_workdir_links FOR EACH ROW + WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + "#, + )?; + + let invalid_capabilities: i64 = db.query_row( + "SELECT COUNT(*) FROM worker_workdir_links + WHERE capabilities IS NULL OR capabilities NOT IN ('all', 'read_only')", + [], + |row| row.get(0), + )?; + if invalid_capabilities != 0 { + return Err(Error::Store(format!( + "schema-66 migration produced {invalid_capabilities} invalid Workdir attachment capability row(s)" + ))); + } + + db.execute("DELETE FROM ticket_schema_migrations", [])?; + db.execute( + "INSERT INTO ticket_schema_migrations (version, name, applied_at) + VALUES (?1, ?2, CURRENT_TIMESTAMP)", + params![ + TICKET_SCHEMA_VERSION_WITH_TARGETS, + TICKET_SCHEMA_BASELINE_NAME + ], + )?; + ticket::verify_sqlite_ticket_schema(db).map_err(|error| { + Error::Store(format!( + "schema-66 Ticket schema verification failed: {error}" + )) + })?; + + let foreign_key_violations = + db.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if foreign_key_violations != 0 { + return Err(Error::Store(format!( + "schema-66 migration left {foreign_key_violations} foreign-key violation(s)" + ))); + } + db.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![ + 66_i64, + TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME + ], + )?; + transaction.commit()?; + Ok(()) + })(); + + let restore_result = match (legacy_alter_table_enabled, foreign_keys_enabled) { + (false, true) => { + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + } + (false, false) => conn.execute_batch("PRAGMA legacy_alter_table = OFF;"), + (true, true) => conn.execute_batch("PRAGMA foreign_keys = ON;"), + (true, false) => Ok(()), + } + .map_err(Error::from); + match (result, restore_result) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { + conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch(include_str!("latest_schema.sql"))?; + conn.execute_batch( + r#" + ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66; + CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + DROP TABLE typed_tickets_legacy_v66; + CREATE INDEX idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); + CREATE INDEX idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; + CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_key) + REFERENCES repositories(workspace_id, repository_key) ON DELETE RESTRICT + ); + CREATE INDEX typed_ticket_targets_workspace_repository + ON typed_ticket_targets(workspace_id, repository_key, ticket_id); + ALTER TABLE worker_workdir_links + ADD COLUMN capabilities TEXT NOT NULL + CHECK (capabilities IN ('all', 'read_only')); + "#, + )?; + ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { + Error::Store(format!( + "fresh Workspace Ticket schema verification failed: {error}" + )) + })?; Ok(()) } @@ -11446,6 +12112,22 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Result Result<()> { let plan = workspace_schema_migration_plan(conn)?; if plan.current_schema_version == 0 { - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - create_latest_workspace_schema(&tx)?; - tx.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", - params![LATEST_SCHEMA_VERSION, SCHEMA_BASELINE_NAME], - )?; - tx.commit()?; + let foreign_keys_enabled = + conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0; + let legacy_alter_table_enabled = + conn.query_row("PRAGMA legacy_alter_table", [], |row| row.get::<_, i64>(0))? != 0; + if foreign_keys_enabled { + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + } + if !legacy_alter_table_enabled { + conn.execute_batch("PRAGMA legacy_alter_table = ON;")?; + } + let create_result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + create_latest_workspace_schema(&tx)?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![LATEST_SCHEMA_VERSION, SCHEMA_BASELINE_NAME], + )?; + tx.commit()?; + Ok(()) + })(); + let restore_result = match (legacy_alter_table_enabled, foreign_keys_enabled) { + (false, true) => { + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + } + (false, false) => conn.execute_batch("PRAGMA legacy_alter_table = OFF;"), + (true, true) => conn.execute_batch("PRAGMA foreign_keys = ON;"), + (true, false) => Ok(()), + } + .map_err(Error::from); + match (create_result, restore_result) { + (Err(error), _) => return Err(error), + (Ok(()), Err(error)) => return Err(error), + (Ok(()), Ok(())) => {} + } } else { for step in &plan.migrations { let migration = MIGRATIONS @@ -11988,6 +12697,19 @@ fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) mod tests { use super::*; + fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> { + conn.execute_batch( + "DROP TABLE typed_ticket_targets; + ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; + ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; + ALTER TABLE worker_workdir_links DROP COLUMN capabilities; + DELETE FROM ticket_schema_migrations; + INSERT INTO ticket_schema_migrations(version, name, applied_at) + VALUES (6, 'ticket schema baseline', CURRENT_TIMESTAMP);", + )?; + Ok(()) + } + #[tokio::test] async fn external_workdir_grant_persists_typed_source_and_fences_generations() { let store = SqliteWorkspaceStore::in_memory().unwrap(); @@ -12693,6 +13415,7 @@ mod tests { let store = SqliteWorkspaceStore::open(&path).unwrap(); store .with_conn(|conn| { + downgrade_schema_66_ticket_and_workdir_authority(conn)?; conn.execute_batch( "DROP TRIGGER runtime_binding_insert_blocked_by_removal; \ DROP TRIGGER runtime_binding_update_blocked_by_removal; \ @@ -12761,6 +13484,7 @@ mod tests { drop(SqliteWorkspaceStore::open(&path).unwrap()); { let conn = Connection::open(&path).unwrap(); + downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap(); conn.execute_batch( "ALTER TABLE worker_removal_operations \ ADD COLUMN run_generation INTEGER NOT NULL DEFAULT 0; \ @@ -12843,6 +13567,219 @@ mod tests { .unwrap(); } + #[test] + fn schema_v65_migrates_ticket_targets_and_workdir_capabilities() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + drop(SqliteWorkspaceStore::open(&path).unwrap()); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch( + r#" + PRAGMA foreign_keys = OFF; + DROP TABLE typed_ticket_targets; + ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; + ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; + DROP INDEX worker_workdir_links_active_workdir_unique; + DROP INDEX worker_workdir_links_active_alias_unique; + DROP INDEX worker_workdir_links_workdir; + CREATE TABLE worker_workdir_links_v65 ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links_v65 ( + workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + ) + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + FROM worker_workdir_links; + DROP TABLE worker_workdir_links; + ALTER TABLE worker_workdir_links_v65 RENAME TO worker_workdir_links; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + DELETE FROM __yoi_schema_migrations; + INSERT INTO __yoi_schema_migrations(version, name) + VALUES (65, 'workspace schema baseline'); + DELETE FROM ticket_schema_migrations; + INSERT INTO ticket_schema_migrations(version, name, applied_at) + VALUES (6, 'ticket schema baseline', '1'); + + INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces( + workspace_id, owner_account_id, display_name, state, created_at, updated_at + ) VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); + INSERT INTO repositories( + workspace_id, repository_id, repository_key, kind, provider, uri, default_ref, + created_at, updated_at, source_kind, source_uri, source_revision, + source_fingerprint, observed_status, observed_at + ) VALUES + ('workspace-a', 'repo-main', 'main', 'git', 'git', '/main', 'develop', + '1', '1', 'local_path', '/main', 1, 'sha256:main', 'unverified', NULL); + INSERT INTO typed_tickets( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + created_at, updated_at, assignee, readiness, workflow_state, + workflow_state_explicit, queued_by, queued_at, resolution, + repository_id, ref_selector + ) VALUES ( + 'workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task', 'P2', '', + '1', '1', NULL, NULL, 'planning', 1, NULL, NULL, NULL, + 'repo-main', 'develop' + ); + INSERT INTO typed_ticket_events( + workspace_id, ticket_id, event_index, kind, author, at, status, + from_state, to_state, reason, state_field, heading, body + ) VALUES ( + 'workspace-a', 'ticket-a', 0, 'state_changed', 'user', '1', NULL, + 'planning', 'ready', NULL, 'state', 'State changed', '' + ); + INSERT INTO typed_ticket_event_attributes( + workspace_id, ticket_id, event_index, key, value + ) VALUES + ('workspace-a', 'ticket-a', 0, 'repository_id', 'repo-main'), + ('workspace-a', 'ticket-a', 0, 'ref_selector', 'develop'); + INSERT INTO worker_registry( + workspace_id, worker_id, runtime_id, display_name, profile, retention_state, + transcript_ref, session_ref, summary_ref, diagnostics_ref, created_at, updated_at + ) VALUES + ('workspace-a', 'worker-repository', 'runtime-a', 'Repository Worker', NULL, + 'normal', NULL, NULL, NULL, NULL, '1', '1'), + ('workspace-a', 'worker-external', 'runtime-a', 'External Worker', NULL, + 'normal', NULL, NULL, NULL, NULL, '1', '1'); + INSERT INTO workdir_registry( + workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, + external_grant_id, creation_selector, creation_ref, materialization_status, + cleanliness, created_at, updated_at, current_selector, current_ref, + creation_tree, current_tree, observed_at_epoch_seconds + ) VALUES + ('workspace-a', 'workdir-repository', 'Repository', 'repository', 'runtime-a', + 'repo-main', NULL, 'develop', 'abc', 'present', 'clean', '1', '1', + 'develop', 'abc', NULL, NULL, NULL), + ('workspace-a', 'workdir-external', 'External', 'external_grant', NULL, + NULL, 'grant-a', NULL, NULL, 'present', 'unknown', '1', '1', + NULL, NULL, NULL, NULL, NULL); + INSERT INTO external_workdir_grants( + grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at, + cleanup_state, cleanup_error, cleanup_updated_at + ) VALUES ( + 'grant-a', 'workspace-a', 'workdir-external', 'provider-a', 'External', + 'read_only', 'owner', '1', '9', 1, 'online', '1', + 'not_required', NULL, NULL + ); + INSERT INTO worker_workdir_links( + workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + ) VALUES + ('workspace-a', 'runtime-a', 'worker-repository', 'workdir-repository', + 'checkout', '1', NULL), + ('workspace-a', 'runtime-a', 'worker-external', 'workdir-external', + 'external', '1', NULL); + PRAGMA foreign_keys = ON; + "#, + ) + .unwrap(); + + migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 66); + ticket::verify_sqlite_ticket_schema(&conn).unwrap(); + assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap()); + assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap()); + assert_eq!( + conn.query_row( + "SELECT repository_key || ':' || ref_selector || ':' || access + FROM typed_ticket_targets + WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a' AND ordinal = 0", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "main:develop:read_write" + ); + let target_evidence: String = conn + .query_row( + "SELECT value FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a' + AND event_index = 0 AND key = 'targets'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&target_evidence).unwrap(), + serde_json::json!([{ + "repository_key": "main", + "ref_selector": "develop", + "access": "read_write" + }]) + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a' + AND event_index = 0 AND key IN ('repository_id', 'ref_selector')", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + assert_eq!( + conn.query_row( + "SELECT value FROM typed_ticket_event_attributes + WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a' + AND event_index = 0 AND key = 'fingerprint_version'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "1" + ); + assert_eq!( + conn.query_row( + "SELECT version || ':' || name FROM ticket_schema_migrations", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "7:ticket schema baseline" + ); + assert_eq!( + conn.query_row( + "SELECT group_concat(workdir_id || ':' || capabilities, ',') + FROM (SELECT workdir_id, capabilities FROM worker_workdir_links ORDER BY workdir_id)", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "workdir-external:read_only,workdir-repository:all" + ); + assert_eq!( + conn.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0)) + .unwrap(), + "ok" + ); + let foreign_key_failures: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(foreign_key_failures, 0); + } + #[test] fn current_schema_accepts_every_retained_canonical_provenance() { for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION { @@ -12888,6 +13825,10 @@ mod tests { )] ); ticket::verify_sqlite_ticket_schema(conn)?; + assert!(table_exists(conn, "typed_ticket_targets")?); + assert!(!column_exists(conn, "typed_tickets", "repository_id")?); + assert!(!column_exists(conn, "typed_tickets", "ref_selector")?); + assert!(column_exists(conn, "worker_workdir_links", "capabilities")?); merge_request::migrate(conn)?; let foreign_key_failures: i64 = conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { @@ -12904,7 +13845,12 @@ mod tests { configure_sqlite(&conn).unwrap(); ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); merge_request::migrate(&conn).unwrap(); + conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;") + .unwrap(); create_latest_workspace_schema(&conn).unwrap(); + downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap(); + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + .unwrap(); conn.execute_batch( r#" DROP TABLE workdir_create_credential_revision_retentions; @@ -13099,6 +14045,10 @@ mod tests { version: 65, name: EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 66, + name: TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME.to_string(), + }, ] ); @@ -13153,6 +14103,10 @@ mod tests { (63, MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME.to_string()), (64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME.to_string()), (65, EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME.to_string()), + ( + 66, + TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME.to_string(), + ), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -13223,7 +14177,7 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65] + vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -13231,8 +14185,9 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 16); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 17); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); + assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); } @@ -14370,7 +15325,11 @@ mod tests { ) .unwrap(); let mut input = ticket::NewTicket::new("Foreign repository"); - input.repository_id = Some("main".to_string()); + input.targets = vec![ticket::TicketTarget { + repository_key: "main".to_string(), + ref_selector: None, + access: ticket::TicketTargetAccess::ReadWrite, + }]; let error = ticket::TicketBackend::create(&backend, input).unwrap_err(); assert!( error.to_string().contains("FOREIGN KEY constraint failed"), @@ -15227,8 +16186,11 @@ INSERT INTO worker_registry ( let mut input = ticket::NewTicket::new("Role assignment"); input.body = ticket::MarkdownText::new("test"); input.workflow_state = Some(ticket::TicketWorkflowState::Ready); - input.repository_id = Some("main".to_string()); - input.ref_selector = Some("develop".to_string()); + input.targets = vec![ticket::TicketTarget { + repository_key: "main".to_string(), + ref_selector: Some("develop".to_string()), + access: ticket::TicketTargetAccess::ReadWrite, + }]; let ticket = ticket::TicketBackend::create(&backend, input).unwrap(); let orchestrator = TicketRoleAssignmentRecord { @@ -15991,6 +16953,7 @@ INSERT INTO worker_registry ( worker: worker.worker.clone(), workdir_id: workdir.workdir_id.clone(), alias: "checkout".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, linked_at: "4".to_string(), unlinked_at: None, }; @@ -16021,6 +16984,19 @@ INSERT INTO worker_registry ( link ); assert_eq!(store.attach_worker_workdir(&link).unwrap(), link); + let downgraded_link = WorkerWorkdirLinkRecord { + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + ..link.clone() + }; + assert_eq!( + store.attach_worker_workdir(&downgraded_link).unwrap(), + downgraded_link + ); + assert_eq!( + store.attach_worker_workdir(&link).unwrap(), + downgraded_link, + "reattaching an existing link cannot expand its persisted capability ceiling" + ); assert_eq!( store @@ -16042,7 +17018,7 @@ INSERT INTO worker_registry ( store .list_worker_workdir_links("local-dev", &worker.worker) .unwrap(), - vec![link.clone()] + vec![downgraded_link.clone()] ); let worker_conflict = WorkerWorkdirLinkRecord { @@ -16065,8 +17041,17 @@ INSERT INTO worker_registry ( )); let second_attachment = WorkerWorkdirLinkRecord { alias: "docs".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, ..worker_conflict }; + let invalid_capabilities = WorkerWorkdirLinkRecord { + capabilities: workdir::WorkdirSessionCapabilities::EMPTY, + ..second_attachment.clone() + }; + assert!(matches!( + store.attach_worker_workdir(&invalid_capabilities), + Err(Error::InvalidInput(_)) + )); assert_eq!( store.attach_worker_workdir(&second_attachment).unwrap(), second_attachment @@ -16114,6 +17099,21 @@ INSERT INTO worker_registry ( store.attach_worker_workdir(&workdir_conflict).unwrap(), workdir_conflict ); + + drop(store); + let reopened = SqliteWorkspaceStore::open(&db).unwrap(); + assert_eq!( + reopened + .list_worker_workdir_links("local-dev", &worker.worker) + .unwrap(), + vec![second_attachment] + ); + assert_eq!( + reopened + .list_worker_workdir_links("local-dev", &second_worker.worker) + .unwrap(), + vec![workdir_conflict] + ); } #[tokio::test] diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index e09c9355a..fb6fe3f03 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -56,6 +56,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "typed_ticket_raw_frontmatter", "typed_ticket_relations", "typed_ticket_risk_flags", + "typed_ticket_targets", "typed_tickets", "workdir_create_credential_candidates", "workdir_create_credential_revision_retentions", diff --git a/crates/yoi/src/workdir_share.rs b/crates/yoi/src/workdir_share.rs index 72438a218..1786e7524 100644 --- a/crates/yoi/src/workdir_share.rs +++ b/crates/yoi/src/workdir_share.rs @@ -7,7 +7,9 @@ use std::time::{Duration, Instant}; use chrono::Utc; use client::BackendApiClient; use futures::{SinkExt, StreamExt}; -use server_api::{ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse}; +use server_api::{ + ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse, ServerApiClient, +}; use tokio_tungstenite::connect_async_with_config; use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig}; use workdir::external::{ @@ -95,40 +97,57 @@ fn pin_selected_root_from( ExternalWorkdirRoot::pin(absolute).map_err(|error| error.to_string()) } +fn authenticated_server_api_client( + backend_client: &BackendApiClient, + backend_url: &str, +) -> Result { + // BackendApiClient remains the stored-credential authority used by the + // manual provider WebSocket. Reuse that transport's Authorization header + // for the generated JSON client instead of loading or interpreting the + // credential a second time here. + let authenticated_request = backend_client + .websocket_request("/") + .map_err(|error| error.to_string())?; + let authorization = authenticated_request + .headers() + .get(reqwest::header::AUTHORIZATION) + .cloned() + .ok_or_else(|| "stored Backend credential did not provide Authorization".to_string())?; + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::AUTHORIZATION, authorization); + let http_client = reqwest::Client::builder() + .default_headers(headers) + .timeout(Duration::from_secs(10)) + .build() + .map_err(|error| format!("failed to configure Backend client: {error}"))?; + let base_url = backend_url + .try_into() + .map_err(|error| format!("invalid Backend URL: {error}"))?; + Ok(ServerApiClient::with_client(base_url, http_client)) +} + pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { // Pin before creating remote authority: the Backend grant can never outlive // a failed local approval/open race, and subsequent path replacement cannot // redirect the provider. let pinned_root = pin_selected_root(&options.path)?; - let client = BackendApiClient::from_stored_token(&options.backend_url) + let backend_client = BackendApiClient::from_stored_token(&options.backend_url) .map_err(|error| error.to_string())?; + let server_api_client = authenticated_server_api_client(&backend_client, &options.backend_url)?; let provider_instance_id = format!("cli-{}", uuid::Uuid::now_v7()); - let create_path = format!( - "/api/w/{}/external-workdir-grants", - encode_path_segment(&options.workspace_id) - ); - let response = client - .request(reqwest::Method::POST, &create_path) - .map_err(|error| error.to_string())? - .timeout(Duration::from_secs(10)) - .json(&ExternalWorkdirGrantCreateRequest { - provider_instance_id: provider_instance_id.clone(), - display_name: options.display_name.clone(), - ttl_seconds: options.ttl.as_secs(), - read_only: true, - }) - .send() + let grant = server_api_client + .external_workdir_grant_create( + options.workspace_id.clone(), + ExternalWorkdirGrantCreateRequest { + provider_instance_id: provider_instance_id.clone(), + display_name: options.display_name.clone(), + ttl_seconds: options.ttl.as_secs(), + read_only: true, + }, + ) .await .map_err(|error| format!("External Workdir grant request failed: {error}"))?; - let response = client - .require_success(response) - .await - .map_err(|error| error.to_string())?; - let grant = response - .json::() - .await - .map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}"))?; let limits = BoundedReadLimits::EXTERNAL_DEFAULT; let session = LocalWorkdirSession::external_read_only_pinned( @@ -150,7 +169,8 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { let mut generation = grant.generation; loop { let outcome = serve_provider_connection( - &client, + &backend_client, + &server_api_client, &options, &grant, &provider_instance_id, @@ -166,11 +186,12 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { "External Workdir grant expired after provider connection failure: {connection_error}" )); } - let refresh = fetch_grant(&client, &options.workspace_id, &grant.grant_id); + let refresh = + fetch_grant(&server_api_client, &options.workspace_id, &grant.grant_id); let refreshed = tokio::select! { signal = tokio::signal::ctrl_c() => { signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; - revoke(&client, &options.workspace_id, &grant.grant_id).await?; + revoke(&server_api_client, &options.workspace_id, &grant.grant_id).await?; println!("External Workdir grant revoked."); return Ok(()); } @@ -182,8 +203,12 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { eprintln!( "Connection refresh failed; retrying: {connection_error}; {refresh_error}" ); - if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id) - .await? + if wait_to_reconnect( + &server_api_client, + &options.workspace_id, + &grant.grant_id, + ) + .await? { println!("External Workdir grant revoked."); return Ok(()); @@ -213,7 +238,9 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { } } println!("Connection: reconnecting"); - if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? { + if wait_to_reconnect(&server_api_client, &options.workspace_id, &grant.grant_id) + .await? + { println!("External Workdir grant revoked."); return Ok(()); } @@ -237,7 +264,9 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { .checked_add(1) .ok_or_else(|| "External Workdir provider generation overflow".to_string())?; println!("Connection: reconnecting"); - if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? { + if wait_to_reconnect(&server_api_client, &options.workspace_id, &grant.grant_id) + .await? + { println!("External Workdir grant revoked."); return Ok(()); } @@ -247,7 +276,8 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { } async fn serve_provider_connection( - client: &BackendApiClient, + backend_client: &BackendApiClient, + server_api_client: &ServerApiClient, options: &WorkdirShareOptions, grant: &ExternalWorkdirGrantResponse, provider_instance_id: &str, @@ -259,7 +289,7 @@ async fn serve_provider_connection( encode_path_segment(&options.workspace_id), encode_path_segment(&grant.grant_id) ); - let request = client + let request = backend_client .websocket_request(&provider_path) .map_err(|error| error.to_string())?; let websocket_config = WebSocketConfig::default() @@ -269,7 +299,7 @@ async fn serve_provider_connection( let (mut socket, _) = tokio::select! { signal = tokio::signal::ctrl_c() => { signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; - revoke(client, &options.workspace_id, &grant.grant_id).await?; + revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?; return Ok(ProviderEnd::Interrupted); } connection = connection => connection @@ -292,7 +322,7 @@ async fn serve_provider_connection( let registered = tokio::select! { signal = tokio::signal::ctrl_c() => { signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; - revoke(client, &options.workspace_id, &grant.grant_id).await?; + revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?; return Ok(ProviderEnd::Interrupted); } registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => { @@ -324,7 +354,7 @@ async fn serve_provider_connection( signal = tokio::signal::ctrl_c() => { signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; stop_operations(&mut operations, &mut completions).await?; - revoke(client, &options.workspace_id, &grant.grant_id).await?; + revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?; return Ok(ProviderEnd::Interrupted); } completion = completions.recv() => { @@ -558,7 +588,7 @@ where } async fn wait_to_reconnect( - client: &BackendApiClient, + client: &ServerApiClient, workspace_id: &str, grant_id: &str, ) -> Result { @@ -573,51 +603,27 @@ async fn wait_to_reconnect( } async fn fetch_grant( - client: &BackendApiClient, + client: &ServerApiClient, workspace_id: &str, grant_id: &str, ) -> Result { - let path = format!( - "/api/w/{}/external-workdir-grants/{}", - encode_path_segment(workspace_id), - encode_path_segment(grant_id) - ); - let response = client - .request(reqwest::Method::GET, &path) - .map_err(|error| error.to_string())? - .timeout(Duration::from_secs(10)) - .send() - .await - .map_err(|error| format!("External Workdir grant refresh failed: {error}"))?; client - .require_success(response) + .external_workdir_grant_get(workspace_id.to_string(), grant_id.to_string()) .await - .map_err(|error| error.to_string())? - .json::() - .await - .map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}")) + .map_err(|error| format!("External Workdir grant refresh failed: {error}")) } async fn revoke( - client: &BackendApiClient, + client: &ServerApiClient, workspace_id: &str, grant_id: &str, ) -> Result<(), String> { - let path = format!( - "/api/w/{}/external-workdir-grants/{}", - encode_path_segment(workspace_id), - encode_path_segment(grant_id) - ); - let response = client - .request(reqwest::Method::DELETE, &path) - .map_err(|error| error.to_string())? - .timeout(Duration::from_secs(10)) - .send() + client + .external_workdir_grant_revoke(workspace_id.to_string(), grant_id.to_string()) .await - .map_err(|error| format!("External Workdir revoke request failed: {error}"))?; - client.require_success(response).await.map_err(|error| { - format!("External Workdir revoke was not confirmed by the Backend: {error}") - })?; + .map_err(|error| { + format!("External Workdir revoke was not confirmed by the Backend: {error}") + })?; Ok(()) } @@ -636,6 +642,169 @@ fn encode_path_segment(value: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + + fn test_server_api_client(base_url: &str) -> ServerApiClient { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_static("Bearer test-token"), + ); + let http_client = reqwest::Client::builder() + .default_headers(headers) + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + ServerApiClient::with_client(base_url.try_into().unwrap(), http_client) + } + + fn read_http_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + let mut expected_length = None; + loop { + let read = stream.read(&mut buffer).unwrap(); + assert!(read > 0, "HTTP client closed before completing request"); + request.extend_from_slice(&buffer[..read]); + if expected_length.is_none() + && let Some(header_end) = + request.windows(4).position(|window| window == b"\r\n\r\n") + { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }); + expected_length = Some(header_end + 4 + content_length.unwrap_or(0)); + } + if expected_length.is_some_and(|length| request.len() >= length) { + break; + } + } + String::from_utf8(request).unwrap() + } + + fn spawn_json_server( + response_body: String, + response_count: usize, + ) -> (String, mpsc::Receiver, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let thread = std::thread::spawn(move || { + for _ in 0..response_count { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let status = if request.starts_with("POST ") { + "201 Created" + } else { + "200 OK" + }; + sender.send(request).unwrap(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ) + .unwrap(); + } + }); + (format!("http://{address}"), receiver, thread) + } + + fn grant_response_json() -> String { + serde_json::json!({ + "grant_id": "grant/a b", + "workspace_id": "workspace/a b", + "working_directory_id": "workdir-a", + "provider_instance_id": "provider-a", + "display_name": "Shared files", + "permissions": "read_only", + "expires_at": "2026-09-23T03:00:00Z", + "generation": 7, + "status": "pending" + }) + .to_string() + } + + #[tokio::test] + async fn generated_grant_operations_preserve_auth_and_encode_contract_paths() { + let (base_url, requests, server) = spawn_json_server(grant_response_json(), 3); + let client = test_server_api_client(&base_url); + + let created = client + .external_workdir_grant_create( + "workspace/a b".to_string(), + ExternalWorkdirGrantCreateRequest { + provider_instance_id: "provider-a".to_string(), + display_name: "Shared files".to_string(), + ttl_seconds: 600, + read_only: true, + }, + ) + .await + .unwrap(); + let fetched = fetch_grant(&client, "workspace/a b", "grant/a b") + .await + .unwrap(); + revoke(&client, "workspace/a b", "grant/a b").await.unwrap(); + + assert_eq!(created.grant_id, "grant/a b"); + assert_eq!(fetched.generation, 7); + let requests = (0..3) + .map(|_| requests.recv_timeout(Duration::from_secs(5)).unwrap()) + .collect::>(); + server.join().unwrap(); + assert!( + requests[0] + .starts_with("POST /api/w/workspace%2Fa%20b/external-workdir-grants HTTP/1.1\r\n") + ); + assert!(requests[1].starts_with( + "GET /api/w/workspace%2Fa%20b/external-workdir-grants/grant%2Fa%20b HTTP/1.1\r\n" + )); + assert!(requests[2].starts_with( + "DELETE /api/w/workspace%2Fa%20b/external-workdir-grants/grant%2Fa%20b HTTP/1.1\r\n" + )); + for request in &requests { + assert!( + request + .to_ascii_lowercase() + .contains("\r\nauthorization: bearer test-token\r\n") + ); + } + assert!(requests[0].contains("\"provider_instance_id\":\"provider-a\"")); + assert!(requests[0].contains("\"ttl_seconds\":600")); + } + + #[tokio::test] + async fn generated_grant_response_is_bounded() { + let oversized_body = format!("{{\"padding\":\"{}\"}}", "x".repeat(1_048_576)); + let (base_url, requests, server) = spawn_json_server(oversized_body, 1); + let client = test_server_api_client(&base_url); + + let error = fetch_grant(&client, "workspace-a", "grant-a") + .await + .unwrap_err(); + + let request = requests.recv_timeout(Duration::from_secs(5)).unwrap(); + server.join().unwrap(); + assert!( + request + .starts_with("GET /api/w/workspace-a/external-workdir-grants/grant-a HTTP/1.1\r\n") + ); + assert!(error.contains("External Workdir grant refresh failed")); + assert!( + error.contains("response body exceeded 1048576 bytes"), + "{error}" + ); + } #[test] fn relative_share_path_is_resolved_and_pinned_before_backend_access() { diff --git a/openapi/server-api.json b/openapi/server-api.json index 4e45d76b0..fb2c77fa3 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -1,6 +1,1476 @@ { "components": { "schemas": { + "AcceptedOrchestrationPlan": { + "additionalProperties": false, + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "role_plan": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "type": "string" + }, + "worktree": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "summary" + ], + "type": "object" + }, + "ActorAuthMethod": { + "description": "Authentication method that established the current request actor.", + "enum": [ + "browser_session", + "api_token" + ], + "type": "string" + }, + "Array_of_FlowSourceRecord": { + "items": { + "$ref": "#/components/schemas/FlowSourceRecord" + }, + "type": "array" + }, + "Array_of_MergeRequestThreadEvent": { + "items": { + "$ref": "#/components/schemas/MergeRequestThreadEvent" + }, + "type": "array" + }, + "Array_of_OrchestrationPlanRecord": { + "items": { + "$ref": "#/components/schemas/OrchestrationPlanRecord" + }, + "type": "array" + }, + "Array_of_RepositorySshCredential": { + "items": { + "$ref": "#/components/schemas/RepositorySshCredential" + }, + "type": "array" + }, + "Array_of_RepositorySshHostTrust": { + "items": { + "$ref": "#/components/schemas/RepositorySshHostTrust" + }, + "type": "array" + }, + "Array_of_TicketRelation": { + "items": { + "$ref": "#/components/schemas/TicketRelation" + }, + "type": "array" + }, + "Array_of_TicketSummary": { + "items": { + "$ref": "#/components/schemas/TicketSummary" + }, + "type": "array" + }, + "AttachmentUploadCancelResponse": { + "additionalProperties": false, + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "type": "object" + }, + "AttachmentUploadGrantResponse": { + "additionalProperties": false, + "properties": { + "expires_at_ms": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "upload_id": { + "type": "string" + } + }, + "required": [ + "upload_id", + "expires_at_ms" + ], + "type": "object" + }, + "AuditEvent": { + "oneOf": [ + { + "$ref": "#/components/schemas/WorkerLifecycleAudit", + "properties": { + "event": { + "const": "worker_lifecycle", + "type": "string" + } + }, + "required": [ + "event" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/RecordOperationAudit", + "properties": { + "event": { + "const": "record_operation", + "type": "string" + } + }, + "required": [ + "event" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/RecordUsageAudit", + "properties": { + "event": { + "const": "record_usage", + "type": "string" + } + }, + "required": [ + "event" + ], + "type": "object" + } + ], + "properties": { + "id": { + "type": "string" + }, + "occurred_at": { + "type": "string" + } + }, + "required": [ + "id", + "occurred_at" + ], + "type": "object" + }, + "AuditStatus": { + "enum": [ + "success", + "failed", + "skipped" + ], + "type": "string" + }, + "AuditTrigger": { + "enum": [ + "session_end", + "turn_threshold", + "token_threshold", + "staging_backlog", + "idle", + "manual", + "startup_recovery", + "unknown" + ], + "type": "string" + }, + "AuditWorker": { + "enum": [ + "memory_extract", + "memory_consolidation" + ], + "type": "string" + }, + "AuthBootstrapUserRequest": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + }, + "handle": { + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "AuthPublicConfig": { + "additionalProperties": false, + "description": "Public browser-authentication configuration.", + "properties": { + "cookie_name": { + "type": "string" + }, + "origin": { + "type": "string" + }, + "public_base_url": { + "type": "string" + }, + "rp_id": { + "type": "string" + } + }, + "required": [ + "rp_id", + "origin", + "public_base_url", + "cookie_name" + ], + "type": "object" + }, + "AuthUserResponse": { + "additionalProperties": false, + "properties": { + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "user" + ], + "type": "object" + }, + "AuthenticatedUser": { + "additionalProperties": false, + "description": "Public user identity returned by authentication operations.", + "properties": { + "account_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "user_id": { + "type": "string" + } + }, + "required": [ + "user_id", + "account_id", + "handle", + "display_name" + ], + "type": "object" + }, + "BrowserAppendTicketEventRequest": { + "additionalProperties": false, + "properties": { + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/BrowserTicketThreadRole" + } + }, + "required": [ + "role", + "body" + ], + "type": "object" + }, + "BrowserCloseTicketRequest": { + "additionalProperties": false, + "properties": { + "resolution": { + "type": "string" + } + }, + "required": [ + "resolution" + ], + "type": "object" + }, + "BrowserCreateWorkerResponse": { + "additionalProperties": false, + "properties": { + "console_href": { + "type": "string" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "runtime_id": { + "type": "string" + }, + "worker": { + "$ref": "#/components/schemas/WorkerLaunchWorkerSummary" + }, + "worker_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtime_id", + "worker_id", + "console_href", + "worker", + "diagnostics" + ], + "type": "object" + }, + "BrowserEditTicketRequest": { + "additionalProperties": false, + "properties": { + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "new_string": { + "type": [ + "string", + "null" + ] + }, + "old_string": { + "type": [ + "string", + "null" + ] + }, + "replace_all": { + "default": false, + "type": "boolean" + }, + "target": { + "anyOf": [ + { + "$ref": "#/components/schemas/BrowserTicketTargetEdit" + }, + { + "type": "null" + } + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "BrowserQueueTicketRequest": { + "additionalProperties": false, + "type": "object" + }, + "BrowserTicketTargetEdit": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "action": { + "const": "set", + "type": "string" + }, + "targets": { + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" + } + }, + "required": [ + "action", + "targets" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "const": "clear", + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + } + ] + }, + "BrowserTicketThreadRole": { + "enum": [ + "comment", + "plan", + "decision", + "implementation_report" + ], + "type": "string" + }, + "BrowserTicketWorkflowState": { + "enum": [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed" + ], + "type": "string" + }, + "BrowserTransitionTicketStateRequest": { + "additionalProperties": false, + "properties": { + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "state": { + "$ref": "#/components/schemas/BrowserTicketWorkflowState" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "BrowserWorkerWorkingDirectorySelection": { + "additionalProperties": false, + "properties": { + "alias": { + "description": "Stable Worker-local routing alias.", + "type": "string" + }, + "relative_cwd": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "alias", + "working_directory_id" + ], + "type": "object" + }, + "BrowserWorkspaceOrchestratorResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "disposition": { + "type": "string" + }, + "online": { + "type": "boolean" + }, + "worker": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkerLaunchWorkerSummary" + }, + { + "type": "null" + } + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "online", + "disposition", + "diagnostics" + ], + "type": "object" + }, + "CancelTicketImplementationRequest": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "operation_id", + "assignment_id", + "reason" + ], + "type": "object" + }, + "CandidateKind": { + "description": "Candidate kinds that extract is allowed to stage.", + "enum": [ + "preference", + "working_assumption", + "constraint", + "decision", + "open_question", + "lesson" + ], + "type": "string" + }, + "CleanupTargetKind": { + "enum": [ + "worker_delete", + "workdir_clean_cleanup", + "workdir_dirty_discard", + "workdir_record_delete" + ], + "type": "string" + }, + "CleanupWorkdirCandidate": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/CleanupTargetKind" + }, + "blocking_reason": { + "type": [ + "string", + "null" + ] + }, + "cleanliness": { + "$ref": "#/components/schemas/CleanupWorkdirCleanliness" + }, + "estimated_reclaim_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "file_status": { + "$ref": "#/components/schemas/CleanupWorkdirFileStatus" + }, + "linked_running_worker_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "linked_worker_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pinned_linked": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "running_linked": { + "type": "boolean" + }, + "runtime_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "workdir_id": { + "type": "string" + } + }, + "required": [ + "target_id", + "action", + "workdir_id", + "runtime_id", + "repository_key", + "reason", + "linked_worker_ids", + "linked_running_worker_ids", + "running_linked", + "pinned_linked", + "file_status", + "cleanliness" + ], + "type": "object" + }, + "CleanupWorkdirCleanliness": { + "enum": [ + "clean", + "dirty", + "unknown" + ], + "type": "string" + }, + "CleanupWorkdirFileStatus": { + "enum": [ + "pending", + "present", + "active", + "cleanup_pending", + "not_found", + "corrupted", + "failed", + "unknown" + ], + "type": "string" + }, + "CleanupWorkerCandidate": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/CleanupTargetKind" + }, + "blocking_reason": { + "type": [ + "string", + "null" + ] + }, + "estimated_reclaim_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "linked_workdir_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pinned": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "retention_state": { + "type": "string" + }, + "running_linked": { + "type": "boolean" + }, + "runtime_id": { + "type": "string" + }, + "runtime_worker_id": { + "type": "string" + }, + "target_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "target_id", + "action", + "worker_id", + "runtime_worker_id", + "runtime_id", + "reason", + "pinned", + "retention_state", + "linked_workdir_ids", + "running_linked" + ], + "type": "object" + }, + "ClearTicketRoleAssignmentQuery": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "CompanionCancelRequest": { + "additionalProperties": false, + "description": "Public Workspace Companion cancellation request.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "CompanionLifecycleState": { + "description": "Public lifecycle projection for the Workspace Companion endpoint.", + "enum": [ + "idle", + "running", + "stopped" + ], + "type": "string" + }, + "CompanionMessageDisposition": { + "description": "Public outcome of a Companion message submission.", + "enum": [ + "accepted", + "rejected" + ], + "type": "string" + }, + "CompanionMessageRequest": { + "additionalProperties": false, + "description": "Public Workspace Companion message request.", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CompanionMessageResponse": { + "additionalProperties": false, + "description": "Public Workspace Companion message response.", + "properties": { + "message": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/CompanionMessageDisposition" + } + }, + "required": [ + "state", + "message" + ], + "type": "object" + }, + "CompanionStatusResponse": { + "additionalProperties": false, + "description": "Public Workspace Companion status response.", + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "state": { + "$ref": "#/components/schemas/CompanionLifecycleState" + }, + "transport": { + "$ref": "#/components/schemas/CompanionTransportSummary" + }, + "worker": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceWorkerDiscoveryItem" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "state", + "transport" + ], + "type": "object" + }, + "CompanionTranscriptItem": { + "additionalProperties": false, + "description": "One allowlisted, user-visible Companion transcript item.", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/CompanionTranscriptRole" + }, + "sequence": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "sequence", + "role", + "content", + "created_at" + ], + "type": "object" + }, + "CompanionTranscriptProjection": { + "additionalProperties": false, + "description": "Bounded public Companion transcript projection.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/CompanionTranscriptItem" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "next": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "start": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/CompanionLifecycleState" + }, + "total": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "state", + "start", + "limit", + "total", + "items" + ], + "type": "object" + }, + "CompanionTranscriptQuery": { + "additionalProperties": false, + "properties": { + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "start": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "CompanionTranscriptRole": { + "description": "User-visible role accepted in the public Companion transcript.", + "enum": [ + "user", + "assistant" + ], + "type": "string" + }, + "CompanionTransportSummary": { + "additionalProperties": false, + "description": "Public, bounded transport metadata for Companion status.", + "properties": { + "available": { + "type": "boolean" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "mode", + "available" + ], + "type": "object" + }, + "CompiledFlowDefinition": { + "additionalProperties": false, + "properties": { + "content_digest": { + "type": "string" + }, + "initial": { + "type": "string" + }, + "name": { + "type": "string" + }, + "schema_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "states": { + "additionalProperties": { + "$ref": "#/components/schemas/CompiledFlowState" + }, + "type": "object" + } + }, + "required": [ + "schema_version", + "name", + "initial", + "states", + "content_digest" + ], + "type": "object" + }, + "CompiledFlowState": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "terminal": { + "type": "boolean" + }, + "transitions": { + "items": { + "$ref": "#/components/schemas/CompiledFlowTransition" + }, + "type": "array" + } + }, + "required": [ + "id", + "instructions", + "terminal", + "transitions" + ], + "type": "object" + }, + "CompiledFlowTransition": { + "additionalProperties": false, + "properties": { + "condition": { + "type": "string" + }, + "id": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "target": { + "type": "string" + } + }, + "required": [ + "id", + "target", + "condition", + "synthetic" + ], + "type": "object" + }, + "ConfigCommitRequest": { + "additionalProperties": false, + "description": "Compare-and-swap request for applying a complete set of source-tree changes.", + "properties": { + "base_digest": { + "type": "string" + }, + "base_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "changes": { + "items": { + "$ref": "#/components/schemas/ConfigTreeChange" + }, + "type": "array" + }, + "entrypoints": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "base_revision", + "base_digest", + "changes", + "entrypoints" + ], + "type": "object" + }, + "ConfigContentType": { + "description": "Source content kinds accepted by the Workspace configuration tree.", + "enum": [ + "decodal", + "text" + ], + "type": "string" + }, + "ConfigEntry": { + "additionalProperties": false, + "description": "One source entry in the Workspace configuration tree.", + "properties": { + "content": { + "type": "string" + }, + "content_digest": { + "type": "string" + }, + "content_type": { + "$ref": "#/components/schemas/ConfigContentType" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "content_type", + "content", + "content_digest" + ], + "type": "object" + }, + "ConfigProjectionValidator": { + "oneOf": [ + { + "properties": { + "key_aliases": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "kind": { + "const": "static_template_catalog", + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "kind", + "namespace" + ], + "type": "object" + } + ] + }, + "ConfigSchemaContribution": { + "additionalProperties": false, + "properties": { + "namespace": { + "type": "string" + }, + "projection_validator": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConfigProjectionValidator" + }, + { + "type": "null" + } + ] + }, + "provider_id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "source_digest": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "provider_id", + "namespace", + "version", + "source", + "source_digest" + ], + "type": "object" + }, + "ConfigTreeChange": { + "description": "Base-relative change applied by a Workspace configuration commit.", + "oneOf": [ + { + "properties": { + "content": { + "type": "string" + }, + "content_type": { + "$ref": "#/components/schemas/ConfigContentType" + }, + "kind": { + "const": "create", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "content_type", + "content" + ], + "type": "object" + }, + { + "properties": { + "content": { + "type": "string" + }, + "expected_digest": { + "type": "string" + }, + "kind": { + "const": "update", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "expected_digest", + "content" + ], + "type": "object" + }, + { + "properties": { + "expected_digest": { + "type": "string" + }, + "from": { + "type": "string" + }, + "kind": { + "const": "rename", + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "kind", + "from", + "to", + "expected_digest" + ], + "type": "object" + }, + { + "properties": { + "expected_digest": { + "type": "string" + }, + "kind": { + "const": "delete", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path", + "expected_digest" + ], + "type": "object" + } + ] + }, + "ConfigTreeSnapshot": { + "additionalProperties": false, + "description": "Immutable Workspace configuration tree snapshot.", + "properties": { + "digest": { + "type": "string" + }, + "entries": { + "additionalProperties": { + "$ref": "#/components/schemas/ConfigEntry" + }, + "type": "object" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "revision", + "digest", + "entries" + ], + "type": "object" + }, + "ConfirmRepositorySshHostTrustRequest": { + "additionalProperties": false, + "properties": { + "expected_host_trust_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "host_key": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "runtime_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "runtime_id", + "host_key" + ], + "type": "object" + }, + "ConflictResolution": { + "enum": [ + "none", + "clean", + "conflicts_resolved" + ], + "type": "string" + }, + "ConsolidationAudit": { + "properties": { + "consumed_staging_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "invalid_staging_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "operations": { + "$ref": "#/components/schemas/OperationCounts", + "default": { + "delete": 0, + "drop": 0, + "edit": 0, + "merge": 0, + "trim": 0, + "write": 0 + } + }, + "staging_bytes": { + "default": 0, + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "staging_count": { + "default": 0, + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "CreateRemoteRuntimeRequest": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + }, + "endpoint": { + "type": "string" + }, + "expected_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "public_bundle": { + "$ref": "#/components/schemas/RuntimePublicIdentityBundle" + } + }, + "required": [ + "public_bundle", + "endpoint" + ], + "type": "object" + }, + "CreateRepositorySshCredentialRequest": { + "additionalProperties": false, + "properties": { + "credential_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "passphrase": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "private_key": { + "type": "string" + } + }, + "required": [ + "operation_id", + "credential_id", + "name", + "private_key" + ], + "type": "object" + }, "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.", @@ -44,6 +1514,301 @@ ], "type": "object" }, + "CreateWorkspaceWorkerRequest": { + "additionalProperties": false, + "properties": { + "control_operation_id": { + "default": null, + "description": "Backend idempotency key used only for authenticated Worker-owned spawn/control.", + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": "string" + }, + "initial_submit": { + "default": [], + "items": { + "$ref": "#/components/schemas/Segment" + }, + "type": "array" + }, + "profile": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "runtime_id": { + "type": "string" + }, + "ticket_assignment": { + "anyOf": [ + { + "$ref": "#/components/schemas/CreateWorkspaceWorkerTicketAssignmentRequest" + }, + { + "type": "null" + } + ], + "default": null + }, + "workdir_attachments": { + "default": [], + "items": { + "$ref": "#/components/schemas/BrowserWorkerWorkingDirectorySelection" + }, + "type": "array" + } + }, + "required": [ + "runtime_id", + "display_name" + ], + "type": "object" + }, + "CreateWorkspaceWorkerTicketAssignmentRequest": { + "additionalProperties": false, + "properties": { + "operation_id": { + "type": "string" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "ticket_id", + "operation_id" + ], + "type": "object" + }, + "DefaultIntakeReadyBodyRequest": { + "additionalProperties": false, + "properties": { + "from": { + "type": "string" + } + }, + "required": [ + "from" + ], + "type": "object" + }, + "DeleteRepositorySshCredentialRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "expected_revision" + ], + "type": "object" + }, + "DeleteRepositorySshHostTrustRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "expected_revision" + ], + "type": "object" + }, + "DerivedTicketRelation": { + "properties": { + "at": { + "type": "string" + }, + "author": { + "type": "string" + }, + "forward_kind": { + "$ref": "#/components/schemas/TicketRelationKind" + }, + "inverse_kind": { + "type": "string" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "source_ticket": { + "type": "string" + } + }, + "required": [ + "source_ticket", + "inverse_kind", + "forward_kind", + "author", + "at" + ], + "type": "object" + }, + "DeviceAccessTokenType": { + "enum": [ + "Bearer" + ], + "type": "string" + }, + "DeviceLoginApprovalStatus": { + "enum": [ + "approved" + ], + "type": "string" + }, + "DeviceLoginApproveRequest": { + "additionalProperties": false, + "properties": { + "user_code": { + "type": "string" + } + }, + "required": [ + "user_code" + ], + "type": "object" + }, + "DeviceLoginApproveResponse": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/DeviceLoginApprovalStatus" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "status", + "user" + ], + "type": "object" + }, + "DeviceLoginPollRequest": { + "additionalProperties": false, + "properties": { + "device_code": { + "type": "string" + } + }, + "required": [ + "device_code" + ], + "type": "object" + }, + "DeviceLoginPollResponse": { + "additionalProperties": false, + "properties": { + "access_token": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/DeviceLoginPollStatus" + }, + "token_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeviceAccessTokenType" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "DeviceLoginPollStatus": { + "enum": [ + "pending", + "approved", + "expired", + "denied", + "consumed" + ], + "type": "string" + }, + "DeviceLoginStartRequest": { + "additionalProperties": false, + "properties": { + "client_name": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "DeviceLoginStartResponse": { + "additionalProperties": false, + "properties": { + "device_code": { + "type": "string" + }, + "expires_in": { + "format": "uint64", + "maximum": 86400, + "minimum": 1, + "type": "integer" + }, + "interval": { + "format": "uint64", + "maximum": 60, + "minimum": 1, + "type": "integer" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "verification_uri_complete": { + "type": "string" + } + }, + "required": [ + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval" + ], + "type": "object" + }, "Diagnostic": { "additionalProperties": false, "properties": { @@ -72,6 +1837,506 @@ ], "type": "string" }, + "EffectivePromptCatalog": { + "additionalProperties": false, + "properties": { + "catalog_digest": { + "type": "string" + }, + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "schema_fingerprint": { + "type": "string" + }, + "source_digest": { + "type": "string" + }, + "templates": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "toolchain_fingerprint": { + "type": "string" + } + }, + "required": [ + "templates", + "config_revision", + "schema_fingerprint", + "toolchain_fingerprint", + "catalog_digest" + ], + "type": "object" + }, + "EvidenceKind": { + "description": "Extensible evidence kind tag used by staging source anchors.\n\nKnown values include `message`, `tool_call`, `tool_result`, `file_ref`,\n`ticket_ref`, and `objective_ref`, but callers may use newer bounded tags\nwithout changing the schema.", + "type": "string" + }, + "EvidenceOrigin": { + "additionalProperties": false, + "description": "Bounded origin snapshot attached to extraction evidence. This is audit\nmetadata only and cannot authorize Workspace operations.", + "properties": { + "account_id": { + "type": [ + "string", + "null" + ] + }, + "flow_definition_id": { + "type": [ + "string", + "null" + ] + }, + "flow_definition_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "flow_selector": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/EvidenceOriginKind" + }, + "runtime_id": { + "type": [ + "string", + "null" + ] + }, + "worker_id": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "EvidenceOriginKind": { + "enum": [ + "human_input", + "worker_input", + "flow_instruction", + "backend_instruction", + "model_output", + "tool_output", + "derived_summary", + "legacy_unknown" + ], + "type": "string" + }, + "ExecuteRuntimeCleanupRequest": { + "additionalProperties": false, + "properties": { + "confirm_dirty_discard_target_ids": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "expected_plan_digest": { + "type": "string" + }, + "expected_plan_revision": { + "type": "string" + }, + "workdir_target_ids": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "worker_target_ids": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "expected_plan_revision", + "expected_plan_digest" + ], + "type": "object" + }, + "ExtensibleTicketStatus": { + "oneOf": [ + { + "enum": [ + "Open", + "Closed" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "Other": { + "type": "string" + } + }, + "required": [ + "Other" + ], + "type": "object" + } + ] + }, + "ExternalWorkdirGrantCreateRequest": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": "string" + }, + "provider_instance_id": { + "type": "string" + }, + "read_only": { + "type": "boolean" + }, + "ttl_seconds": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "provider_instance_id", + "display_name", + "ttl_seconds", + "read_only" + ], + "type": "object" + }, + "ExternalWorkdirGrantResponse": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "generation": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "grant_id": { + "type": "string" + }, + "permissions": { + "type": "string" + }, + "provider_instance_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "working_directory_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "grant_id", + "workspace_id", + "working_directory_id", + "provider_instance_id", + "display_name", + "permissions", + "expires_at", + "generation", + "status" + ], + "type": "object" + }, + "ExtractAudit": { + "properties": { + "entry_range": { + "items": { + "$ref": "#/components/schemas/MemoryJsonSafeU64" + }, + "maxItems": 2, + "minItems": 2, + "type": [ + "array", + "null" + ] + }, + "history_range": { + "items": { + "$ref": "#/components/schemas/MemoryJsonSafeU64" + }, + "maxItems": 2, + "minItems": 2, + "type": [ + "array", + "null" + ] + }, + "segment_id": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": [ + "string", + "null" + ] + }, + "staging_count": { + "default": 0, + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "staging_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "staging_paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ExtractedCandidate": { + "description": "Model-submitted candidate before host-side staging metadata is attached.", + "properties": { + "claim": { + "description": "Concise candidate claim.", + "type": "string" + }, + "evidence_ids": { + "description": "Evidence ids selected by the extract worker. The transitional\n`write_extracted` path may leave this empty until `session-explore`\nprovides host-issued evidence ids.", + "items": { + "type": "string" + }, + "type": "array" + }, + "kind": { + "$ref": "#/components/schemas/CandidateKind", + "description": "Kind of candidate. This is intentionally a narrow taxonomy, not an\nactivity-log category." + }, + "staleness": { + "description": "Optional invalidation / staleness hint.", + "type": [ + "string", + "null" + ] + }, + "why_useful": { + "description": "Why this may be useful for future work or consolidation.", + "type": "string" + } + }, + "required": [ + "kind", + "claim", + "why_useful" + ], + "type": "object" + }, + "ExtractedPayload": { + "description": "Transitional extract output: zero or more flat candidates.", + "properties": { + "candidates": { + "default": [], + "items": { + "$ref": "#/components/schemas/ExtractedCandidate" + }, + "type": "array" + } + }, + "type": "object" + }, + "FindingSeverity": { + "enum": [ + "blocker", + "major", + "minor", + "note" + ], + "type": "string" + }, + "FlowSourceKind": { + "enum": [ + "builtin", + "workspace" + ], + "type": "string" + }, + "FlowSourceRecord": { + "additionalProperties": false, + "description": "Current source record for one Workspace-authored Flow.", + "properties": { + "content": { + "type": "string" + }, + "content_digest": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "flow_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source_kind": { + "$ref": "#/components/schemas/FlowSourceKind" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "flow_id", + "source_kind", + "name", + "path", + "content", + "content_digest", + "revision", + "created_at", + "updated_at" + ], + "type": "object" + }, + "FlowSourceResolveRequest": { + "additionalProperties": false, + "properties": { + "selector": { + "type": "string" + } + }, + "required": [ + "selector" + ], + "type": "object" + }, + "GenerateRepositorySshCredentialRequest": { + "additionalProperties": false, + "properties": { + "credential_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "credential_id", + "name" + ], + "type": "object" + }, + "GitCommitSummary": { + "additionalProperties": false, + "properties": { + "author_date": { + "type": "string" + }, + "author_email": { + "type": "string" + }, + "author_name": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "parents": { + "items": { + "type": "string" + }, + "type": "array" + }, + "refs": { + "items": { + "type": "string" + }, + "type": "array" + }, + "short_hash": { + "type": "string" + }, + "summary": { + "type": "string" + } + }, + "required": [ + "hash", + "short_hash", + "summary", + "author_name", + "author_email", + "author_date", + "parents", + "refs" + ], + "type": "object" + }, "GitRemoteSummary": { "additionalProperties": false, "properties": { @@ -123,7 +2388,3197 @@ ], "type": "object" }, + "HealthResponse": { + "additionalProperties": false, + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "HostListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/HostSummary" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "limit", + "items", + "source", + "diagnostics" + ], + "type": "object" + }, + "HostSummary": { + "additionalProperties": false, + "properties": { + "arch": { + "type": "string" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "host_id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "last_seen_at": { + "type": [ + "string", + "null" + ] + }, + "observed_at": { + "type": "string" + }, + "os": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "runtime_id", + "host_id", + "label", + "kind", + "status", + "observed_at", + "os", + "arch", + "diagnostics" + ], + "type": "object" + }, + "HostWorkerListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/WorkerSummary" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "limit", + "items", + "source", + "diagnostics" + ], + "type": "object" + }, + "InitialRepositoryIntent": { + "additionalProperties": false, + "description": "Initial Repository registration intent for Workspace creation.", + "properties": { + "default_ref": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "repository_key", + "uri" + ], + "type": "object" + }, + "InvalidProjectRecord": { + "additionalProperties": false, + "description": "One malformed project record reported by a bounded list projection.", + "properties": { + "label": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "label", + "reason" + ], + "type": "object" + }, + "LogoutResponse": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/LogoutStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "LogoutStatus": { + "enum": [ + "logged_out" + ], + "type": "string" + }, + "MarkdownText": { + "type": "string" + }, + "MemoryAppendAuditOperation": { + "properties": { + "event": { + "$ref": "#/components/schemas/AuditEvent" + } + }, + "required": [ + "event" + ], + "type": "object" + }, + "MemoryBackendAckOutput": { + "properties": { + "summary": { + "type": "string" + } + }, + "required": [ + "summary" + ], + "type": "object" + }, + "MemoryBackendHttpResponse": { + "oneOf": [ + { + "properties": { + "result": { + "$ref": "#/components/schemas/MemoryBackendOperationResult" + }, + "status": { + "const": "ok", + "type": "string" + } + }, + "required": [ + "status", + "result" + ], + "type": "object" + }, + { + "properties": { + "message": { + "type": "string" + }, + "status": { + "const": "error", + "type": "string" + } + }, + "required": [ + "status", + "message" + ], + "type": "object" + } + ] + }, + "MemoryBackendOperation": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemoryQueryOperation", + "properties": { + "operation": { + "const": "query", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryDocumentReadOperation", + "properties": { + "operation": { + "const": "read_document", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryDocumentUpdateOperation", + "properties": { + "operation": { + "const": "update_document", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryResidentSummaryOperation", + "properties": { + "operation": { + "const": "resident_summary", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryAppendAuditOperation", + "properties": { + "operation": { + "const": "append_audit", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryStageCandidateOperation", + "properties": { + "operation": { + "const": "stage_candidate", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryStageExtractedOperation", + "properties": { + "operation": { + "const": "stage_extracted", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryStagingListOperation", + "properties": { + "operation": { + "const": "staging_list", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryStagingReadOperation", + "properties": { + "operation": { + "const": "staging_read", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryStagingCloseOperation", + "properties": { + "operation": { + "const": "staging_close", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + } + ] + }, + "MemoryBackendOperationResult": { + "oneOf": [ + { + "$ref": "#/components/schemas/MemoryToolOutput", + "properties": { + "kind": { + "const": "tool_output", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryBackendAckOutput", + "properties": { + "kind": { + "const": "acknowledged", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "$ref": "#/components/schemas/MemoryStagingWriteOutput", + "properties": { + "kind": { + "const": "staging_written", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "MemoryBackendRequest": { + "$ref": "#/components/schemas/MemoryBackendOperation", + "description": "Public wrapper retaining the established tagged Memory backend wire shape." + }, + "MemoryBackendResponse": { + "$ref": "#/components/schemas/MemoryBackendHttpResponse", + "description": "Public wrapper retaining the established tagged Memory backend response shape." + }, + "MemoryCandidateKind": { + "description": "Candidate kinds exposed by the Memory staging resource.", + "enum": [ + "preference", + "working_assumption", + "constraint", + "decision", + "open_question", + "lesson" + ], + "type": "string" + }, + "MemoryConsolidateStagingRequest": { + "additionalProperties": false, + "properties": { + "force": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "MemoryConsolidationResponse": { + "additionalProperties": false, + "properties": { + "candidate_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "status": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "total_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "summary", + "candidate_count", + "total_bytes" + ], + "type": "object" + }, + "MemoryDocumentReadOperation": { + "properties": { + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "offset": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "MemoryDocumentResponse": { + "additionalProperties": false, + "description": "Public Workspace Memory document projection.", + "properties": { + "body_md": { + "type": "string" + }, + "bytes": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "record_source": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "body_md", + "created_at", + "updated_at", + "bytes", + "record_source" + ], + "type": "object" + }, + "MemoryDocumentUpdateOperation": { + "properties": { + "new_string": { + "type": "string" + }, + "old_string": { + "type": "string" + }, + "replace_all": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "old_string", + "new_string" + ], + "type": "object" + }, + "MemoryEvidenceOrigin": { + "additionalProperties": false, + "description": "Bounded origin metadata copied from one typed Memory evidence anchor.\n\nThis is provenance only. It carries no message body, prompt, reasoning,\nsecret, tool output, or authorization authority.", + "properties": { + "account_id": { + "type": [ + "string", + "null" + ] + }, + "flow_definition_id": { + "type": [ + "string", + "null" + ] + }, + "flow_definition_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "flow_selector": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/MemoryEvidenceOriginKind" + }, + "runtime_id": { + "type": [ + "string", + "null" + ] + }, + "worker_id": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "MemoryEvidenceOriginKind": { + "description": "Typed, bounded provenance classification for public Memory evidence anchors.", + "enum": [ + "human_input", + "worker_input", + "flow_instruction", + "backend_instruction", + "model_output", + "tool_output", + "derived_summary", + "legacy_unknown" + ], + "type": "string" + }, + "MemoryJsonSafeU64": { + "description": "Schema-only representation for JSON integers that must remain exactly\nrepresentable by JavaScript consumers.", + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "MemoryQueryOperation": { + "properties": { + "query": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MemoryResidentSummaryOperation": { + "type": "object" + }, + "MemorySettingsAudit": { + "properties": { + "language": { + "type": "string" + }, + "settings_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "settings_revision", + "language" + ], + "type": "object" + }, + "MemorySourceEvidenceRef": { + "additionalProperties": false, + "description": "Bounded source anchor included in one Memory staging record.", + "properties": { + "entry_range": { + "items": { + "$ref": "#/components/schemas/ServerJsonSafeU64" + }, + "maxItems": 2, + "minItems": 2, + "type": [ + "array", + "null" + ] + }, + "evidence_id": { + "type": [ + "string", + "null" + ] + }, + "evidence_kind": { + "type": [ + "string", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "origin": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemoryEvidenceOrigin" + }, + { + "type": "null" + } + ] + }, + "segment_id": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MemorySourceRef": { + "additionalProperties": false, + "description": "Record-level source range for one Memory staging candidate.", + "properties": { + "range": { + "items": { + "$ref": "#/components/schemas/ServerJsonSafeU64" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "segment_id": { + "type": "string" + } + }, + "required": [ + "segment_id", + "range" + ], + "type": "object" + }, + "MemoryStageCandidateOperation": { + "properties": { + "candidate": { + "$ref": "#/components/schemas/ExtractedCandidate" + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/StagingEvidence" + }, + "type": "array" + }, + "extract_run_id": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/SourceRef" + }, + "source_refs": { + "items": { + "$ref": "#/components/schemas/SourceEvidenceRef" + }, + "type": "array" + } + }, + "required": [ + "source", + "extract_run_id", + "candidate" + ], + "type": "object" + }, + "MemoryStageExtractedOperation": { + "properties": { + "payload": { + "$ref": "#/components/schemas/ExtractedPayload" + }, + "source": { + "$ref": "#/components/schemas/SourceRef" + } + }, + "required": [ + "source", + "payload" + ], + "type": "object" + }, + "MemoryStagingAffectedMemory": { + "properties": { + "operation": { + "$ref": "#/components/schemas/MemoryStagingAffectedMemoryOperation" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "MemoryStagingAffectedMemoryOperation": { + "enum": [ + "edit" + ], + "type": "string" + }, + "MemoryStagingCloseAction": { + "enum": [ + "applied", + "discarded", + "invalid", + "duplicate", + "already_covered" + ], + "type": "string" + }, + "MemoryStagingCloseOperation": { + "properties": { + "action": { + "$ref": "#/components/schemas/MemoryStagingCloseAction" + }, + "affected_memory": { + "items": { + "$ref": "#/components/schemas/MemoryStagingAffectedMemory" + }, + "type": "array" + }, + "candidate_id": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "candidate_id", + "action", + "reason" + ], + "type": "object" + }, + "MemoryStagingEntry": { + "additionalProperties": false, + "description": "Public list entry for one valid Memory staging record.", + "properties": { + "byte_len": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "record": { + "$ref": "#/components/schemas/MemoryStagingRecord" + } + }, + "required": [ + "id", + "byte_len", + "record" + ], + "type": "object" + }, + "MemoryStagingEvidence": { + "additionalProperties": false, + "description": "Bounded evidence snippet included in one Memory staging record.", + "properties": { + "entry_range": { + "items": { + "$ref": "#/components/schemas/ServerJsonSafeU64" + }, + "maxItems": 2, + "minItems": 2, + "type": [ + "array", + "null" + ] + }, + "excerpt": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "origin": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemoryEvidenceOrigin" + }, + { + "type": "null" + } + ] + }, + "summary": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "kind" + ], + "type": "object" + }, + "MemoryStagingListOperation": { + "properties": { + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "MemoryStagingListResponse": { + "additionalProperties": false, + "description": "Public response returned by the Workspace Memory staging list resource.", + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "invalid_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MemoryStagingEntry" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "order": { + "type": "string" + }, + "record_authority": { + "type": "string" + }, + "returned_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "total_valid_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "limit", + "returned_count", + "total_valid_count", + "invalid_count", + "truncated", + "order", + "record_authority", + "items", + "diagnostics" + ], + "type": "object" + }, + "MemoryStagingQuery": { + "additionalProperties": false, + "description": "Query parameters for the bounded Memory staging list.", + "properties": { + "limit": { + "format": "uint32", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "MemoryStagingReadOperation": { + "properties": { + "candidate_id": { + "type": "string" + } + }, + "required": [ + "candidate_id" + ], + "type": "object" + }, + "MemoryStagingRecord": { + "additionalProperties": false, + "description": "Public projection of one valid Memory staging record.", + "properties": { + "claim": { + "type": "string" + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/MemoryStagingEvidence" + }, + "type": "array" + }, + "extract_run_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/MemoryCandidateKind" + }, + "schema_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "source": { + "$ref": "#/components/schemas/MemorySourceRef" + }, + "source_refs": { + "items": { + "$ref": "#/components/schemas/MemorySourceEvidenceRef" + }, + "type": "array" + }, + "staleness": { + "type": [ + "string", + "null" + ] + }, + "why_useful": { + "type": "string" + } + }, + "required": [ + "schema_version", + "id", + "extract_run_id", + "source", + "kind", + "claim", + "why_useful", + "evidence", + "source_refs" + ], + "type": "object" + }, + "MemoryStagingWriteOutput": { + "properties": { + "staging_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "staging_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "staging_count", + "staging_ids" + ], + "type": "object" + }, + "MemoryToolOutput": { + "properties": { + "content": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "type": "string" + } + }, + "required": [ + "summary" + ], + "type": "object" + }, + "MergeRequestDetailResponse": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "linked_tickets": { + "items": { + "$ref": "#/components/schemas/MergeRequestLinkedTicketResponse" + }, + "type": "array" + }, + "merge_request_id": { + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "selector_from": { + "type": [ + "string", + "null" + ] + }, + "selector_to": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/MergeRequestRefResponse" + }, + "state": { + "$ref": "#/components/schemas/MergeRequestState" + }, + "target": { + "$ref": "#/components/schemas/MergeRequestRefResponse" + }, + "thread": { + "items": { + "$ref": "#/components/schemas/MergeRequestThreadEvent" + }, + "type": "array" + }, + "ticket_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "merge_request_id", + "repository_key", + "state", + "selector_to", + "ticket_ids", + "created_at", + "updated_at", + "thread", + "source", + "target", + "linked_tickets" + ], + "type": "object" + }, + "MergeRequestLinkedTicketResponse": { + "additionalProperties": false, + "properties": { + "key": { + "type": [ + "string", + "null" + ] + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "ticket_id" + ], + "type": "object" + }, + "MergeRequestListItem": { + "additionalProperties": false, + "properties": { + "ref_diagnostics": { + "items": { + "$ref": "#/components/schemas/MergeRequestRefDiagnostic" + }, + "type": "array" + }, + "summary": { + "$ref": "#/components/schemas/TicketMergeRequestSummary" + }, + "thread_event_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "ticket_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "summary", + "ticket_ids", + "thread_event_count" + ], + "type": "object" + }, + "MergeRequestListQuery": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "repository_key": { + "type": [ + "string", + "null" + ] + }, + "selector_from": { + "type": [ + "string", + "null" + ] + }, + "selector_to": { + "type": [ + "string", + "null" + ] + }, + "state": { + "type": [ + "string", + "null" + ] + }, + "ticket_ref": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MergeRequestListResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MergeRequestListItem" + }, + "type": "array" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "MergeRequestReadinessResponse": { + "additionalProperties": false, + "properties": { + "blockers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "ready": { + "type": "boolean" + }, + "review": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReviewEvent" + }, + { + "type": "null" + } + ] + }, + "subject_ref": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "ready", + "blockers" + ], + "type": "object" + }, + "MergeRequestRefDiagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "MergeRequestRefResponse": { + "additionalProperties": false, + "properties": { + "diagnostic": { + "anyOf": [ + { + "$ref": "#/components/schemas/MergeRequestRefDiagnostic" + }, + { + "type": "null" + } + ] + }, + "observed_at": { + "type": "string" + }, + "ref": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + } + }, + "required": [ + "status", + "observed_at" + ], + "type": "object" + }, + "MergeRequestState": { + "enum": [ + "open", + "merged", + "closed" + ], + "type": "string" + }, + "MergeRequestThreadEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "kind": { + "const": "review_requested", + "type": "string" + }, + "requested_by": { + "$ref": "#/components/schemas/MergeRequestWorkerIdentity" + }, + "reviewer": { + "$ref": "#/components/schemas/MergeRequestWorkerIdentity" + }, + "sequence": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "subject_ref": { + "type": "string" + } + }, + "required": [ + "kind", + "event_id", + "sequence", + "subject_ref", + "requested_by", + "reviewer", + "created_at" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "decision": { + "$ref": "#/components/schemas/ReviewDecision" + }, + "event_id": { + "type": "string" + }, + "findings": { + "items": { + "$ref": "#/components/schemas/ReviewFinding" + }, + "type": "array" + }, + "kind": { + "const": "review", + "type": "string" + }, + "request_event_id": { + "type": "string" + }, + "reviewer": { + "$ref": "#/components/schemas/MergeRequestWorkerIdentity" + }, + "sequence": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "subject_ref": { + "type": "string" + } + }, + "required": [ + "kind", + "event_id", + "sequence", + "request_event_id", + "subject_ref", + "decision", + "body", + "findings", + "reviewer", + "created_at" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "kind": { + "const": "review_revoked", + "type": "string" + }, + "reason": { + "type": "string" + }, + "review_event_id": { + "type": "string" + }, + "revoked_by": { + "$ref": "#/components/schemas/MergeRequestWorkerIdentity" + }, + "sequence": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "subject_ref": { + "type": "string" + } + }, + "required": [ + "kind", + "event_id", + "sequence", + "review_event_id", + "subject_ref", + "reason", + "revoked_by", + "created_at" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "kind": { + "const": "review_cancelled", + "type": "string" + }, + "reason": { + "type": "string" + }, + "request_event_id": { + "type": "string" + }, + "sequence": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "subject_ref": { + "type": "string" + } + }, + "required": [ + "kind", + "event_id", + "sequence", + "request_event_id", + "subject_ref", + "reason", + "created_at" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "author": { + "$ref": "#/components/schemas/MergeRequestWorkerIdentity" + }, + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "kind": { + "const": "comment", + "type": "string" + }, + "sequence": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "event_id", + "sequence", + "body", + "author", + "created_at" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "approval_event_id": { + "type": "string" + }, + "approved_source_ref": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "kind": { + "const": "merge", + "type": "string" + }, + "merged_by": { + "$ref": "#/components/schemas/MergeRequestWorkerIdentity" + }, + "operation_id": { + "type": "string" + }, + "resolution": { + "$ref": "#/components/schemas/ConflictResolution" + }, + "sequence": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "strategy": { + "$ref": "#/components/schemas/MergeStrategy" + }, + "target_ref_after": { + "type": "string" + }, + "target_ref_before": { + "type": "string" + } + }, + "required": [ + "kind", + "event_id", + "sequence", + "operation_id", + "approval_event_id", + "approved_source_ref", + "target_ref_before", + "target_ref_after", + "strategy", + "resolution", + "merged_by", + "created_at" + ], + "type": "object" + } + ] + }, + "MergeRequestThreadQuery": { + "additionalProperties": false, + "properties": { + "after": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "MergeRequestWorkerIdentity": { + "additionalProperties": false, + "properties": { + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "runtime_id", + "worker_id" + ], + "type": "object" + }, + "MergeStrategy": { + "enum": [ + "fast_forward", + "merge" + ], + "type": "string" + }, + "ModelAudit": { + "properties": { + "model_id": { + "type": [ + "string", + "null" + ] + }, + "ref_": { + "type": [ + "string", + "null" + ] + }, + "scheme": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "NewOrchestrationPlanRecord": { + "properties": { + "accepted_plan": { + "anyOf": [ + { + "$ref": "#/components/schemas/AcceptedOrchestrationPlan" + }, + { + "type": "null" + } + ] + }, + "author": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/OrchestrationPlanKind" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "related_ticket": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "NewTicket": { + "properties": { + "assignee": { + "type": [ + "string", + "null" + ] + }, + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "$ref": "#/components/schemas/MarkdownText" + }, + "kind": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "priority": { + "type": "string" + }, + "queued_at": { + "type": [ + "string", + "null" + ] + }, + "queued_by": { + "type": [ + "string", + "null" + ] + }, + "readiness": { + "type": [ + "string", + "null" + ] + }, + "risk_flags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "slug": { + "type": [ + "string", + "null" + ] + }, + "targets": { + "default": [], + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "workflow_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketWorkflowState" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "title", + "kind", + "priority", + "labels", + "body", + "risk_flags" + ], + "type": "object" + }, + "NewTicketEvent": { + "properties": { + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "$ref": "#/components/schemas/MarkdownText" + }, + "kind": { + "$ref": "#/components/schemas/TicketEventKind" + }, + "references": { + "items": { + "$ref": "#/components/schemas/TicketReference" + }, + "type": "array" + } + }, + "required": [ + "kind", + "body", + "references" + ], + "type": "object" + }, + "NewTicketRelation": { + "properties": { + "author": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/TicketRelationKind" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": "string" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + }, + "Nullable_string": { + "type": [ + "string", + "null" + ] + }, + "ObjectiveCreateRequest": { + "additionalProperties": false, + "properties": { + "body_md": { + "default": "", + "type": "string" + }, + "linked_tickets": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "state": { + "default": "active", + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + }, + "ObjectiveDetail": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "body_truncated": { + "type": "boolean" + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "event_page": { + "$ref": "#/components/schemas/QueryPage" + }, + "events": { + "items": { + "$ref": "#/components/schemas/ObjectiveEventDetail" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "linked_ticket_summaries": { + "items": { + "$ref": "#/components/schemas/ObjectiveLinkedTicketSummary" + }, + "type": "array" + }, + "linked_tickets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "record_source": { + "type": "string" + }, + "resource_key": { + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/components/schemas/ObjectiveResourceSummary" + }, + "type": "array" + }, + "revision": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "resource_key", + "title", + "state", + "revision", + "linked_tickets", + "linked_ticket_summaries", + "resources", + "body", + "body_truncated", + "events", + "event_page", + "record_source" + ], + "type": "object" + }, + "ObjectiveEditRequest": { + "additionalProperties": false, + "properties": { + "new_string": { + "type": [ + "string", + "null" + ] + }, + "old_string": { + "type": [ + "string", + "null" + ] + }, + "replace_all": { + "default": false, + "type": "boolean" + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ObjectiveEventDetail": { + "additionalProperties": false, + "properties": { + "body": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "event_ref": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "event_ref", + "kind", + "created_at" + ], + "type": "object" + }, + "ObjectiveLinkSummary": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "resource_key": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "resource_key", + "title", + "state" + ], + "type": "object" + }, + "ObjectiveLinkTicketRequest": { + "additionalProperties": false, + "properties": { + "ticket_id": { + "type": "string" + } + }, + "required": [ + "ticket_id" + ], + "type": "object" + }, + "ObjectiveLinkedTicketSummary": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "resource_key": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "resource_key", + "title", + "state" + ], + "type": "object" + }, + "ObjectiveListQuery": { + "additionalProperties": false, + "properties": { + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "ObjectiveListResponse": { + "additionalProperties": false, + "properties": { + "invalid_records": { + "items": { + "$ref": "#/components/schemas/InvalidProjectRecord" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/ObjectiveSummary" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "record_authority": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "limit", + "items", + "invalid_records", + "record_authority" + ], + "type": "object" + }, + "ObjectiveQueryItem": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "linked_ticket_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "linked_ticket_keys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "linked_tickets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "matched_fields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "resource_key": { + "type": "string" + }, + "snippet": { + "type": [ + "string", + "null" + ] + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "resource_key", + "title", + "state", + "matched_fields", + "linked_ticket_count", + "linked_tickets", + "linked_ticket_keys" + ], + "type": "object" + }, + "ObjectiveQueryRequest": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "linked_ticket_id": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "sort": { + "type": [ + "string", + "null" + ] + }, + "states": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_after": { + "type": [ + "string", + "null" + ] + }, + "updated_before": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ObjectiveQueryResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ObjectiveQueryItem" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/QueryPage" + }, + "record_authority": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "record_authority" + ], + "type": "object" + }, + "ObjectiveResourceSummary": { + "additionalProperties": false, + "properties": { + "bytes": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "media_type": { + "type": [ + "string", + "null" + ] + }, + "path": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "path", + "bytes", + "updated_at" + ], + "type": "object" + }, + "ObjectiveShowRequest": { + "additionalProperties": false, + "properties": { + "event_cursor": { + "type": [ + "string", + "null" + ] + }, + "event_limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "ObjectiveStateRequest": { + "additionalProperties": false, + "properties": { + "state": { + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "ObjectiveSummary": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "linked_tickets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "record_source": { + "type": "string" + }, + "resource_key": { + "type": "string" + }, + "state": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "resource_key", + "title", + "state", + "summary", + "linked_tickets", + "record_source" + ], + "type": "object" + }, + "OperationCounts": { + "properties": { + "delete": { + "default": 0, + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "drop": { + "default": 0, + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "edit": { + "default": 0, + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "merge": { + "default": 0, + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "trim": { + "default": 0, + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "write": { + "default": 0, + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "OrchestrationPlanKind": { + "enum": [ + "before", + "after", + "blocked_by", + "blocks", + "conflicts_with", + "do_not_parallelize", + "waiting_capacity_note", + "accepted_plan" + ], + "type": "string" + }, + "OrchestrationPlanRecord": { + "additionalProperties": false, + "properties": { + "accepted_plan": { + "anyOf": [ + { + "$ref": "#/components/schemas/AcceptedOrchestrationPlan" + }, + { + "type": "null" + } + ] + }, + "at": { + "type": "string" + }, + "author": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/OrchestrationPlanKind" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "related_ticket": { + "type": [ + "string", + "null" + ] + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "id", + "ticket_id", + "kind", + "author", + "at" + ], + "type": "object" + }, + "PasskeyLoginCompleteRequest": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "credential": true + }, + "required": [ + "challenge_id", + "credential" + ], + "type": "object" + }, + "PasskeyLoginOptionsRequest": { + "additionalProperties": false, + "properties": { + "browser_origin": { + "type": [ + "string", + "null" + ] + }, + "handle": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "PasskeyLoginOptionsResponse": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "public_key": true + }, + "required": [ + "challenge_id", + "public_key" + ], + "type": "object" + }, + "PasskeyRegistrationCompleteRequest": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "credential": true + }, + "required": [ + "challenge_id", + "credential" + ], + "type": "object" + }, + "PasskeyRegistrationOptionsRequest": { + "additionalProperties": false, + "properties": { + "browser_origin": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "handle": { + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "PasskeyRegistrationOptionsResponse": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "public_key": true + }, + "required": [ + "challenge_id", + "public_key" + ], + "type": "object" + }, + "PasteArtifactAvailability": { + "enum": [ + "available", + "unavailable", + "integrity_failed" + ], + "type": "string" + }, + "PasteArtifactMediaType": { + "description": "One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.", + "enum": [ + "text_plain_utf8" + ], + "type": "string" + }, + "PasteArtifactRef": { + "description": "Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.", + "properties": { + "artifact_id": { + "type": "string" + }, + "availability": { + "$ref": "#/components/schemas/PasteArtifactAvailability", + "description": "Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field." + }, + "byte_len": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "char_count": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "created_at_ms": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "line_count": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "media_type": { + "$ref": "#/components/schemas/PasteArtifactMediaType" + }, + "sha256": { + "type": "string" + }, + "source_entry_id": { + "type": "string" + } + }, + "required": [ + "artifact_id", + "created_at_ms", + "media_type", + "availability", + "byte_len", + "char_count", + "line_count", + "sha256", + "source_entry_id" + ], + "type": "object" + }, + "ProfileSettingsResponse": { + "additionalProperties": false, + "description": "Read-only Profile catalog projected from one active Workspace config revision.", + "properties": { + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "default_profile": { + "type": [ + "string", + "null" + ] + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/WorkspaceProfileSummary" + }, + "type": "array" + }, + "projection_digest": { + "type": [ + "string", + "null" + ] + }, + "registry_revision": { + "type": "string" + }, + "sources": { + "items": { + "$ref": "#/components/schemas/WorkspaceProfileSourceSummary" + }, + "type": "array" + }, + "tree_digest": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "registry_revision", + "profiles", + "sources", + "diagnostics" + ], + "type": "object" + }, + "PublicMergeRequest": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "merge_request_id": { + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "selector_from": { + "type": [ + "string", + "null" + ] + }, + "selector_to": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/MergeRequestState" + }, + "thread": { + "items": { + "$ref": "#/components/schemas/MergeRequestThreadEvent" + }, + "type": "array" + }, + "ticket_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "merge_request_id", + "repository_key", + "state", + "selector_to", + "ticket_ids", + "created_at", + "updated_at", + "thread" + ], + "type": "object" + }, + "PutFlowRequest": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "content" + ], + "type": "object" + }, + "PutRepositorySshHostTrustRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "default": null, + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "host_key": { + "type": "string" + }, + "host_trust_id": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "operation_id", + "host_trust_id", + "hostname", + "port", + "host_key" + ], + "type": "object" + }, + "QueryPage": { + "additionalProperties": false, + "properties": { + "has_more": { + "type": "boolean" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "returned": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "sort": { + "type": "string" + }, + "source_limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "source_truncated": { + "type": "boolean" + } + }, + "required": [ + "limit", + "returned", + "has_more", + "sort", + "source_truncated" + ], + "type": "object" + }, + "RecordOperationAudit": { + "properties": { + "after_hash": { + "type": [ + "string", + "null" + ] + }, + "before_hash": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string" + }, + "op": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AuditStatus" + } + }, + "required": [ + "op", + "status", + "kind", + "slug", + "path" + ], + "type": "object" + }, + "RecordUsageAudit": { + "properties": { + "kind": { + "type": "string" + }, + "op": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "result_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "slug": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/AuditStatus" + } + }, + "required": [ + "op", + "status", + "kind" + ], + "type": "object" + }, + "RemoveRuntimeRequest": { + "additionalProperties": false, + "properties": { + "expected_binding_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "expected_binding_revision" + ], + "type": "object" + }, + "RepairMergeRequestSelectorRequest": { + "additionalProperties": false, + "properties": { + "explicit_confirmation": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "selector_from": { + "type": "string" + } + }, + "required": [ + "selector_from", + "reason", + "explicit_confirmation" + ], + "type": "object" + }, + "RepositoryAccessMode": { + "enum": [ + "read_only", + "read_write" + ], + "type": "string" + }, + "RepositoryAccessProjection": { + "additionalProperties": false, + "description": "Secret-free active Repository access projection consumed by later Runtime work.", + "properties": { + "bindings": { + "items": { + "$ref": "#/components/schemas/RepositorySshAccessBinding" + }, + "type": "array" + }, + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "projection_digest": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "config_revision", + "projection_digest", + "bindings" + ], + "type": "object" + }, "RepositoryApiError": { + "additionalProperties": false, "description": "Error body shared by the existing repository routes and their generated adapters.", "properties": { "diagnostics": { @@ -216,6 +5671,65 @@ ], "type": "object" }, + "RepositoryLogQuery": { + "additionalProperties": false, + "properties": { + "limit": { + "default": null, + "format": "uint", + "maximum": 200, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "RepositoryLogResponse": { + "additionalProperties": false, + "properties": { + "default_selector": { + "type": [ + "string", + "null" + ] + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/GitCommitSummary" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "repository_key": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "repository_key", + "limit", + "items", + "diagnostics" + ], + "type": "object" + }, "RepositoryObservedStatus": { "enum": [ "unverified", @@ -260,6 +5774,337 @@ } ] }, + "RepositorySshAccessBinding": { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/RepositoryAccessMode" + }, + "credential_id": { + "type": "string" + }, + "host_trust_id": { + "type": "string" + }, + "repository_key": { + "type": "string" + } + }, + "required": [ + "repository_key", + "credential_id", + "host_trust_id", + "access" + ], + "type": "object" + }, + "RepositorySshConnectionProbeRequest": { + "additionalProperties": false, + "properties": { + "runtime_id": { + "type": "string" + } + }, + "required": [ + "runtime_id" + ], + "type": "object" + }, + "RepositorySshConnectionProbeResponse": { + "additionalProperties": false, + "properties": { + "candidates": { + "items": { + "$ref": "#/components/schemas/RepositorySshHostKeyCandidate" + }, + "type": "array" + }, + "expected_host_trust_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "host_trust_id": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "repository_key": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "trust_state": { + "$ref": "#/components/schemas/RepositorySshConnectionTrustState" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "repository_key", + "runtime_id", + "hostname", + "port", + "trust_state", + "host_trust_id", + "candidates" + ], + "type": "object" + }, + "RepositorySshConnectionTrustState": { + "enum": [ + "untrusted", + "verified", + "changed" + ], + "type": "string" + }, + "RepositorySshCredential": { + "additionalProperties": false, + "description": "Public metadata for one Workspace-scoped Repository SSH credential.\n\nSecret references and secret material are deliberately not part of this DTO.", + "properties": { + "created_at": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "name": { + "type": "string" + }, + "public_key_algorithm": { + "type": "string" + }, + "public_key_fingerprint": { + "type": "string" + }, + "referenced_repositories": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "rotated_at": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "credential_id", + "workspace_id", + "name", + "public_key_algorithm", + "public_key_fingerprint", + "current_revision", + "status", + "created_at" + ], + "type": "object" + }, + "RepositorySshHostKeyCandidate": { + "additionalProperties": false, + "properties": { + "algorithm": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "host_key": { + "type": "string" + } + }, + "required": [ + "algorithm", + "host_key", + "fingerprint" + ], + "type": "object" + }, + "RepositorySshHostTrust": { + "additionalProperties": false, + "description": "Public metadata for an explicitly pinned SSH host key.", + "properties": { + "created_at": { + "type": "string" + }, + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "fingerprint": { + "type": "string" + }, + "host_key": { + "type": "string" + }, + "host_trust_id": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "key_algorithm": { + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "referenced_repositories": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "host_trust_id", + "workspace_id", + "hostname", + "port", + "key_algorithm", + "host_key", + "fingerprint", + "current_revision", + "created_at", + "updated_at" + ], + "type": "object" + }, + "RepositorySshHostTrustMutationResponse": { + "additionalProperties": false, + "description": "Public metadata for an explicitly pinned SSH host key.", + "properties": { + "created_at": { + "type": "string" + }, + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "fingerprint": { + "type": "string" + }, + "host_key": { + "type": "string" + }, + "host_trust_id": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "key_algorithm": { + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "referenced_repositories": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "host_trust_id", + "workspace_id", + "hostname", + "port", + "key_algorithm", + "host_key", + "fingerprint", + "current_revision", + "created_at", + "updated_at" + ], + "type": "object" + }, + "RepositorySshPublicKey": { + "additionalProperties": false, + "properties": { + "credential_id": { + "type": "string" + }, + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "public_key": { + "type": "string" + }, + "public_key_algorithm": { + "type": "string" + }, + "public_key_fingerprint": { + "type": "string" + } + }, + "required": [ + "credential_id", + "current_revision", + "public_key_algorithm", + "public_key_fingerprint", + "public_key" + ], + "type": "object" + }, "RepositorySummary": { "additionalProperties": false, "properties": { @@ -334,6 +6179,7709 @@ ], "type": "object" }, + "RequestActor": { + "additionalProperties": false, + "description": "Authenticated actor returned by `GET /api/auth/whoami`.", + "properties": { + "account_id": { + "type": "string" + }, + "auth_method": { + "$ref": "#/components/schemas/ActorAuthMethod" + }, + "display_name": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "user_id": { + "type": "string" + } + }, + "required": [ + "user_id", + "account_id", + "handle", + "display_name", + "auth_method" + ], + "type": "object" + }, + "ResolvedFlowSource": { + "additionalProperties": false, + "properties": { + "content_digest": { + "type": "string" + }, + "definition": { + "$ref": "#/components/schemas/CompiledFlowDefinition" + }, + "flow_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "selector": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "selector", + "workspace_id", + "flow_id", + "revision", + "content_digest", + "definition" + ], + "type": "object" + }, + "RestoreTicketAssignmentQuery": { + "additionalProperties": false, + "properties": { + "assignment_operation_id": { + "type": [ + "string", + "null" + ] + }, + "ticket_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ReviewDecision": { + "enum": [ + "approve", + "request_changes" + ], + "type": "string" + }, + "ReviewEvent": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "decision": { + "$ref": "#/components/schemas/ReviewDecision" + }, + "event_id": { + "type": "string" + }, + "findings": { + "items": { + "$ref": "#/components/schemas/ReviewFinding" + }, + "type": "array" + }, + "request_event_id": { + "type": "string" + }, + "reviewer": { + "$ref": "#/components/schemas/MergeRequestWorkerIdentity" + }, + "sequence": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "subject_ref": { + "type": "string" + } + }, + "required": [ + "event_id", + "sequence", + "request_event_id", + "subject_ref", + "decision", + "body", + "findings", + "reviewer", + "created_at" + ], + "type": "object" + }, + "ReviewFinding": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "code": { + "type": [ + "string", + "null" + ] + }, + "line": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "severity": { + "$ref": "#/components/schemas/FindingSeverity" + } + }, + "required": [ + "severity", + "body" + ], + "type": "object" + }, + "RevokeRuntimeTrustKeyRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "expected_revision" + ], + "type": "object" + }, + "RotateRepositorySshCredentialRequest": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "operation_id": { + "type": "string" + }, + "passphrase": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "private_key": { + "type": "string" + } + }, + "required": [ + "operation_id", + "expected_revision", + "private_key" + ], + "type": "object" + }, + "RuntimeCleanupExecutionResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "executed_at": { + "type": "string" + }, + "plan_after": { + "$ref": "#/components/schemas/RuntimeCleanupPlanResponse" + }, + "results": { + "items": { + "$ref": "#/components/schemas/RuntimeCleanupExecutionResult" + }, + "type": "array" + }, + "runtime_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtime_id", + "executed_at", + "results", + "plan_after", + "diagnostics" + ], + "type": "object" + }, + "RuntimeCleanupExecutionResult": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/CleanupTargetKind" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + }, + "target_id": { + "type": "string" + } + }, + "required": [ + "target_id", + "action", + "status", + "message" + ], + "type": "object" + }, + "RuntimeCleanupPlanResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "digest": { + "type": "string" + }, + "generated_at": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "workdirs": { + "items": { + "$ref": "#/components/schemas/CleanupWorkdirCandidate" + }, + "type": "array" + }, + "workers": { + "items": { + "$ref": "#/components/schemas/CleanupWorkerCandidate" + }, + "type": "array" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtime_id", + "generated_at", + "revision", + "digest", + "workers", + "workdirs", + "diagnostics" + ], + "type": "object" + }, + "RuntimeConfigBundleAvailabilityQuery": { + "additionalProperties": false, + "properties": { + "digest": { + "type": "string" + } + }, + "required": [ + "digest" + ], + "type": "object" + }, + "RuntimeConfigBundleResult": { + "additionalProperties": false, + "properties": { + "availability": true, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "state": { + "$ref": "#/components/schemas/WorkerOperationState" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "RuntimeConfigBundleSyncRequest": { + "additionalProperties": false, + "properties": { + "bundle": true + }, + "required": [ + "bundle" + ], + "type": "object" + }, + "RuntimeConnectionDisplayState": { + "enum": [ + "configured", + "verified", + "unavailable", + "revoked" + ], + "type": "string" + }, + "RuntimeConnectionTestFailureKind": { + "enum": [ + "authentication", + "authorization", + "network_unreachable", + "timeout", + "tls_or_transport", + "malformed_response", + "protocol_version_mismatch", + "runtime_identity_mismatch", + "configuration" + ], + "type": "string" + }, + "RuntimeConnectionTestResponse": { + "additionalProperties": false, + "properties": { + "actual_protocol_version": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "binding_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "checked_at": { + "type": "string" + }, + "connection_state": { + "$ref": "#/components/schemas/RuntimeConnectionDisplayState" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "expected_protocol_version": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "failure_kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeConnectionTestFailureKind" + }, + { + "type": "null" + } + ] + }, + "runtime_id": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/RuntimeConnectionTestStatus" + }, + "verification": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeVerificationEvidenceSummary" + }, + { + "type": "null" + } + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtime_id", + "binding_revision", + "connection_state", + "checked_at", + "status", + "expected_protocol_version" + ], + "type": "object" + }, + "RuntimeConnectionTestStatus": { + "enum": [ + "compatible", + "failed" + ], + "type": "string" + }, + "RuntimeIdentityAuthority": { + "enum": [ + "runtime_registry_projection", + "server_runtime_configuration" + ], + "type": "string" + }, + "RuntimeListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/RuntimeSummary" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "limit", + "items", + "source" + ], + "type": "object" + }, + "RuntimeManagementApiError": { + "additionalProperties": false, + "properties": { + "current_fingerprint": { + "type": [ + "string", + "null" + ] + }, + "current_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "type": "object" + }, + "RuntimeManagementSummary": { + "additionalProperties": false, + "properties": { + "binding": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimeBindingSummary" + }, + { + "type": "null" + } + ] + }, + "built_in": { + "type": "boolean" + }, + "config_managed": { + "type": "boolean" + }, + "endpoint_configured": { + "type": "boolean" + }, + "removable": { + "type": "boolean" + }, + "token_ref_configured": { + "type": "boolean" + } + }, + "required": [ + "built_in", + "config_managed", + "removable", + "endpoint_configured", + "token_ref_configured" + ], + "type": "object" + }, + "RuntimeProfileSelector": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "builtin", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "named", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "RuntimePublicIdentityBundle": { + "additionalProperties": false, + "properties": { + "identity_id": { + "type": "string" + }, + "public_key": { + "type": "string" + } + }, + "required": [ + "identity_id", + "public_key" + ], + "type": "object" + }, + "RuntimeRemovalOperationResponse": { + "additionalProperties": false, + "properties": { + "binding_removed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "failure_category": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "runtime_registration_removed": { + "type": [ + "boolean", + "null" + ] + }, + "state": { + "$ref": "#/components/schemas/RuntimeRemovalOperationState" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "workspace_id", + "runtime_id", + "state", + "binding_removed", + "created_at", + "updated_at" + ], + "type": "object" + }, + "RuntimeRemovalOperationState": { + "enum": [ + "pending", + "cleanup_pending", + "succeeded", + "failed" + ], + "type": "string" + }, + "RuntimeSourceKind": { + "enum": [ + "embedded_worker_runtime", + "remote_http" + ], + "type": "string" + }, + "RuntimeSourceStatus": { + "enum": [ + "active", + "reserved" + ], + "type": "string" + }, + "RuntimeSourceSummary": { + "additionalProperties": false, + "properties": { + "identity_authority": { + "$ref": "#/components/schemas/RuntimeIdentityAuthority" + }, + "kind": { + "$ref": "#/components/schemas/RuntimeSourceKind" + }, + "note": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/RuntimeSourceStatus" + } + }, + "required": [ + "kind", + "status", + "identity_authority", + "note" + ], + "type": "object" + }, + "RuntimeSummary": { + "properties": { + "arch": { + "type": "string" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "host_ids": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "os": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/RuntimeSourceSummary" + }, + "status": { + "type": "string" + }, + "worker_creation_available": { + "type": "boolean" + } + }, + "required": [ + "runtime_id", + "label", + "kind", + "status", + "source", + "worker_creation_available", + "os", + "arch" + ], + "type": "object" + }, + "RuntimeTicketWorkerRole": { + "enum": [ + "intake", + "orchestrator", + "coder", + "reviewer" + ], + "type": "string" + }, + "RuntimeTrustAuditAction": { + "enum": [ + "created", + "replaced", + "reactivated", + "revoked" + ], + "type": "string" + }, + "RuntimeTrustAuditEntry": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/RuntimeTrustAuditAction" + }, + "actor_account_id": { + "type": "string" + }, + "at": { + "type": "string" + }, + "new_fingerprint": { + "type": [ + "string", + "null" + ] + }, + "old_fingerprint": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "action", + "actor_account_id", + "revision", + "at" + ], + "type": "object" + }, + "RuntimeTrustKeyRevealResponse": { + "additionalProperties": false, + "properties": { + "public_key": { + "type": "string" + } + }, + "required": [ + "public_key" + ], + "type": "object" + }, + "RuntimeTrustKeyState": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": [ + "string", + "null" + ] + }, + "fingerprint": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "revoked_at": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/RuntimeTrustKeyStatus" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "RuntimeTrustKeyStatus": { + "enum": [ + "unconfigured", + "active", + "revoked" + ], + "type": "string" + }, + "RuntimeVerificationEvidenceSummary": { + "additionalProperties": false, + "properties": { + "binding_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "last_checked_at": { + "type": "string" + }, + "last_outcome": { + "$ref": "#/components/schemas/RuntimeVerificationOutcome" + }, + "runtime_identity_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "runtime_public_key_fingerprint": { + "type": "string" + }, + "verified_at": { + "type": [ + "string", + "null" + ] + }, + "workspace_identity_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workspace_key_id": { + "type": "string" + }, + "workspace_trust_generation": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "last_checked_at", + "last_outcome", + "binding_revision", + "workspace_key_id", + "workspace_identity_revision", + "workspace_trust_generation", + "runtime_public_key_fingerprint", + "runtime_identity_revision" + ], + "type": "object" + }, + "RuntimeVerificationOutcome": { + "enum": [ + "verified", + "challenge_issued", + "verification_failed", + "connectivity_failed" + ], + "type": "string" + }, + "RuntimeWorkerCompletionsRequest": { + "additionalProperties": false, + "properties": { + "kind": true, + "prefix": { + "default": "", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "RuntimeWorkerCompletionsResult": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "entries": { + "items": true, + "type": "array" + }, + "kind": true, + "prefix": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "runtime_id", + "worker_id", + "kind", + "prefix", + "entries" + ], + "type": "object" + }, + "RuntimeWorkerInputRequest": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "kind": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "segments": { + "items": true, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "RuntimeWorkerInputResult": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "runtime_id": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/WorkerOperationState" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "state", + "runtime_id", + "worker_id" + ], + "type": "object" + }, + "RuntimeWorkerLifecycleRequest": { + "additionalProperties": false, + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "ticket_assignment": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeWorkerTicketAssignmentRequest" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RuntimeWorkerLifecycleResult": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "runtime_id": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/WorkerOperationState" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "state", + "runtime_id", + "worker_id" + ], + "type": "object" + }, + "RuntimeWorkerListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/WorkerSummary" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "limit", + "items", + "source" + ], + "type": "object" + }, + "RuntimeWorkerShowResponse": { + "additionalProperties": false, + "description": "Workspace-authoritative Worker projection.\n\n`resource_key` is required here even though Runtime-internal Worker summaries\ndo not carry one. The Workspace Server must resolve it from Workspace\nauthority before constructing this response.", + "properties": { + "capabilities": { + "$ref": "#/components/schemas/WorkerCapabilitySummary" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "display_name": { + "default": "", + "type": "string" + }, + "host_id": { + "type": "string" + }, + "implementation": { + "$ref": "#/components/schemas/WorkerImplementationSummary" + }, + "label": { + "type": "string" + }, + "last_seen_at": { + "type": [ + "string", + "null" + ] + }, + "pinned": { + "default": false, + "type": "boolean" + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "resource_key": { + "type": "string" + }, + "retention_state": { + "default": "", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "singleton_key": { + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "Runtime catalog lifecycle compatibility state. Live foreground state, when\navailable, is carried separately in `worker_state`.", + "type": "string" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_at": { + "type": "string" + }, + "workdir_attachments": { + "items": { + "$ref": "#/components/schemas/WorkerWorkdirAttachmentSummary" + }, + "type": "array" + }, + "worker_id": { + "type": "string" + }, + "worker_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkerStateSnapshot" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "$ref": "#/components/schemas/WorkerWorkspaceSummary" + } + }, + "required": [ + "runtime_id", + "worker_id", + "resource_key", + "host_id", + "label", + "workspace", + "state", + "implementation", + "capabilities", + "updated_at" + ], + "type": "object" + }, + "RuntimeWorkerSpawnAcceptanceEvidence": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "kind", + "detail" + ], + "type": "object" + }, + "RuntimeWorkerSpawnAcceptanceRequirement": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "socket_ready", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expected_segments": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "run_accepted", + "type": "string" + } + }, + "required": [ + "kind", + "expected_segments" + ], + "type": "object" + } + ] + }, + "RuntimeWorkerSpawnIntent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace_companion", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace_orchestrator", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace_coding", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "ticket_role", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/RuntimeTicketWorkerRole" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "kind", + "ticket_id", + "role" + ], + "type": "object" + } + ] + }, + "RuntimeWorkerSpawnRequest": { + "additionalProperties": false, + "properties": { + "acceptance": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnAcceptanceRequirement" + }, + "initial_submit": { + "items": true, + "type": "array" + }, + "intent": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnIntent" + }, + "profile": { + "$ref": "#/components/schemas/RuntimeProfileSelector" + }, + "requested_worker_name": { + "type": [ + "string", + "null" + ] + }, + "ticket_assignment": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeWorkerTicketAssignmentRequest" + }, + { + "type": "null" + } + ] + }, + "workdir_attachment_requests": { + "items": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnWorkingDirectoryAttachmentRequest" + }, + "type": "array" + } + }, + "required": [ + "intent", + "acceptance", + "profile" + ], + "type": "object" + }, + "RuntimeWorkerSpawnResponse": { + "additionalProperties": false, + "properties": { + "acceptance_evidence": { + "default": [], + "items": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnAcceptanceEvidence" + }, + "type": "array" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "state": { + "$ref": "#/components/schemas/WorkerOperationState" + }, + "worker": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeWorkerSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "RuntimeWorkerSpawnWorkingDirectoryAttachmentRequest": { + "additionalProperties": false, + "properties": { + "alias": { + "type": "string" + }, + "working_directory": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnWorkingDirectoryRequest" + } + }, + "required": [ + "alias", + "working_directory" + ], + "type": "object" + }, + "RuntimeWorkerSpawnWorkingDirectoryRequest": { + "additionalProperties": false, + "properties": { + "repository_key": { + "type": "string" + }, + "selector": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "repository_key" + ], + "type": "object" + }, + "RuntimeWorkerSummary": { + "additionalProperties": false, + "properties": { + "capabilities": { + "$ref": "#/components/schemas/WorkerCapabilitySummary" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "display_name": { + "default": "", + "type": "string" + }, + "host_id": { + "type": "string" + }, + "implementation": { + "$ref": "#/components/schemas/WorkerImplementationSummary" + }, + "label": { + "type": "string" + }, + "last_seen_at": { + "type": [ + "string", + "null" + ] + }, + "pinned": { + "default": false, + "type": "boolean" + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "retention_state": { + "default": "", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "singleton_key": { + "type": [ + "string", + "null" + ] + }, + "state": { + "type": "string" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "workdir_attachments": { + "items": true, + "type": "array" + }, + "worker_id": { + "type": "string" + }, + "worker_state": true, + "workspace": { + "$ref": "#/components/schemas/WorkerWorkspaceSummary" + } + }, + "required": [ + "runtime_id", + "worker_id", + "host_id", + "label", + "workspace", + "state", + "implementation", + "capabilities" + ], + "type": "object" + }, + "RuntimeWorkerTicketAssignmentRequest": { + "additionalProperties": false, + "properties": { + "operation_id": { + "type": "string" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "ticket_id", + "operation_id" + ], + "type": "object" + }, + "RuntimeWorkerWorkdirAttachmentSummary": { + "additionalProperties": false, + "properties": { + "alias": { + "type": "string" + }, + "working_directory": { + "$ref": "#/components/schemas/RuntimeWorkingDirectorySummary" + } + }, + "required": [ + "alias", + "working_directory" + ], + "type": "object" + }, + "RuntimeWorkersQuery": { + "additionalProperties": false, + "properties": { + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeWorkersStatusFilter" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RuntimeWorkersStatusFilter": { + "enum": [ + "stopped" + ], + "type": "string" + }, + "RuntimeWorkingDirectoryCleanupTarget": { + "additionalProperties": false, + "description": "Runtime-internal Workdir cleanup authority. This transport intentionally\nretains the Backend-generated Repository id and is never a Workspace public\nprojection.", + "properties": { + "kind": { + "type": "string" + }, + "repository_id": { + "type": "string" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "kind", + "working_directory_id", + "repository_id" + ], + "type": "object" + }, + "RuntimeWorkingDirectorySummary": { + "additionalProperties": false, + "description": "Runtime-internal Workdir inventory transport. Workspace REST and model-facing\nsurfaces must project this through [`WorkingDirectorySummary`] so the UUID is\nreplaced with `repository_key`.", + "properties": { + "cleanliness": { + "type": [ + "string", + "null" + ] + }, + "cleanup_target": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeWorkingDirectoryCleanupTarget" + }, + { + "type": "null" + } + ] + }, + "creation_ref": { + "type": [ + "string", + "null" + ] + }, + "creation_selector": { + "type": [ + "string", + "null" + ] + }, + "creation_tree": { + "type": [ + "string", + "null" + ] + }, + "current_ref": { + "type": [ + "string", + "null" + ] + }, + "current_selector": { + "type": [ + "string", + "null" + ] + }, + "current_tree": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "description": "Optional human-facing label, never a routing key.", + "type": [ + "string", + "null" + ] + }, + "materializer_kind": { + "$ref": "#/components/schemas/WorkingDirectoryMaterializerKind" + }, + "observed_at_epoch_seconds": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "occupied_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkingDirectoryOccupancy" + }, + { + "type": "null" + } + ] + }, + "repository_id": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/WorkingDirectoryStatusKind" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "working_directory_id", + "repository_id", + "materializer_kind", + "status" + ], + "type": "object" + }, + "Segment": { + "oneOf": [ + { + "description": "Free-form text. The fallback every client can produce.", + "properties": { + "content": { + "type": "string" + }, + "kind": { + "const": "text", + "type": "string" + } + }, + "required": [ + "kind", + "content" + ], + "type": "object" + }, + { + "description": "Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.", + "properties": { + "chars": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "content": { + "type": "string" + }, + "id": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "paste", + "type": "string" + }, + "lines": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "id", + "chars", + "lines", + "content" + ], + "type": "object" + }, + { + "description": "Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.", + "properties": { + "artifact": { + "$ref": "#/components/schemas/PasteArtifactRef" + }, + "kind": { + "const": "paste_artifact", + "type": "string" + } + }, + "required": [ + "kind", + "artifact" + ], + "type": "object" + }, + { + "description": "Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.", + "properties": { + "file": { + "$ref": "#/components/schemas/UploadedFileRef" + }, + "kind": { + "const": "uploaded_file", + "type": "string" + } + }, + "required": [ + "kind", + "file" + ], + "type": "object" + }, + { + "description": "`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.", + "properties": { + "kind": { + "const": "file_ref", + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + }, + { + "description": "Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.", + "properties": { + "kind": { + "const": "flow", + "type": "string" + }, + "selector": { + "type": "string" + } + }, + "required": [ + "kind", + "selector" + ], + "type": "object" + }, + { + "description": "Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.", + "properties": { + "kind": { + "const": "unknown", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "ServerJsonSafeU64": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "SetCookieHeader": { + "description": "Sensitive `Set-Cookie` response header value.\n\nFormatting is available only for transport encoding; `Debug` always redacts the value.", + "type": "string" + }, + "SetTicketRoleAssignmentRequest": { + "additionalProperties": false, + "properties": { + "expected_assignment_id": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "principal": { + "$ref": "#/components/schemas/TicketAssignmentPrincipal" + } + }, + "required": [ + "operation_id", + "principal" + ], + "type": "object" + }, + "SkillActivationResponse": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/SkillDiagnostic" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "provenance": { + "$ref": "#/components/schemas/SkillProvenance" + } + }, + "required": [ + "name", + "provenance", + "body" + ], + "type": "object" + }, + "SkillActivationStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "SkillCatalogEntry": { + "additionalProperties": false, + "properties": { + "activation_status": { + "$ref": "#/components/schemas/SkillActivationStatus" + }, + "description": { + "type": "string" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/SkillDiagnostic" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "overrides": { + "items": { + "$ref": "#/components/schemas/SkillProvenance" + }, + "type": "array" + }, + "projection_status": { + "$ref": "#/components/schemas/SkillProjectionStatus" + }, + "provenance": { + "$ref": "#/components/schemas/SkillProvenance" + } + }, + "required": [ + "name", + "description", + "activation_status", + "projection_status", + "provenance", + "overrides", + "diagnostics" + ], + "type": "object" + }, + "SkillCatalogResponse": { + "additionalProperties": false, + "properties": { + "authority": { + "type": "string" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/SkillDiagnostic" + }, + "type": "array" + }, + "entries": { + "items": { + "$ref": "#/components/schemas/SkillCatalogEntry" + }, + "type": "array" + }, + "projection": { + "$ref": "#/components/schemas/SkillProjectionIdentity" + } + }, + "required": [ + "authority", + "projection", + "entries", + "diagnostics" + ], + "type": "object" + }, + "SkillDetailResponse": { + "additionalProperties": false, + "properties": { + "activation_status": { + "$ref": "#/components/schemas/SkillActivationStatus" + }, + "allowed_tools": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_tools_status": { + "type": "string" + }, + "authority": { + "type": "string" + }, + "body": { + "type": "string" + }, + "description": { + "type": "string" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/SkillDiagnostic" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "overrides": { + "items": { + "$ref": "#/components/schemas/SkillProvenance" + }, + "type": "array" + }, + "projection": { + "$ref": "#/components/schemas/SkillProjectionIdentity" + }, + "projection_status": { + "$ref": "#/components/schemas/SkillProjectionStatus" + }, + "provenance": { + "$ref": "#/components/schemas/SkillProvenance" + }, + "resources": { + "items": { + "$ref": "#/components/schemas/SkillResourceRef" + }, + "type": "array" + } + }, + "required": [ + "authority", + "projection", + "name", + "description", + "provenance", + "overrides", + "diagnostics", + "activation_status", + "projection_status", + "body", + "allowed_tools", + "allowed_tools_status", + "resources" + ], + "type": "object" + }, + "SkillDiagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/components/schemas/SkillDiagnosticSeverity" + }, + "source": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "severity", + "code", + "message" + ], + "type": "object" + }, + "SkillDiagnosticSeverity": { + "enum": [ + "error", + "warning" + ], + "type": "string" + }, + "SkillProjectionIdentity": { + "additionalProperties": false, + "properties": { + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "tree_digest": { + "type": "string" + } + }, + "required": [ + "config_revision", + "tree_digest" + ], + "type": "object" + }, + "SkillProjectionStatus": { + "enum": [ + "valid", + "invalid" + ], + "type": "string" + }, + "SkillProvenance": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/SkillSourceKind" + }, + "revision": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "source_digest": { + "type": [ + "string", + "null" + ] + }, + "tree_digest": { + "type": [ + "string", + "null" + ] + }, + "virtual_path": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + }, + "SkillResourceRef": { + "additionalProperties": false, + "properties": { + "diagnostic": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "supported": { + "type": "boolean" + } + }, + "required": [ + "kind", + "name", + "supported" + ], + "type": "object" + }, + "SkillSourceKind": { + "enum": [ + "builtin", + "workspace" + ], + "type": "string" + }, + "SourceEvidenceRef": { + "additionalProperties": false, + "description": "Host-resolved source/evidence metadata for an individual staging claim.\n\nThis deliberately stores only bounded anchor metadata: stable ids, entry\nranges, and short labels/summaries. It must not carry raw message bodies or\nfull tool result content.", + "properties": { + "entry_range": { + "description": "`[start_entry, end_entry]` inclusive range of session-store entry indices.", + "items": { + "$ref": "#/components/schemas/MemoryJsonSafeU64" + }, + "maxItems": 2, + "minItems": 2, + "type": [ + "array", + "null" + ] + }, + "evidence_id": { + "description": "Host-assigned evidence id within the referenced evidence set.", + "type": [ + "string", + "null" + ] + }, + "evidence_kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/EvidenceKind" + }, + { + "type": "null" + } + ], + "description": "Extensible evidence kind tag." + }, + "label": { + "description": "Short host-provided display label, not raw evidence content.", + "type": [ + "string", + "null" + ] + }, + "origin": { + "anyOf": [ + { + "$ref": "#/components/schemas/EvidenceOrigin" + }, + { + "type": "null" + } + ], + "description": "Trusted typed origin snapshot for this logical evidence entry." + }, + "segment_id": { + "description": "Session segment id containing the anchored log entries.", + "type": [ + "string", + "null" + ] + }, + "session_id": { + "description": "Stable session id when the anchor crosses or disambiguates segments.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "Short host-provided summary, not raw evidence content.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "SourceRef": { + "description": "Reference to a session-store entry range. Stored in `sources` /\n`last_sources` arrays for traceability back to raw session logs.", + "properties": { + "range": { + "description": "`[start_entry, end_entry]` inclusive range of session-store entry indices.", + "items": { + "$ref": "#/components/schemas/MemoryJsonSafeU64" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "segment_id": { + "type": "string" + } + }, + "required": [ + "segment_id", + "range" + ], + "type": "object" + }, + "StagingEvidence": { + "additionalProperties": false, + "description": "Bounded evidence snippet copied into a flat staging record.", + "properties": { + "entry_range": { + "items": { + "$ref": "#/components/schemas/MemoryJsonSafeU64" + }, + "maxItems": 2, + "minItems": 2, + "type": [ + "array", + "null" + ] + }, + "excerpt": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/EvidenceKind" + }, + "origin": { + "anyOf": [ + { + "$ref": "#/components/schemas/EvidenceOrigin" + }, + { + "type": "null" + } + ] + }, + "summary": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "kind" + ], + "type": "object" + }, + "Ticket": { + "properties": { + "artifacts": { + "items": { + "$ref": "#/components/schemas/TicketArtifactRef" + }, + "type": "array" + }, + "document": { + "$ref": "#/components/schemas/TicketDocument" + }, + "events": { + "items": { + "$ref": "#/components/schemas/TicketEvent" + }, + "type": "array" + }, + "meta": { + "$ref": "#/components/schemas/TicketMeta" + }, + "relations": { + "$ref": "#/components/schemas/TicketRelationView" + }, + "resolution": { + "anyOf": [ + { + "$ref": "#/components/schemas/MarkdownText" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "meta", + "document", + "events", + "artifacts", + "relations" + ], + "type": "object" + }, + "TicketActionEligibility": { + "additionalProperties": false, + "properties": { + "blockers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "can_assign_orchestrator": { + "type": "boolean" + }, + "can_queue": { + "type": "boolean" + }, + "can_start_manual_coder": { + "type": "boolean" + }, + "can_unassign_orchestrator": { + "type": "boolean" + }, + "queue_tickets": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "can_assign_orchestrator", + "can_unassign_orchestrator", + "can_queue", + "can_start_manual_coder", + "queue_tickets", + "blockers" + ], + "type": "object" + }, + "TicketArtifactRef": { + "properties": { + "relative_path": { + "description": "Path relative to the ticket's `artifacts/` directory.", + "type": "string" + } + }, + "required": [ + "relative_path" + ], + "type": "object" + }, + "TicketAssignmentPrincipal": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "account_id": { + "type": "string" + }, + "kind": { + "const": "user", + "type": "string" + } + }, + "required": [ + "kind", + "account_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "worker", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "agent_key": { + "type": "string" + }, + "kind": { + "const": "workspace_agent", + "type": "string" + } + }, + "required": [ + "kind", + "agent_key" + ], + "type": "object" + } + ] + }, + "TicketAssignmentRole": { + "enum": [ + "orchestrator", + "coder", + "owner", + "contributor" + ], + "type": "string" + }, + "TicketAssignmentSummary": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + }, + "worker_resource_key": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "assignment_id", + "runtime_id", + "worker_id" + ], + "type": "object" + }, + "TicketBodyReplacement": { + "properties": { + "new_string": { + "type": "string" + }, + "old_string": { + "type": "string" + }, + "replace_all": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "old_string", + "new_string" + ], + "type": "object" + }, + "TicketDependencyCheck": { + "properties": { + "blockers": { + "items": { + "$ref": "#/components/schemas/TicketRelationBlocker" + }, + "type": "array" + }, + "queue_guard": { + "$ref": "#/components/schemas/TicketQueueGuard" + }, + "queue_tickets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "recommended_action": { + "$ref": "#/components/schemas/TicketWorkspaceNextAction" + }, + "ticket": { + "$ref": "#/components/schemas/TicketSummary" + } + }, + "required": [ + "ticket", + "blockers", + "queue_guard", + "queue_tickets", + "recommended_action" + ], + "type": "object" + }, + "TicketDetail": { + "additionalProperties": false, + "properties": { + "action_eligibility": { + "$ref": "#/components/schemas/TicketActionEligibility" + }, + "artifact_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "artifacts": { + "items": { + "type": "string" + }, + "type": "array" + }, + "assignment_diagnostics": { + "items": { + "type": "string" + }, + "type": "array" + }, + "assignments": { + "items": { + "$ref": "#/components/schemas/TicketRoleAssignmentSummary" + }, + "type": "array" + }, + "body": { + "type": "string" + }, + "body_truncated": { + "type": "boolean" + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "current_coder": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentSummary" + }, + { + "type": "null" + } + ] + }, + "event_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "event_page": { + "$ref": "#/components/schemas/QueryPage" + }, + "events": { + "items": { + "$ref": "#/components/schemas/TicketEventDetail" + }, + "type": "array" + }, + "evidence": { + "$ref": "#/components/schemas/TicketEvidenceSummary" + }, + "id": { + "type": "string" + }, + "implementation_reports": { + "items": { + "$ref": "#/components/schemas/TicketEvidenceEvent" + }, + "type": "array" + }, + "item_revision": { + "type": "string" + }, + "linked_objectives": { + "items": { + "$ref": "#/components/schemas/ObjectiveLinkSummary" + }, + "type": "array" + }, + "merge_request": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketMergeRequestSummary" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "string" + }, + "queued_at": { + "type": [ + "string", + "null" + ] + }, + "queued_by": { + "type": [ + "string", + "null" + ] + }, + "readiness": { + "type": [ + "string", + "null" + ] + }, + "record_source": { + "type": "string" + }, + "relations": { + "$ref": "#/components/schemas/TicketDetailRelationView" + }, + "resolution": { + "type": [ + "string", + "null" + ] + }, + "resource_key": { + "type": "string" + }, + "risk_flags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "state": { + "type": "string" + }, + "targets": { + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "resource_key", + "title", + "state", + "priority", + "item_revision", + "targets", + "risk_flags", + "body", + "body_truncated", + "event_count", + "events", + "event_page", + "artifact_count", + "artifacts", + "relations", + "linked_objectives", + "implementation_reports", + "assignments", + "assignment_diagnostics", + "action_eligibility", + "evidence", + "record_source" + ], + "type": "object" + }, + "TicketDetailDerivedRelation": { + "additionalProperties": false, + "properties": { + "at": { + "type": "string" + }, + "author": { + "type": "string" + }, + "forward_kind": { + "type": "string" + }, + "inverse_kind": { + "type": "string" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "source_resource_key": { + "type": [ + "string", + "null" + ] + }, + "source_ticket": { + "type": "string" + } + }, + "required": [ + "source_ticket", + "inverse_kind", + "forward_kind", + "author", + "at" + ], + "type": "object" + }, + "TicketDetailRelation": { + "additionalProperties": false, + "properties": { + "at": { + "type": "string" + }, + "author": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": "string" + }, + "target_resource_key": { + "type": [ + "string", + "null" + ] + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "ticket_id", + "kind", + "target", + "author", + "at" + ], + "type": "object" + }, + "TicketDetailRelationBlocker": { + "additionalProperties": false, + "properties": { + "blocking_resource_key": { + "type": [ + "string", + "null" + ] + }, + "blocking_state": { + "type": "string" + }, + "blocking_ticket": { + "type": "string" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "reason_kind": { + "type": "string" + }, + "relation_kind": { + "type": "string" + } + }, + "required": [ + "blocking_ticket", + "reason_kind", + "relation_kind", + "blocking_state" + ], + "type": "object" + }, + "TicketDetailRelationNotice": { + "additionalProperties": false, + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "related_ticket": { + "type": "string" + } + }, + "required": [ + "related_ticket", + "kind", + "message" + ], + "type": "object" + }, + "TicketDetailRelationView": { + "additionalProperties": false, + "properties": { + "blockers": { + "items": { + "$ref": "#/components/schemas/TicketDetailRelationBlocker" + }, + "type": "array" + }, + "incoming": { + "items": { + "$ref": "#/components/schemas/TicketDetailDerivedRelation" + }, + "type": "array" + }, + "notices": { + "items": { + "$ref": "#/components/schemas/TicketDetailRelationNotice" + }, + "type": "array" + }, + "outgoing": { + "items": { + "$ref": "#/components/schemas/TicketDetailRelation" + }, + "type": "array" + } + }, + "required": [ + "outgoing", + "incoming", + "blockers", + "notices" + ], + "type": "object" + }, + "TicketDoctorDiagnostic": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "severity": { + "$ref": "#/components/schemas/TicketDoctorSeverity" + } + }, + "required": [ + "severity", + "message" + ], + "type": "object" + }, + "TicketDoctorReport": { + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/TicketDoctorDiagnostic" + }, + "type": "array" + } + }, + "required": [ + "diagnostics" + ], + "type": "object" + }, + "TicketDoctorSeverity": { + "enum": [ + "Error", + "Warning" + ], + "type": "string" + }, + "TicketDocument": { + "properties": { + "body": { + "$ref": "#/components/schemas/MarkdownText" + }, + "raw_frontmatter": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "required": [ + "body", + "raw_frontmatter" + ], + "type": "object" + }, + "TicketEvent": { + "properties": { + "at": { + "type": [ + "string", + "null" + ] + }, + "attributes": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "$ref": "#/components/schemas/MarkdownText" + }, + "from": { + "type": [ + "string", + "null" + ] + }, + "heading": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/TicketEventKind" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "references": { + "items": { + "$ref": "#/components/schemas/TicketReference" + }, + "type": "array" + }, + "state_field": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "to": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "body", + "references", + "attributes" + ], + "type": "object" + }, + "TicketEventDetail": { + "additionalProperties": false, + "properties": { + "at": { + "type": [ + "string", + "null" + ] + }, + "attributes": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "type": [ + "string", + "null" + ] + }, + "event_ref": { + "type": "string" + }, + "from": { + "type": [ + "string", + "null" + ] + }, + "heading": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "references": { + "items": { + "type": "string" + }, + "type": "array" + }, + "sequence": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "state_field": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "to": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "sequence", + "event_ref", + "kind", + "attributes", + "references" + ], + "type": "object" + }, + "TicketEventKind": { + "oneOf": [ + { + "enum": [ + "create", + "comment", + "plan", + "decision", + "implementation_report", + "state_changed", + "intake_summary", + "status_changed", + "close" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": [ + "other" + ], + "type": "object" + } + ] + }, + "TicketEvidenceEvent": { + "additionalProperties": false, + "properties": { + "at": { + "type": [ + "string", + "null" + ] + }, + "author": { + "type": [ + "string", + "null" + ] + }, + "event_ref": { + "type": "string" + }, + "excerpt": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "sequence": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "event_ref", + "sequence", + "kind", + "excerpt" + ], + "type": "object" + }, + "TicketEvidenceSummary": { + "additionalProperties": false, + "properties": { + "approved_current_subject": { + "type": "boolean" + }, + "complete_for_integration": { + "type": "boolean" + }, + "has_commit": { + "type": "boolean" + }, + "has_current_subject_ref": { + "type": "boolean" + }, + "has_merge_request": { + "type": "boolean" + }, + "has_review_request": { + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "type": "array" + }, + "review_after_rescope": { + "type": "boolean" + }, + "review_status": { + "type": [ + "string", + "null" + ] + }, + "unresolved_request_changes": { + "type": "boolean" + } + }, + "required": [ + "has_merge_request", + "has_current_subject_ref", + "has_review_request", + "has_commit", + "approved_current_subject", + "review_after_rescope", + "unresolved_request_changes", + "complete_for_integration", + "missing" + ], + "type": "object" + }, + "TicketIdOrSlug": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + } + }, + "required": [ + "Id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Slug": { + "type": "string" + } + }, + "required": [ + "Slug" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Query": { + "type": "string" + } + }, + "required": [ + "Query" + ], + "type": "object" + } + ] + }, + "TicketIntakeSummary": { + "properties": { + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "$ref": "#/components/schemas/MarkdownText" + }, + "references": { + "items": { + "$ref": "#/components/schemas/TicketReference" + }, + "type": "array" + } + }, + "required": [ + "body", + "references" + ], + "type": "object" + }, + "TicketItemEdit": { + "properties": { + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "anyOf": [ + { + "$ref": "#/components/schemas/MarkdownText" + }, + { + "type": "null" + } + ], + "description": "Whole-body replacement for legacy authoring surfaces. Prefer `body_replacement`\nfor small edits that should be validated against the current body." + }, + "body_replacement": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketBodyReplacement" + }, + { + "type": "null" + } + ], + "default": null + }, + "targets": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketTargetsEdit" + }, + { + "type": "null" + } + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "TicketListHttpQuery": { + "additionalProperties": false, + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "states": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "TicketListItemSummary": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "priority": { + "type": "string" + }, + "queued_at": { + "type": [ + "string", + "null" + ] + }, + "queued_by": { + "type": [ + "string", + "null" + ] + }, + "record_source": { + "type": "string" + }, + "resource_key": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "workspace_action_priority": { + "type": "string" + } + }, + "required": [ + "id", + "resource_key", + "title", + "state", + "priority", + "workspace_action_priority", + "record_source" + ], + "type": "object" + }, + "TicketListResponse": { + "additionalProperties": false, + "properties": { + "invalid_records": { + "items": { + "$ref": "#/components/schemas/InvalidProjectRecord" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/TicketListItemSummary" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "page": { + "$ref": "#/components/schemas/QueryPage" + }, + "record_authority": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "limit", + "items", + "page", + "invalid_records", + "record_authority" + ], + "type": "object" + }, + "TicketMarkReadyRequest": { + "additionalProperties": false, + "properties": { + "intake_summary": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketIntakeSummary" + }, + { + "type": "null" + } + ], + "default": null + }, + "operation_key": { + "type": "string" + }, + "reason": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "operation_key" + ], + "type": "object" + }, + "TicketMergeRequestSummary": { + "additionalProperties": false, + "properties": { + "current_subject_ref": { + "type": [ + "string", + "null" + ] + }, + "merge_request_id": { + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "review_excerpt": { + "type": [ + "string", + "null" + ] + }, + "review_requested_at": { + "type": [ + "string", + "null" + ] + }, + "review_status": { + "type": "string" + }, + "review_subject_ref": { + "type": [ + "string", + "null" + ] + }, + "review_submitted_at": { + "type": [ + "string", + "null" + ] + }, + "selector_from": { + "type": [ + "string", + "null" + ] + }, + "selector_to": { + "type": "string" + }, + "state": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "merge_request_id", + "repository_key", + "state", + "review_status", + "selector_to", + "updated_at" + ], + "type": "object" + }, + "TicketMeta": { + "properties": { + "assignee": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "priority": { + "type": "string" + }, + "queued_at": { + "type": [ + "string", + "null" + ] + }, + "queued_by": { + "type": [ + "string", + "null" + ] + }, + "raw": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "readiness": { + "type": [ + "string", + "null" + ] + }, + "resource_key": { + "type": [ + "string", + "null" + ] + }, + "risk_flags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ExtensibleTicketStatus" + }, + "targets": { + "default": [], + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "workflow_state": { + "$ref": "#/components/schemas/TicketWorkflowState" + }, + "workflow_state_explicit": { + "type": "boolean" + } + }, + "required": [ + "id", + "slug", + "title", + "status", + "kind", + "priority", + "labels", + "risk_flags", + "workflow_state", + "workflow_state_explicit", + "raw" + ], + "type": "object" + }, + "TicketOrchestrationPlanSearchRequest": { + "additionalProperties": false, + "properties": { + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/OrchestrationPlanKind" + }, + { + "type": "null" + } + ] + }, + "ticket": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketIdOrSlug" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "TicketQueryItem": { + "additionalProperties": false, + "properties": { + "blocker_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "current_coder": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentSummary" + }, + { + "type": "null" + } + ] + }, + "evidence": { + "$ref": "#/components/schemas/TicketEvidenceSummary" + }, + "id": { + "type": "string" + }, + "item_revision": { + "type": "string" + }, + "linked_objective_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "linked_objective_keys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "matched_fields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "matching_event": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketEvidenceEvent" + }, + { + "type": "null" + } + ] + }, + "merge_request": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketMergeRequestSummary" + }, + { + "type": "null" + } + ] + }, + "priority": { + "type": "string" + }, + "readiness": { + "type": [ + "string", + "null" + ] + }, + "relation_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "resource_key": { + "type": "string" + }, + "snippet": { + "type": [ + "string", + "null" + ] + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "unresolved_blocker_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "unresolved_review_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "workspace_action_priority": { + "type": "string" + } + }, + "required": [ + "id", + "resource_key", + "title", + "state", + "priority", + "item_revision", + "workspace_action_priority", + "matched_fields", + "linked_objective_ids", + "linked_objective_keys", + "relation_count", + "blocker_count", + "unresolved_blocker_count", + "unresolved_review_count", + "evidence" + ], + "type": "object" + }, + "TicketQueryRequest": { + "additionalProperties": false, + "properties": { + "attention": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "cursor": { + "type": [ + "string", + "null" + ] + }, + "event_kinds": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "evidence": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "linked_objective_id": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "related_ticket_id": { + "type": [ + "string", + "null" + ] + }, + "relation_kind": { + "type": [ + "string", + "null" + ] + }, + "review_status": { + "type": [ + "string", + "null" + ] + }, + "sort": { + "type": [ + "string", + "null" + ] + }, + "states": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "updated_after": { + "type": [ + "string", + "null" + ] + }, + "updated_before": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "TicketQueryResponse": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TicketQueryItem" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/QueryPage" + }, + "record_authority": { + "type": "string" + } + }, + "required": [ + "items", + "page", + "record_authority" + ], + "type": "object" + }, + "TicketQueueGuard": { + "properties": { + "blocked_reason": { + "type": [ + "string", + "null" + ] + }, + "can_queue_for_orchestrator": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "can_queue_for_orchestrator" + ], + "type": "object" + }, + "TicketQueueOutcome": { + "properties": { + "queued_tickets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "requested_ticket": { + "type": "string" + } + }, + "required": [ + "requested_ticket", + "queued_tickets" + ], + "type": "object" + }, + "TicketRef": { + "properties": { + "id": { + "type": "string" + }, + "resource_key": { + "type": [ + "string", + "null" + ] + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/TicketStatus" + } + }, + "required": [ + "id", + "slug", + "status" + ], + "type": "object" + }, + "TicketReference": { + "properties": { + "kind": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + }, + "TicketRelation": { + "additionalProperties": false, + "properties": { + "at": { + "type": "string" + }, + "author": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/TicketRelationKind" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": "string" + }, + "ticket_id": { + "type": "string" + } + }, + "required": [ + "ticket_id", + "kind", + "target", + "author", + "at" + ], + "type": "object" + }, + "TicketRelationBlocker": { + "properties": { + "blocking_state": { + "$ref": "#/components/schemas/TicketWorkflowState" + }, + "blocking_ticket": { + "type": "string" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "reason_kind": { + "type": "string" + }, + "relation_kind": { + "$ref": "#/components/schemas/TicketRelationKind" + } + }, + "required": [ + "blocking_ticket", + "reason_kind", + "relation_kind", + "blocking_state" + ], + "type": "object" + }, + "TicketRelationKind": { + "enum": [ + "depends_on", + "blocks", + "related", + "supersedes", + "duplicate_of" + ], + "type": "string" + }, + "TicketRelationNotice": { + "properties": { + "kind": { + "$ref": "#/components/schemas/TicketRelationKind" + }, + "message": { + "type": "string" + }, + "related_ticket": { + "type": "string" + } + }, + "required": [ + "related_ticket", + "kind", + "message" + ], + "type": "object" + }, + "TicketRelationRemoveRequest": { + "additionalProperties": false, + "properties": { + "kind": { + "$ref": "#/components/schemas/TicketRelationKind" + }, + "target": { + "type": "string" + } + }, + "required": [ + "kind", + "target" + ], + "type": "object" + }, + "TicketRelationSearchRequest": { + "additionalProperties": false, + "properties": { + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketRelationKind" + }, + { + "type": "null" + } + ] + }, + "ticket": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketIdOrSlug" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "TicketRelationView": { + "properties": { + "blockers": { + "items": { + "$ref": "#/components/schemas/TicketRelationBlocker" + }, + "type": "array" + }, + "incoming": { + "items": { + "$ref": "#/components/schemas/DerivedTicketRelation" + }, + "type": "array" + }, + "notices": { + "items": { + "$ref": "#/components/schemas/TicketRelationNotice" + }, + "type": "array" + }, + "outgoing": { + "items": { + "$ref": "#/components/schemas/TicketRelation" + }, + "type": "array" + } + }, + "required": [ + "outgoing", + "incoming", + "blockers", + "notices" + ], + "type": "object" + }, + "TicketRoleAssignmentMutationResponse": { + "additionalProperties": false, + "properties": { + "assignment": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketRoleAssignmentRecord" + }, + { + "type": "null" + } + ] + }, + "ticket_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "ticket_id" + ], + "type": "object" + }, + "TicketRoleAssignmentRecord": { + "additionalProperties": false, + "properties": { + "assigned_at": { + "type": "string" + }, + "assigned_by": { + "type": "string" + }, + "assignment_id": { + "type": "string" + }, + "principal": { + "$ref": "#/components/schemas/TicketAssignmentPrincipal" + }, + "role": { + "$ref": "#/components/schemas/TicketAssignmentRole" + }, + "ticket_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "ticket_id", + "assignment_id", + "role", + "principal", + "assigned_by", + "assigned_at" + ], + "type": "object" + }, + "TicketRoleAssignmentSummary": { + "additionalProperties": false, + "properties": { + "assigned_at": { + "type": "string" + }, + "assigned_by": { + "type": "string" + }, + "assignment_id": { + "type": "string" + }, + "principal": { + "$ref": "#/components/schemas/TicketAssignmentPrincipal" + }, + "role": { + "type": "string" + } + }, + "required": [ + "assignment_id", + "role", + "principal", + "assigned_by", + "assigned_at" + ], + "type": "object" + }, + "TicketRoleAssignmentsResponse": { + "additionalProperties": false, + "properties": { + "assignments": { + "items": { + "$ref": "#/components/schemas/TicketRoleAssignmentRecord" + }, + "type": "array" + }, + "ticket_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "ticket_id", + "assignments" + ], + "type": "object" + }, + "TicketShowRequest": { + "additionalProperties": false, + "properties": { + "event_cursor": { + "type": [ + "string", + "null" + ] + }, + "event_limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "TicketStateChange": { + "properties": { + "author": { + "type": [ + "string", + "null" + ] + }, + "body": { + "$ref": "#/components/schemas/MarkdownText" + }, + "from": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "references": { + "items": { + "$ref": "#/components/schemas/TicketReference" + }, + "type": "array" + }, + "to": { + "type": "string" + } + }, + "required": [ + "from", + "to", + "reason", + "body", + "references" + ], + "type": "object" + }, + "TicketStatus": { + "enum": [ + "Open", + "Closed" + ], + "type": "string" + }, + "TicketSummary": { + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "priority": { + "type": "string" + }, + "queued_at": { + "type": [ + "string", + "null" + ] + }, + "queued_by": { + "type": [ + "string", + "null" + ] + }, + "readiness": { + "type": [ + "string", + "null" + ] + }, + "resource_key": { + "type": [ + "string", + "null" + ] + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ExtensibleTicketStatus" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "workflow_state": { + "$ref": "#/components/schemas/TicketWorkflowState" + }, + "workflow_state_explicit": { + "type": "boolean" + } + }, + "required": [ + "id", + "slug", + "title", + "status", + "kind", + "priority", + "labels", + "workflow_state", + "workflow_state_explicit" + ], + "type": "object" + }, + "TicketSummarySearchQuery": { + "additionalProperties": false, + "properties": { + "limit": { + "format": "uint", + "maximum": 1000, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "state": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "TicketTarget": { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/TicketTargetAccess" + }, + "ref_selector": { + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + } + }, + "required": [ + "repository_key", + "access" + ], + "type": "object" + }, + "TicketTargetAccess": { + "enum": [ + "read_only", + "read_write" + ], + "type": "string" + }, + "TicketTargetsEdit": { + "oneOf": [ + { + "properties": { + "action": { + "const": "set", + "type": "string" + }, + "targets": { + "items": { + "$ref": "#/components/schemas/TicketTarget" + }, + "type": "array" + } + }, + "required": [ + "action", + "targets" + ], + "type": "object" + }, + { + "properties": { + "action": { + "const": "clear", + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + } + ] + }, + "TicketWorkflowState": { + "enum": [ + "planning", + "ready", + "queued", + "in_progress", + "done", + "closed" + ], + "type": "string" + }, + "TicketWorkspaceNextAction": { + "enum": [ + "clarify", + "queue_for_orchestrator", + "close", + "wait_for_orchestrator" + ], + "type": "string" + }, + "ToolchainContract": { + "additionalProperties": false, + "properties": { + "contract_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "decodal_version": { + "type": "string" + }, + "entrypoints": { + "items": { + "type": "string" + }, + "type": "array" + }, + "fingerprint": { + "type": "string" + }, + "import_policy_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "schema_bundle": { + "$ref": "#/components/schemas/WorkspaceConfigSchemaBundle" + }, + "schema_version": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "contract_version", + "decodal_version", + "schema_version", + "entrypoints", + "import_policy_version", + "schema_bundle", + "fingerprint" + ], + "type": "object" + }, + "UpdateRemoteRuntimeRequest": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + }, + "endpoint": { + "type": "string" + } + }, + "required": [ + "endpoint" + ], + "type": "object" + }, + "UpdateWorkspaceMemorySettingsRequest": { + "additionalProperties": false, + "description": "Compare-and-swap update for Workspace-owned Memory settings.", + "properties": { + "expected_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "language": { + "type": "string" + } + }, + "required": [ + "expected_revision", + "language" + ], + "type": "object" + }, + "UpdateWorkspaceMetadataRequest": { + "additionalProperties": false, + "description": "Compare-and-swap update for Workspace identity display metadata.", + "properties": { + "display_name": { + "type": "string" + }, + "revision": { + "type": "string" + } + }, + "required": [ + "display_name", + "revision" + ], + "type": "object" + }, + "UploadedFileAvailability": { + "description": "Availability recorded for an uploaded client-local file.", + "enum": [ + "available", + "unavailable", + "integrity_failed" + ], + "type": "string" + }, + "UploadedFileRef": { + "description": "Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.", + "properties": { + "artifact_id": { + "type": "string" + }, + "availability": { + "$ref": "#/components/schemas/UploadedFileAvailability" + }, + "byte_len": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "created_at_ms": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "file_name": { + "type": "string" + }, + "media_type": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "source_entry_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "artifact_id", + "file_name", + "media_type", + "created_at_ms", + "availability", + "byte_len", + "sha256" + ], + "type": "object" + }, + "UsageAudit": { + "properties": { + "cache_creation_input_tokens": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cache_read_input_tokens": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input_tokens": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "WhoamiResponse": { + "additionalProperties": false, + "properties": { + "actor": { + "anyOf": [ + { + "$ref": "#/components/schemas/RequestActor" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "WorkerBusyState": { + "oneOf": [ + { + "properties": { + "kind": { + "const": "run", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/WorkerRunState" + } + }, + "required": [ + "kind", + "state" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "maintenance", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/WorkerMaintenanceState" + } + }, + "required": [ + "kind", + "state" + ], + "type": "object" + } + ] + }, + "WorkerCapabilitySummary": { + "properties": { + "can_spawn_followup": { + "type": "boolean" + }, + "can_stop": { + "type": "boolean" + } + }, + "required": [ + "can_stop", + "can_spawn_followup" + ], + "type": "object" + }, + "WorkerFileDeleteResponse": { + "additionalProperties": false, + "properties": { + "deleted": { + "type": "boolean" + } + }, + "required": [ + "deleted" + ], + "type": "object" + }, + "WorkerFileUploadQuery": { + "additionalProperties": false, + "properties": { + "file_name": { + "type": "string" + }, + "media_type": { + "type": "string" + }, + "upload_id": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "file_name", + "media_type" + ], + "type": "object" + }, + "WorkerFileUploadResponse": { + "additionalProperties": false, + "properties": { + "file": { + "$ref": "#/components/schemas/UploadedFileRef" + } + }, + "required": [ + "file" + ], + "type": "object" + }, + "WorkerImplementationSummary": { + "properties": { + "display_hint": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "kind", + "display_hint" + ], + "type": "object" + }, + "WorkerLaunchOptionsResponse": { + "additionalProperties": false, + "properties": { + "default_profile": { + "type": [ + "string", + "null" + ] + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/WorkerLaunchProfileCandidate" + }, + "type": "array" + }, + "repositories": { + "items": { + "$ref": "#/components/schemas/WorkingDirectoryRepositoryOption" + }, + "type": "array" + }, + "runtimes": { + "items": { + "$ref": "#/components/schemas/WorkerLaunchRuntimeOption" + }, + "type": "array" + }, + "working_directories": { + "items": { + "$ref": "#/components/schemas/WorkingDirectorySummary" + }, + "type": "array" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtimes", + "profiles", + "repositories", + "working_directories", + "diagnostics" + ], + "type": "object" + }, + "WorkerLaunchProfileCandidate": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "description" + ], + "type": "object" + }, + "WorkerLaunchRuntimeOption": { + "additionalProperties": false, + "properties": { + "built_in": { + "type": "boolean" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "display_name": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "worker_creation_available": { + "type": "boolean" + }, + "working_directory_required": { + "type": "boolean" + } + }, + "required": [ + "runtime_id", + "display_name", + "built_in", + "worker_creation_available", + "working_directory_required", + "status", + "diagnostics" + ], + "type": "object" + }, + "WorkerLaunchWorkerSummary": { + "additionalProperties": false, + "description": "Runtime-owned Worker summary embedded in Worker launch responses.\n\nThis preserves the existing launch wire shape. Workspace-owned Worker list\nand detail responses use [`WorkerSummary`] instead.", + "properties": { + "capabilities": { + "$ref": "#/components/schemas/WorkerCapabilitySummary" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "display_name": { + "type": "string" + }, + "host_id": { + "type": "string" + }, + "implementation": { + "$ref": "#/components/schemas/WorkerImplementationSummary" + }, + "label": { + "type": "string" + }, + "last_seen_at": { + "type": [ + "string", + "null" + ] + }, + "pinned": { + "type": "boolean" + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "retention_state": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "singleton_key": { + "type": [ + "string", + "null" + ] + }, + "state": { + "type": "string" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workdir_attachments": { + "items": { + "$ref": "#/components/schemas/RuntimeWorkerWorkdirAttachmentSummary" + }, + "type": "array" + }, + "worker_id": { + "type": "string" + }, + "workspace": { + "$ref": "#/components/schemas/WorkerWorkspaceSummary" + } + }, + "required": [ + "runtime_id", + "worker_id", + "host_id", + "display_name", + "label", + "tags", + "workspace", + "state", + "pinned", + "retention_state", + "implementation", + "capabilities" + ], + "type": "object" + }, + "WorkerLifecycleAudit": { + "properties": { + "consolidation": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConsolidationAudit" + }, + { + "type": "null" + } + ] + }, + "extract": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExtractAudit" + }, + { + "type": "null" + } + ] + }, + "memory_settings": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemorySettingsAudit" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelAudit" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": "string" + }, + "run_id": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/WorkerLifecycleStatus" + }, + "trigger": { + "$ref": "#/components/schemas/AuditTrigger" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageAudit" + }, + { + "type": "null" + } + ] + }, + "worker": { + "$ref": "#/components/schemas/AuditWorker" + } + }, + "required": [ + "run_id", + "worker", + "status", + "trigger", + "reason" + ], + "type": "object" + }, + "WorkerLifecycleStatus": { + "enum": [ + "started", + "completed", + "skipped", + "failed", + "cancelled" + ], + "type": "string" + }, + "WorkerListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/WorkerSummary" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "limit", + "items", + "source" + ], + "type": "object" + }, + "WorkerMaintenanceState": { + "enum": [ + "compacting" + ], + "type": "string" + }, + "WorkerOperationState": { + "enum": [ + "accepted", + "unsupported", + "rejected" + ], + "type": "string" + }, + "WorkerRestoreResponse": { + "additionalProperties": false, + "properties": { + "result": { + "$ref": "#/components/schemas/WorkerRestoreResult" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtime_id", + "worker_id", + "result" + ], + "type": "object" + }, + "WorkerRestoreResult": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "state": { + "$ref": "#/components/schemas/WorkerRestoreState" + }, + "worker": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkerSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "state" + ], + "type": "object" + }, + "WorkerRestoreState": { + "description": "Cross-layer outcome of an explicit Worker restore operation.\n\n`Rejected` is reserved for read-only preflight failures. Once live restore\nwork starts, failure is either a confirmed `RolledBack` operation or a\n`ReconciliationRequired` result whose commit state must be reread/retried.", + "enum": [ + "accepted", + "rejected", + "rolled_back", + "reconciliation_required" + ], + "type": "string" + }, + "WorkerRetentionResponse": { + "additionalProperties": false, + "properties": { + "pinned": { + "type": "boolean" + }, + "retention_state": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtime_id", + "worker_id", + "pinned", + "retention_state" + ], + "type": "object" + }, + "WorkerRunState": { + "enum": [ + "running", + "pausing", + "paused", + "cancelling" + ], + "type": "string" + }, + "WorkerState": { + "oneOf": [ + { + "properties": { + "kind": { + "const": "idle", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "busy", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/WorkerBusyState" + } + }, + "required": [ + "kind", + "state" + ], + "type": "object" + } + ] + }, + "WorkerStateSnapshot": { + "properties": { + "last_command_id": { + "description": "Highest lifecycle command id observed by this controller instance.", + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/WorkerState" + } + }, + "required": [ + "last_command_id", + "state" + ], + "type": "object" + }, + "WorkerSummary": { + "description": "Workspace-authoritative Worker projection.\n\n`resource_key` is required here even though Runtime-internal Worker summaries\ndo not carry one. The Workspace Server must resolve it from Workspace\nauthority before constructing this response.", + "properties": { + "capabilities": { + "$ref": "#/components/schemas/WorkerCapabilitySummary" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "display_name": { + "default": "", + "type": "string" + }, + "host_id": { + "type": "string" + }, + "implementation": { + "$ref": "#/components/schemas/WorkerImplementationSummary" + }, + "label": { + "type": "string" + }, + "last_seen_at": { + "type": [ + "string", + "null" + ] + }, + "pinned": { + "default": false, + "type": "boolean" + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "resource_key": { + "type": "string" + }, + "retention_state": { + "default": "", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "singleton_key": { + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "Runtime catalog lifecycle compatibility state. Live foreground state, when\navailable, is carried separately in `worker_state`.", + "type": "string" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "workdir_attachments": { + "items": { + "$ref": "#/components/schemas/WorkerWorkdirAttachmentSummary" + }, + "type": "array" + }, + "worker_id": { + "type": "string" + }, + "worker_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkerStateSnapshot" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "$ref": "#/components/schemas/WorkerWorkspaceSummary" + } + }, + "required": [ + "runtime_id", + "worker_id", + "resource_key", + "host_id", + "label", + "workspace", + "state", + "implementation", + "capabilities" + ], + "type": "object" + }, + "WorkerWorkdirAttachmentSummary": { + "additionalProperties": false, + "description": "One Workspace Worker Workdir attachment. `alias` is the stable Worker-local\nrouting key; the nested Workdir id and display name are inventory metadata.", + "properties": { + "alias": { + "type": "string" + }, + "working_directory": { + "$ref": "#/components/schemas/WorkingDirectorySummary" + } + }, + "required": [ + "alias", + "working_directory" + ], + "type": "object" + }, + "WorkerWorkspaceSummary": { + "additionalProperties": false, + "properties": { + "identity": { + "type": "string" + }, + "visibility": { + "type": "string" + }, + "workspace_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "visibility", + "identity" + ], + "type": "object" + }, + "WorkingDirectoryCleanupTarget": { + "additionalProperties": false, + "properties": { + "kind": { + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "kind", + "working_directory_id", + "repository_key" + ], + "type": "object" + }, + "WorkingDirectoryCreateRequest": { + "additionalProperties": false, + "description": "Browser/Rust-client Workdir materialization request.\n\n`runtime_id = None` requests Workspace default Runtime resolution and\n`operation_id = Some(_)` fences exact replay. All four fields deliberately\npreserve the Server's existing optionality.", + "properties": { + "display_name": { + "description": "Optional human-facing label, independent of every Worker attachment alias.", + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + }, + "runtime_id": { + "type": [ + "string", + "null" + ] + }, + "selector": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "repository_key" + ], + "type": "object" + }, + "WorkingDirectoryCreateResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "item": { + "$ref": "#/components/schemas/WorkingDirectorySummary" + }, + "runtime_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtime_id", + "item", + "diagnostics" + ], + "type": "object" + }, + "WorkingDirectoryDetailResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "item": { + "$ref": "#/components/schemas/WorkingDirectorySummary" + }, + "runtime_id": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "item", + "diagnostics" + ], + "type": "object" + }, + "WorkingDirectoryListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/WorkingDirectorySummary" + }, + "type": "array" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "items", + "diagnostics" + ], + "type": "object" + }, + "WorkingDirectoryMaterializerKind": { + "description": "Public Workdir materializer classification.\n\nThe value identifies stable materialization provenance without exposing a\nprovider path, Runtime handle, or session identity.", + "enum": [ + "runtime_git_clone", + "client_hosted_external" + ], + "type": "string" + }, + "WorkingDirectoryOccupancy": { + "additionalProperties": false, + "description": "Durable Workspace occupancy projection for one Workdir.", + "properties": { + "display_name": { + "type": "string" + }, + "linked_at": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "runtime_id", + "worker_id", + "display_name", + "linked_at" + ], + "type": "object" + }, + "WorkingDirectoryRemovalDisposition": { + "enum": [ + "removed", + "retained", + "attention_required" + ], + "type": "string" + }, + "WorkingDirectoryRemovalRequest": { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "WorkingDirectoryRemovalResponse": { + "additionalProperties": false, + "properties": { + "disposition": { + "$ref": "#/components/schemas/WorkingDirectoryRemovalDisposition" + }, + "failure_category": { + "type": [ + "string", + "null" + ] + }, + "retryable": { + "type": "boolean" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "working_directory_id", + "disposition", + "retryable" + ], + "type": "object" + }, + "WorkingDirectoryRepositoryOption": { + "additionalProperties": false, + "properties": { + "default_selector": { + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + } + }, + "required": [ + "repository_key" + ], + "type": "object" + }, + "WorkingDirectorySource": { + "description": "Public Workdir source identity. Provider routing details and host paths are intentionally absent.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "repository", + "type": "string" + }, + "repository_key": { + "type": "string" + } + }, + "required": [ + "kind", + "repository_key" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "grant_id": { + "type": "string" + }, + "kind": { + "const": "external_grant", + "type": "string" + } + }, + "required": [ + "kind", + "grant_id" + ], + "type": "object" + } + ] + }, + "WorkingDirectoryStatusKind": { + "enum": [ + "active", + "cleanup_pending", + "corrupted", + "not_found", + "unknown" + ], + "type": "string" + }, + "WorkingDirectorySummary": { + "additionalProperties": false, + "description": "Public, provider-neutral Workdir inventory projection.", + "properties": { + "cleanliness": { + "type": [ + "string", + "null" + ] + }, + "cleanup_target": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkingDirectoryCleanupTarget" + }, + { + "type": "null" + } + ] + }, + "creation_ref": { + "type": [ + "string", + "null" + ] + }, + "creation_selector": { + "type": [ + "string", + "null" + ] + }, + "creation_tree": { + "type": [ + "string", + "null" + ] + }, + "current_ref": { + "type": [ + "string", + "null" + ] + }, + "current_selector": { + "type": [ + "string", + "null" + ] + }, + "current_tree": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "description": "Optional human-facing label, never a routing key.", + "type": [ + "string", + "null" + ] + }, + "materializer_kind": { + "$ref": "#/components/schemas/WorkingDirectoryMaterializerKind" + }, + "observed_at_epoch_seconds": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "occupied_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkingDirectoryOccupancy" + }, + { + "type": "null" + } + ] + }, + "source": { + "$ref": "#/components/schemas/WorkingDirectorySource" + }, + "status": { + "$ref": "#/components/schemas/WorkingDirectoryStatusKind" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "working_directory_id", + "source", + "materializer_kind", + "status" + ], + "type": "object" + }, + "WorkspaceAuthConfig": { + "description": "Browser authentication configuration exposed by the scoped Workspace summary.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "Passkey": { + "properties": { + "cookie_name": { + "type": "string" + }, + "origin": { + "type": "string" + }, + "public_base_url": { + "type": "string" + }, + "rp_id": { + "type": "string" + } + }, + "required": [ + "rp_id", + "origin", + "public_base_url", + "cookie_name" + ], + "type": "object" + } + }, + "required": [ + "Passkey" + ], + "type": "object" + } + ] + }, + "WorkspaceCatalogListResponse": { + "description": "Public response returned by `GET /api/workspaces`.\n\nThe transparent newtype keeps the established top-level JSON array while making the\ncomplete list response a named cross-crate and generated-TypeScript authority.", + "items": { + "$ref": "#/components/schemas/WorkspaceSummary" + }, + "type": "array" + }, + "WorkspaceConfigSchemaBundle": { + "additionalProperties": false, + "properties": { + "contributions": { + "items": { + "$ref": "#/components/schemas/ConfigSchemaContribution" + }, + "type": "array" + }, + "fingerprint": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "contributions", + "source", + "fingerprint" + ], + "type": "object" + }, + "WorkspaceConfigTreeResponse": { + "additionalProperties": false, + "description": "Workspace configuration tree plus the exact evaluation contract that produced it.", + "properties": { + "contract": { + "$ref": "#/components/schemas/ToolchainContract" + }, + "projection_digest": { + "type": "string" + }, + "snapshot": { + "$ref": "#/components/schemas/ConfigTreeSnapshot" + } + }, + "required": [ + "snapshot", + "contract", + "projection_digest" + ], + "type": "object" + }, + "WorkspaceCreateRequest": { + "additionalProperties": false, + "description": "Request for atomically creating a Workspace and its initial Repository.", + "properties": { + "display_name": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "repository": { + "$ref": "#/components/schemas/InitialRepositoryIntent" + } + }, + "required": [ + "operation_key", + "display_name", + "repository" + ], + "type": "object" + }, + "WorkspaceCreateResponse": { + "additionalProperties": false, + "description": "Response returned after atomically creating a Workspace and its first Repository.", + "properties": { + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "replayed": { + "type": "boolean" + }, + "repository": { + "$ref": "#/components/schemas/WorkspaceRepositoryRecord" + }, + "request_fingerprint": { + "type": "string" + }, + "workspace": { + "$ref": "#/components/schemas/WorkspaceSummary" + } + }, + "required": [ + "workspace", + "repository", + "config_revision", + "request_fingerprint", + "replayed" + ], + "type": "object" + }, + "WorkspaceDeletionBlocker": { + "additionalProperties": false, + "description": "One bounded, user-actionable blocker returned by preflight or execution.", + "properties": { + "kind": { + "$ref": "#/components/schemas/WorkspaceDeletionBlockerKind" + }, + "message": { + "type": "string" + }, + "resource_key": { + "type": [ + "string", + "null" + ] + }, + "resource_kind": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "message" + ], + "type": "object" + }, + "WorkspaceDeletionBlockerKind": { + "description": "Stable category explaining why Workspace deletion cannot currently advance.", + "enum": [ + "last_accessible_workspace", + "revision_conflict", + "dirty_workdir", + "worker_removal_blocked", + "workdir_removal_blocked", + "retention_hold", + "cleanup_unavailable" + ], + "type": "string" + }, + "WorkspaceDeletionOperationResponse": { + "additionalProperties": false, + "description": "Durable deletion operation projection used by request responses and polling.", + "properties": { + "blockers": { + "items": { + "$ref": "#/components/schemas/WorkspaceDeletionBlocker" + }, + "type": "array" + }, + "child_operation_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "completed_at": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "failure_category": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "resources": { + "$ref": "#/components/schemas/WorkspaceDeletionResourceCounts" + }, + "state": { + "$ref": "#/components/schemas/WorkspaceDeletionState" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "workspace_id", + "display_name", + "state", + "resources", + "child_operation_ids", + "blockers", + "created_at", + "updated_at" + ], + "type": "object" + }, + "WorkspaceDeletionPreflightResponse": { + "additionalProperties": false, + "description": "Owner-only impact preview for deleting one Workspace.", + "properties": { + "blockers": { + "items": { + "$ref": "#/components/schemas/WorkspaceDeletionBlocker" + }, + "type": "array" + }, + "can_delete": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "expected_revision": { + "description": "Opaque persisted Workspace metadata revision used as a CAS fence.", + "type": "string" + }, + "resources": { + "$ref": "#/components/schemas/WorkspaceDeletionResourceCounts" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "display_name", + "expected_revision", + "can_delete", + "resources", + "blockers" + ], + "type": "object" + }, + "WorkspaceDeletionRequest": { + "additionalProperties": false, + "description": "Idempotent request to start or resume Workspace deletion.", + "properties": { + "confirmation": { + "type": "string" + }, + "expected_revision": { + "type": "string" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "expected_revision", + "confirmation" + ], + "type": "object" + }, + "WorkspaceDeletionResourceCounts": { + "additionalProperties": false, + "description": "Workspace-owned resources summarized before destructive confirmation.", + "properties": { + "artifacts": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "repositories": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "runtime_bindings": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "secrets": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workdirs": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workers": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "workers", + "workdirs", + "repositories", + "runtime_bindings", + "secrets", + "artifacts" + ], + "type": "object" + }, + "WorkspaceDeletionState": { + "description": "Lifecycle state for one durable Workspace deletion operation.", + "enum": [ + "queued", + "running", + "blocked", + "failed", + "succeeded" + ], + "type": "string" + }, + "WorkspaceExtensionPointState": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "note": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "status", + "note", + "diagnostics" + ], + "type": "object" + }, + "WorkspaceExtensionPoints": { + "additionalProperties": false, + "properties": { + "companion_console": { + "$ref": "#/components/schemas/WorkspaceExtensionPointState" + }, + "event_stream": { + "$ref": "#/components/schemas/WorkspaceExtensionPointState" + }, + "host_worker_bridge": { + "$ref": "#/components/schemas/WorkspaceExtensionPointState" + }, + "store": { + "type": "string" + } + }, + "required": [ + "store", + "event_stream", + "host_worker_bridge", + "companion_console" + ], + "type": "object" + }, + "WorkspaceListQuery": { + "additionalProperties": false, + "properties": { + "limit": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "WorkspaceMemorySettings": { + "additionalProperties": false, + "description": "Workspace-owned Memory settings returned by the shared Server API.", + "properties": { + "language": { + "type": "string" + }, + "settings_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "settings_revision", + "language" + ], + "type": "object" + }, + "WorkspaceMetadataMutationResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "workspace": { + "$ref": "#/components/schemas/WorkspaceMetadataSettingsResponse" + } + }, + "required": [ + "workspace", + "diagnostics" + ], + "type": "object" + }, + "WorkspaceMetadataSettingsResponse": { + "additionalProperties": false, + "description": "Workspace display metadata exposed from the Server DB settings authority.", + "properties": { + "created_at": { + "type": "string" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "display_name": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "display_name", + "created_at", + "revision", + "source", + "diagnostics" + ], + "type": "object" + }, + "WorkspacePermissionSummary": { + "additionalProperties": false, + "description": "Backend-authoritative permissions for the current Workspace actor.", + "properties": { + "delete_workspace": { + "type": "boolean" + }, + "manage_repositories": { + "type": "boolean" + }, + "manage_runtimes": { + "type": "boolean" + }, + "manage_secrets": { + "type": "boolean" + } + }, + "required": [ + "manage_repositories", + "manage_secrets", + "manage_runtimes", + "delete_workspace" + ], + "type": "object" + }, + "WorkspaceProfileSourceProvenance": { + "enum": [ + "project_profile_source_tree" + ], + "type": "string" + }, + "WorkspaceProfileSourceSummary": { + "additionalProperties": false, + "properties": { + "content_digest": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "display_path": { + "type": "string" + }, + "editable": { + "type": "boolean" + }, + "kind": { + "type": "string" + }, + "profile_source_id": { + "type": "string" + }, + "provenance": { + "$ref": "#/components/schemas/WorkspaceProfileSourceProvenance" + }, + "revision": { + "type": "string" + }, + "size_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "profile_source_id", + "display_path", + "kind", + "content_type", + "content_digest", + "provenance", + "editable", + "revision", + "size_bytes", + "diagnostics" + ], + "type": "object" + }, + "WorkspaceProfileSummary": { + "additionalProperties": false, + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "editable": { + "type": "boolean" + }, + "is_default": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "profile_id": { + "type": "string" + }, + "profile_source_id": { + "type": [ + "string", + "null" + ] + }, + "selector": { + "type": "string" + }, + "source_kind": { + "type": "string" + } + }, + "required": [ + "profile_id", + "selector", + "label", + "source_kind", + "editable", + "is_default", + "diagnostics" + ], + "type": "object" + }, + "WorkspacePromptProjection": { + "additionalProperties": false, + "description": "Effective Prompt catalog projected from one immutable Workspace config revision.", + "properties": { + "catalog": { + "$ref": "#/components/schemas/EffectivePromptCatalog" + }, + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "projection_digest": { + "type": "string" + }, + "schema_fingerprint": { + "type": "string" + }, + "source_digest": { + "type": "string" + }, + "toolchain_fingerprint": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "config_revision", + "source_digest", + "projection_digest", + "schema_fingerprint", + "toolchain_fingerprint", + "catalog" + ], + "type": "object" + }, + "WorkspacePublicIdentityBundle": { + "additionalProperties": false, + "description": "Copyable public trust bundle consumed by future Runtime enrollment work.", + "properties": { + "algorithm": { + "type": "string" + }, + "backend_url": { + "type": "string" + }, + "key_id": { + "type": "string" + }, + "public_key": { + "type": "string" + }, + "public_key_fingerprint": { + "type": "string" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "backend_url", + "key_id", + "algorithm", + "public_key", + "public_key_fingerprint", + "revision" + ], + "type": "object" + }, + "WorkspaceRepositoryRecord": { + "additionalProperties": false, + "description": "Public Repository record embedded in Workspace creation responses.", + "properties": { + "created_at": { + "type": "string" + }, + "default_ref": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string" + }, + "observed_at": { + "type": [ + "string", + "null" + ] + }, + "observed_status": { + "$ref": "#/components/schemas/RepositoryObservedStatus" + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/RepositorySource" + }, + "source_fingerprint": { + "type": "string" + }, + "source_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "repository_key", + "kind", + "source", + "source_revision", + "source_fingerprint", + "observed_status", + "created_at", + "updated_at" + ], + "type": "object" + }, + "WorkspaceResponse": { + "additionalProperties": false, + "description": "Scoped Workspace metadata and current-actor permission projection.", + "properties": { + "auth": { + "$ref": "#/components/schemas/WorkspaceAuthConfig" + }, + "display_name": { + "type": "string" + }, + "extension_points": { + "$ref": "#/components/schemas/WorkspaceExtensionPoints" + }, + "permissions": { + "$ref": "#/components/schemas/WorkspacePermissionSummary" + }, + "record_authority": { + "type": "string" + }, + "schema_version": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "display_name", + "record_authority", + "schema_version", + "auth", + "permissions", + "extension_points" + ], + "type": "object" + }, + "WorkspaceRuntimeBindingState": { + "enum": [ + "configured", + "verified", + "revoked" + ], + "type": "string" + }, + "WorkspaceRuntimeBindingSummary": { + "additionalProperties": false, + "properties": { + "connection_state": { + "$ref": "#/components/schemas/RuntimeConnectionDisplayState" + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/WorkspaceRuntimeBindingState" + }, + "verification": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeVerificationEvidenceSummary" + }, + { + "type": "null" + } + ] + }, + "workspace_key_generation": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "workspace_key_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "state", + "connection_state", + "revision" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigDeclarationKind" + }, + "name": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "kind", + "name", + "reference" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigDeclarationKind": { + "enum": [ + "secret_ref", + "mount_grant", + "network_policy", + "shell_policy", + "git_policy", + "capability_grant", + "unsupported" + ], + "type": "string" + }, + "WorkspaceRuntimeConfigMetadata": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "id": { + "type": "string" + }, + "provenance": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigProvenance" + }, + "revision": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "id", + "digest", + "revision", + "workspace_id", + "created_at", + "provenance" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigProfile": { + "additionalProperties": false, + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "selector": { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSelector" + } + }, + "required": [ + "selector" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigProvenance": { + "additionalProperties": false, + "properties": { + "detail": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "source" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigQuery": { + "additionalProperties": false, + "properties": { + "profile": { + "type": "string" + } + }, + "required": [ + "profile" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigResponse": { + "additionalProperties": false, + "description": "Canonical Runtime config-bundle response projected by Workspace authority.\n\nThis is intentionally a concrete transport DTO rather than an opaque JSON value so generated\nclients and OpenAPI consumers receive the same complete wire contract as the Runtime.", + "properties": { + "declarations": { + "items": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigDeclaration" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigMetadata" + }, + "profile_source_archive": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSourceArchive" + }, + { + "type": "null" + } + ] + }, + "profile_source_archive_handle": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimeResourceHandle" + }, + { + "type": "null" + } + ] + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigProfile" + }, + "type": "array" + }, + "prompt_catalog": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimePromptCatalog" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "metadata" + ], + "type": "object" + }, + "WorkspaceRuntimeDetail": { + "additionalProperties": false, + "properties": { + "endpoint": { + "type": [ + "string", + "null" + ] + }, + "recent_audit": { + "default": [], + "items": { + "$ref": "#/components/schemas/RuntimeTrustAuditEntry" + }, + "type": "array" + }, + "runtime": { + "$ref": "#/components/schemas/WorkspaceRuntimeResource" + }, + "trust_key": { + "$ref": "#/components/schemas/RuntimeTrustKeyState" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "runtime", + "trust_key" + ], + "type": "object" + }, + "WorkspaceRuntimeListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/WorkspaceRuntimeResource" + }, + "type": "array" + }, + "limit": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "limit", + "items", + "source" + ], + "type": "object" + }, + "WorkspaceRuntimeProfileSelector": { + "oneOf": [ + { + "properties": { + "kind": { + "const": "builtin", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "named", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "WorkspaceRuntimeProfileSourceArchive": { + "additionalProperties": false, + "properties": { + "content": { + "items": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "reference": { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSourceArchiveRef" + } + }, + "required": [ + "reference", + "content" + ], + "type": "object" + }, + "WorkspaceRuntimeProfileSourceArchiveRef": { + "additionalProperties": false, + "properties": { + "digest": { + "type": "string" + }, + "id": { + "type": "string" + }, + "size_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source_graph": { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSourceGraph" + } + }, + "required": [ + "id", + "digest", + "size_bytes", + "source_graph" + ], + "type": "object" + }, + "WorkspaceRuntimeProfileSourceGraph": { + "additionalProperties": false, + "properties": { + "entrypoints": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "import_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "total_source_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "source_count", + "total_source_bytes", + "entrypoints", + "import_count" + ], + "type": "object" + }, + "WorkspaceRuntimePromptCatalog": { + "additionalProperties": false, + "properties": { + "catalog_digest": { + "type": "string" + }, + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "schema_fingerprint": { + "type": "string" + }, + "source_digest": { + "type": "string" + }, + "templates": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "toolchain_fingerprint": { + "type": "string" + } + }, + "required": [ + "templates", + "config_revision", + "schema_fingerprint", + "toolchain_fingerprint", + "catalog_digest" + ], + "type": "object" + }, + "WorkspaceRuntimeResource": { + "properties": { + "arch": { + "type": "string" + }, + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "host_ids": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "management": { + "$ref": "#/components/schemas/RuntimeManagementSummary" + }, + "os": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/RuntimeSourceSummary" + }, + "status": { + "type": "string" + }, + "worker_creation_available": { + "type": "boolean" + } + }, + "required": [ + "runtime_id", + "label", + "kind", + "status", + "source", + "worker_creation_available", + "os", + "arch", + "management" + ], + "type": "object" + }, + "WorkspaceRuntimeResourceHandle": { + "additionalProperties": false, + "properties": { + "audit_correlation_id": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "expires_at_unix_seconds": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "generation": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/WorkspaceRuntimeResourceKind" + }, + "max_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "nonce": { + "type": "string" + }, + "operation": { + "$ref": "#/components/schemas/WorkspaceRuntimeResourceOperation" + }, + "profile_source_graph": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSourceGraph" + }, + { + "type": "null" + } + ] + }, + "redaction": { + "$ref": "#/components/schemas/WorkspaceRuntimeResourceRedactionPolicy" + }, + "resource_id": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "runtime_id": { + "type": [ + "string", + "null" + ] + }, + "scope_id": { + "type": [ + "string", + "null" + ] + }, + "worker_id": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "resource_id", + "digest", + "operation", + "expires_at_unix_seconds", + "nonce", + "revision", + "max_bytes", + "content_type", + "redaction", + "audit_correlation_id" + ], + "type": "object" + }, + "WorkspaceRuntimeResourceKind": { + "enum": [ + "profile_source_archive", + "repository_ssh_access" + ], + "type": "string" + }, + "WorkspaceRuntimeResourceOperation": { + "enum": [ + "fetch_archive", + "fetch_once" + ], + "type": "string" + }, + "WorkspaceRuntimeResourceRedactionPolicy": { + "enum": [ + "runtime_internal_only" + ], + "type": "string" + }, + "WorkspaceSigningIdentityPublic": { + "additionalProperties": false, + "description": "Public metadata for a Workspace signing identity. Private material and its\nstorage reference are deliberately not part of this wire authority.", + "properties": { + "algorithm": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "key_id": { + "type": "string" + }, + "provisioned_at": { + "type": [ + "string", + "null" + ] + }, + "public_key": { + "type": [ + "string", + "null" + ] + }, + "public_key_fingerprint": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/WorkspaceSigningIdentityState" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "key_id", + "algorithm", + "revision", + "state", + "created_at" + ], + "type": "object" + }, + "WorkspaceSigningIdentityResponse": { + "additionalProperties": false, + "properties": { + "identity": { + "$ref": "#/components/schemas/WorkspaceSigningIdentityPublic" + }, + "public_bundle": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspacePublicIdentityBundle" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "identity" + ], + "type": "object" + }, + "WorkspaceSigningIdentityState": { + "description": "Lifecycle state for a Workspace-scoped Ed25519 signing identity.", + "enum": [ + "pending_provisioning", + "active" + ], + "type": "string" + }, + "WorkspaceSummary": { + "additionalProperties": false, + "description": "Public Workspace catalog item returned by `GET /api/workspaces`.", + "properties": { + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "owner_account_id": { + "type": "string" + }, + "state": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "owner_account_id", + "display_name", + "state", + "created_at", + "updated_at" + ], + "type": "object" + }, + "WorkspaceWorkerDiscoveryItem": { + "additionalProperties": false, + "description": "Bounded, model-safe projection used by privileged Workspace Worker discovery.\nRuntime placement appears only in the typed subject required by Worker\ncontrol operations; provider and launch internals are intentionally omitted.", + "properties": { + "display_name": { + "type": "string" + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "resource_key": { + "type": "string" + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "subject": { + "$ref": "#/components/schemas/WorkspaceWorkerSubject" + } + }, + "required": [ + "subject", + "resource_key", + "display_name" + ], + "type": "object" + }, + "WorkspaceWorkerSubject": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "runtime_worker", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id" + ], + "type": "object" + } + ] + }, "string": { "type": "string" } @@ -353,10 +13901,1186 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef" + "x-yoi-source-digest": "sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c" }, "openapi": "3.1.0", "paths": { + "/api/auth/bootstrap-user": { + "post": { + "operationId": "auth_bootstrap_user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthBootstrapUserRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthUserResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/config": { + "get": { + "operationId": "auth_config", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthPublicConfig" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/device-login/approve": { + "post": { + "operationId": "auth_device_login_approve", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginApproveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginApproveResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/auth/device-login/poll": { + "post": { + "operationId": "auth_device_login_poll", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginPollRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginPollResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/device-login/start": { + "post": { + "operationId": "auth_device_login_start", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginStartRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceLoginStartResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/logout": { + "post": { + "operationId": "auth_logout", + "parameters": [ + { + "explode": false, + "in": "header", + "name": "cookie", + "required": false, + "schema": { + "$ref": "#/components/schemas/Nullable_string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + }, + "description": "Successful response", + "headers": { + "set-cookie": { + "schema": { + "$ref": "#/components/schemas/SetCookieHeader" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/passkeys/login/complete": { + "post": { + "operationId": "auth_passkey_login_complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthUserResponse" + } + } + }, + "description": "Successful response", + "headers": { + "set-cookie": { + "schema": { + "$ref": "#/components/schemas/SetCookieHeader" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/passkeys/login/options": { + "post": { + "operationId": "auth_passkey_login_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginOptionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginOptionsResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/passkeys/registration/complete": { + "post": { + "operationId": "auth_passkey_registration_complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRegistrationCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthUserResponse" + } + } + }, + "description": "Successful response", + "headers": { + "set-cookie": { + "schema": { + "$ref": "#/components/schemas/SetCookieHeader" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/passkeys/registration/options": { + "post": { + "operationId": "auth_passkey_registration_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRegistrationOptionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRegistrationOptionsResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/whoami": { + "get": { + "operationId": "auth_whoami", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoamiResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/companion/cancel": { + "post": { + "operationId": "companion_cancel_alias", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionCancelRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionMessageResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/companion/messages": { + "post": { + "operationId": "companion_message_alias", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionMessageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionMessageResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/companion/status": { + "get": { + "operationId": "companion_status_alias", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionStatusResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/companion/transcript": { + "get": { + "operationId": "companion_transcript_alias", + "parameters": [ + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/CompanionTranscriptQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTranscriptProjection" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/hosts": { + "get": { + "operationId": "host_list_alias", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostListResponse" + } + } + }, + "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/hosts/{host_id}/workers": { + "get": { + "operationId": "host_worker_list_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "host_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostWorkerListResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/objectives": { + "get": { + "operationId": "objective_list_alias", + "parameters": [ + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/ObjectiveListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "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/objectives/{id}": { + "get": { + "operationId": "objective_get_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveDetail" + } + } + }, + "description": "Successful response" + }, + "400": { + "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": { "get": { "operationId": "repository_list_alias", @@ -519,6 +15243,5175 @@ ] } }, + "/api/repositories/{repository_key}/log": { + "get": { + "operationId": "repository_log_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "repository_key", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/RepositoryLogQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryLogResponse" + } + } + }, + "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/runtimes": { + "get": { + "operationId": "runtime_list_alias", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeListResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/config-bundles": { + "post": { + "operationId": "runtime_config_bundle_sync_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeConfigBundleSyncRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeConfigBundleResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability": { + "get": { + "operationId": "runtime_config_bundle_availability_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "bundle_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/RuntimeConfigBundleAvailabilityQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeConfigBundleResult" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/workers": { + "get": { + "operationId": "runtime_worker_list_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/RuntimeWorkersQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerListResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "runtime_worker_create_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/workers/{worker_id}": { + "get": { + "operationId": "runtime_worker_detail_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerShowResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/workers/{worker_id}/cancel": { + "post": { + "operationId": "runtime_worker_cancel_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerLifecycleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerLifecycleResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/workers/{worker_id}/completions": { + "post": { + "operationId": "runtime_worker_completions_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerCompletionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerCompletionsResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/workers/{worker_id}/input": { + "post": { + "operationId": "runtime_worker_input_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerInputRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerInputResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/workers/{worker_id}/restore": { + "post": { + "operationId": "runtime_worker_restore_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerRestoreResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/runtimes/{runtime_id}/workers/{worker_id}/stop": { + "post": { + "operationId": "runtime_worker_stop_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerLifecycleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerLifecycleResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/tickets": { + "get": { + "operationId": "ticket_list_alias", + "parameters": [ + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketListHttpQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "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/tickets/{id}": { + "get": { + "operationId": "ticket_get_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "description": "Successful response" + }, + "400": { + "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}/companion/cancel": { + "post": { + "operationId": "companion_cancel", + "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/CompanionCancelRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionMessageResponse" + } + } + }, + "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}/companion/messages": { + "post": { + "operationId": "companion_message", + "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/CompanionMessageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionMessageResponse" + } + } + }, + "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}/companion/status": { + "get": { + "operationId": "companion_status", + "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/CompanionStatusResponse" + } + } + }, + "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}/companion/transcript": { + "get": { + "operationId": "companion_transcript", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/CompanionTranscriptQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTranscriptProjection" + } + } + }, + "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}/config/projections/prompts": { + "get": { + "operationId": "workspace_prompt_projection", + "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/WorkspacePromptProjection" + } + } + }, + "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}/config/source-tree": { + "get": { + "operationId": "workspace_config_tree", + "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/WorkspaceConfigTreeResponse" + } + } + }, + "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}/config/source-tree/commit": { + "post": { + "operationId": "workspace_config_tree_commit", + "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/ConfigCommitRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigTreeResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/config/source-tree/entries/{path}": { + "get": { + "operationId": "workspace_config_entry", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigEntry" + } + } + }, + "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}/config/source-tree/revisions/{revision}": { + "get": { + "operationId": "workspace_config_revision", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "revision", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigTreeSnapshot" + } + } + }, + "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}/external-workdir-grants": { + "post": { + "operationId": "external_workdir_grant_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/ExternalWorkdirGrantCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalWorkdirGrantResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}": { + "delete": { + "operationId": "external_workdir_grant_revoke", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "grant_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalWorkdirGrantResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "get": { + "operationId": "external_workdir_grant_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "grant_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalWorkdirGrantResponse" + } + } + }, + "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}/flows": { + "get": { + "operationId": "flow_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/Array_of_FlowSourceRecord" + } + } + }, + "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": [] + } + ] + }, + "put": { + "operationId": "flow_put", + "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/PutFlowRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowSourceRecord" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/flows/resolve": { + "post": { + "operationId": "flow_resolve", + "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/FlowSourceResolveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolvedFlowSource" + } + } + }, + "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}/flows/{flow_id}": { + "get": { + "operationId": "flow_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "flow_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowSourceRecord" + } + } + }, + "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}/hosts": { + "get": { + "operationId": "host_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/HostListResponse" + } + } + }, + "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}/hosts/{host_id}/workers": { + "get": { + "operationId": "host_worker_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "host_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostWorkerListResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/memory": { + "get": { + "operationId": "memory_document", + "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/MemoryDocumentResponse" + } + } + }, + "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}/memory/backend": { + "post": { + "operationId": "memory_backend", + "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/MemoryBackendRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryBackendResponse" + } + } + }, + "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}/memory/consolidation": { + "post": { + "operationId": "memory_consolidation", + "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/MemoryConsolidateStagingRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryConsolidationResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/memory/staging": { + "get": { + "operationId": "memory_staging_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MemoryStagingQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryStagingListResponse" + } + } + }, + "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}/merge-requests": { + "get": { + "operationId": "merge_request_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MergeRequestListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeRequestListResponse" + } + } + }, + "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}/merge-requests/{merge_request_id}": { + "get": { + "operationId": "merge_request_show", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "merge_request_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MergeRequestThreadQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeRequestDetailResponse" + } + } + }, + "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}/objectives": { + "get": { + "operationId": "objective_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/ObjectiveListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveListResponse" + } + } + }, + "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": "objective_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/ObjectiveCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/objectives/query": { + "post": { + "operationId": "objective_query", + "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/ObjectiveQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveQueryResponse" + } + } + }, + "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}/objectives/{objective_id}": { + "get": { + "operationId": "objective_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "objective_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveDetail" + } + } + }, + "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": [] + } + ] + }, + "patch": { + "operationId": "objective_edit", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "objective_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveEditRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/objectives/{objective_id}/show": { + "post": { + "operationId": "objective_show", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "objective_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveShowRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveDetail" + } + } + }, + "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}/objectives/{objective_id}/state": { + "post": { + "operationId": "objective_state_set", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "objective_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveStateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links": { + "post": { + "operationId": "objective_ticket_link", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "objective_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveLinkTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}": { + "delete": { + "operationId": "objective_ticket_unlink", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "objective_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectiveDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/orchestrator": { + "get": { + "operationId": "workspace_orchestrator_status", + "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/BrowserWorkspaceOrchestratorResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_orchestrator_start", + "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/BrowserWorkspaceOrchestratorResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/repositories": { "get": { "operationId": "repository_list", @@ -847,6 +20740,12743 @@ } ] } + }, + "/api/w/{workspace_id}/repositories/{repository_key}/log": { + "get": { + "operationId": "repository_log", + "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" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/RepositoryLogQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryLogResponse" + } + } + }, + "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/{repository_key}/ssh-connection-test": { + "post": { + "operationId": "repository_ssh_connection_probe", + "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" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshConnectionProbeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshConnectionProbeResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "put": { + "operationId": "repository_ssh_host_trust_confirm", + "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" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmRepositorySshHostTrustRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshHostTrust" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtime-config": { + "get": { + "operationId": "workspace_runtime_config", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigQuery" + }, + "style": "form" + }, + { + "explode": false, + "in": "header", + "name": "if-none-match", + "required": false, + "schema": { + "$ref": "#/components/schemas/Nullable_string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigResponse" + } + } + }, + "description": "Successful response", + "headers": { + "cache-control": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "etag": { + "schema": { + "$ref": "#/components/schemas/string" + } + } + } + }, + "304": { + "description": "Alternate successful response", + "headers": { + "cache-control": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "etag": { + "schema": { + "$ref": "#/components/schemas/string" + } + } + } + }, + "400": { + "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" + } + } + } + }, + "/api/w/{workspace_id}/runtimes": { + "get": { + "operationId": "runtime_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/WorkspaceRuntimeListResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "runtime_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/CreateRemoteRuntimeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeResource" + } + } + }, + "description": "Alternate successful response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeResource" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}": { + "delete": { + "operationId": "runtime_remove", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveRuntimeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeRemovalOperationResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "get": { + "operationId": "runtime_detail", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeDetail" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "runtime_update", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRemoteRuntimeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-executions": { + "post": { + "operationId": "runtime_cleanup_execute", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteRuntimeCleanupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeCleanupExecutionResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-plan": { + "get": { + "operationId": "runtime_cleanup_plan", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeCleanupPlanResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles": { + "post": { + "operationId": "runtime_config_bundle_sync", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeConfigBundleSyncRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeConfigBundleResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability": { + "get": { + "operationId": "runtime_config_bundle_availability", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "bundle_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/RuntimeConfigBundleAvailabilityQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeConfigBundleResult" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests": { + "post": { + "operationId": "runtime_connection_test", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeConnectionTestResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key": { + "delete": { + "operationId": "runtime_trust_key_revoke", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeRuntimeTrustKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeDetail" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeManagementApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeManagementApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeManagementApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeManagementApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeManagementApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeManagementApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "get": { + "operationId": "runtime_trust_key_reveal", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeTrustKeyRevealResponse" + } + } + }, + "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}/runtimes/{runtime_id}/workers": { + "get": { + "operationId": "runtime_worker_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/RuntimeWorkersQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerListResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "runtime_worker_create", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerSpawnResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}": { + "get": { + "operationId": "runtime_worker_detail", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerShowResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-upload-grants": { + "post": { + "operationId": "runtime_worker_attachment_upload_grant", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/WorkerFileUploadQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachmentUploadGrantResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}": { + "delete": { + "operationId": "runtime_worker_attachment_upload_cancel", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "upload_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachmentUploadCancelResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "put": { + "operationId": "runtime_worker_attachment_upload", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "upload_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerFileUploadResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachments/{artifact_id}": { + "delete": { + "operationId": "runtime_worker_attachment_delete", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "artifact_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerFileDeleteResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/cancel": { + "post": { + "operationId": "runtime_worker_cancel", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerLifecycleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerLifecycleResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/completions": { + "post": { + "operationId": "runtime_worker_completions", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerCompletionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerCompletionsResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/input": { + "post": { + "operationId": "runtime_worker_input", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerInputRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerInputResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/pin": { + "delete": { + "operationId": "runtime_worker_unpin", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerRetentionResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "put": { + "operationId": "runtime_worker_pin", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerRetentionResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore": { + "post": { + "operationId": "runtime_worker_restore", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/RestoreTicketAssignmentQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerRestoreResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/stop": { + "post": { + "operationId": "runtime_worker_stop", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerLifecycleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeWorkerLifecycleResult" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories": { + "get": { + "operationId": "runtime_workdir_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryListResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "runtime_workdir_create", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryCreateResponse" + } + } + }, + "description": "Alternate successful response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryCreateResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}": { + "delete": { + "operationId": "runtime_workdir_cleanup", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "working_directory_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryRemovalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryRemovalResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "get": { + "operationId": "runtime_workdir_detail", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "working_directory_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryDetailResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/settings": { + "get": { + "operationId": "workspace_metadata_settings", + "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/WorkspaceMetadataSettingsResponse" + } + } + }, + "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": [] + } + ] + }, + "put": { + "operationId": "workspace_metadata_settings_update", + "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/UpdateWorkspaceMetadataRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceMetadataMutationResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/settings/memory": { + "get": { + "operationId": "workspace_memory_settings", + "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/WorkspaceMemorySettings" + } + } + }, + "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": [] + } + ] + }, + "put": { + "operationId": "workspace_memory_settings_update", + "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/UpdateWorkspaceMemorySettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceMemorySettings" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/settings/profiles": { + "get": { + "operationId": "profile_settings", + "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/ProfileSettingsResponse" + } + } + }, + "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}/settings/repository-access": { + "get": { + "operationId": "repository_access_projection", + "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/RepositoryAccessProjection" + } + } + }, + "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}/settings/repository-access/credentials": { + "get": { + "operationId": "repository_ssh_credential_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/Array_of_RepositorySshCredential" + } + } + }, + "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_ssh_credential_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/CreateRepositorySshCredentialRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshCredential" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/settings/repository-access/credentials/generate": { + "post": { + "operationId": "repository_ssh_credential_generate", + "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/GenerateRepositorySshCredentialRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshCredential" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}": { + "delete": { + "operationId": "repository_ssh_credential_delete", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "credential_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRepositorySshCredentialRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "get": { + "operationId": "repository_ssh_credential_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "credential_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshCredential" + } + } + }, + "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}/settings/repository-access/credentials/{credential_id}/public-key": { + "get": { + "operationId": "repository_ssh_credential_public_key", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "credential_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshPublicKey" + } + } + }, + "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}/settings/repository-access/credentials/{credential_id}/rotate": { + "post": { + "operationId": "repository_ssh_credential_rotate", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "credential_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateRepositorySshCredentialRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshCredential" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/settings/repository-access/host-trusts": { + "get": { + "operationId": "repository_ssh_host_trust_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/Array_of_RepositorySshHostTrust" + } + } + }, + "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_ssh_host_trust_put", + "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/PutRepositorySshHostTrustRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshHostTrustMutationResponse" + } + } + }, + "description": "Alternate successful response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshHostTrustMutationResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/settings/repository-access/host-trusts/{host_trust_id}": { + "delete": { + "operationId": "repository_ssh_host_trust_delete", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "host_trust_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRepositorySshHostTrustRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "get": { + "operationId": "repository_ssh_host_trust_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "host_trust_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySshHostTrust" + } + } + }, + "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}/settings/signing-identity": { + "get": { + "operationId": "workspace_signing_identity", + "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/WorkspaceSigningIdentityResponse" + } + } + }, + "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}/settings/signing-identity/provision": { + "post": { + "operationId": "workspace_signing_identity_provision", + "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/WorkspaceSigningIdentityResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/skills": { + "get": { + "operationId": "skill_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/SkillCatalogResponse" + } + } + }, + "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}/skills/lint": { + "get": { + "operationId": "skill_lint", + "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/SkillCatalogResponse" + } + } + }, + "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}/skills/{name}": { + "get": { + "operationId": "skill_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "name", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillDetailResponse" + } + } + }, + "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}/skills/{name}/activate": { + "get": { + "operationId": "skill_activate", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "name", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillActivationResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets": { + "get": { + "operationId": "ticket_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketListHttpQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketListResponse" + } + } + }, + "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": "ticket_create_record", + "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/NewTicket" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketRef" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/default-intake-ready-body": { + "post": { + "operationId": "ticket_default_intake_ready_body", + "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/DefaultIntakeReadyBodyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/string" + } + } + }, + "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}/tickets/doctor": { + "get": { + "operationId": "ticket_doctor", + "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/TicketDoctorReport" + } + } + }, + "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}/tickets/orchestration-plans/search": { + "post": { + "operationId": "ticket_orchestration_plan_query", + "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/TicketOrchestrationPlanSearchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_OrchestrationPlanRecord" + } + } + }, + "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}/tickets/query": { + "post": { + "operationId": "ticket_query", + "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/TicketQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketQueryResponse" + } + } + }, + "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}/tickets/relations/search": { + "post": { + "operationId": "ticket_relation_query", + "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/TicketRelationSearchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_TicketRelation" + } + } + }, + "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}/tickets/search": { + "get": { + "operationId": "ticket_summary_search", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketSummarySearchQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_TicketSummary" + } + } + }, + "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}/tickets/{id}": { + "get": { + "operationId": "ticket_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "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": [] + } + ] + }, + "patch": { + "operationId": "ticket_edit", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserEditTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/assignments": { + "get": { + "operationId": "ticket_assignment_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketRoleAssignmentsResponse" + } + } + }, + "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}/tickets/{id}/assignments/{role}": { + "delete": { + "operationId": "ticket_assignment_clear", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "role", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/ClearTicketRoleAssignmentQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketRoleAssignmentMutationResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "put": { + "operationId": "ticket_assignment_set", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "role", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetTicketRoleAssignmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketRoleAssignmentMutationResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/close": { + "post": { + "operationId": "ticket_close", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserCloseTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/dependency-check": { + "get": { + "operationId": "ticket_dependency_check", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDependencyCheck" + } + } + }, + "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}/tickets/{id}/events": { + "post": { + "operationId": "ticket_event_append", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserAppendTicketEventRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations": { + "post": { + "operationId": "ticket_implementation_cancel", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelTicketImplementationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/intake-summaries": { + "post": { + "operationId": "ticket_intake_summary_add", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketIntakeSummary" + } + } + }, + "required": true + }, + "responses": { + "204": { + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/item": { + "patch": { + "operationId": "ticket_record_item_edit", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketItemEdit" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ticket" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/merge-request/readiness": { + "get": { + "operationId": "merge_request_readiness", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeRequestReadinessResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/merge-request/repair-source": { + "post": { + "operationId": "merge_request_selector_repair", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepairMergeRequestSelectorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicMergeRequest" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/merge-request/thread": { + "get": { + "operationId": "merge_request_thread", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MergeRequestThreadQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_MergeRequestThreadEvent" + } + } + }, + "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}/tickets/{id}/orchestration-plans": { + "post": { + "operationId": "ticket_orchestration_plan_record", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewOrchestrationPlanRecord" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrchestrationPlanRecord" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/queue": { + "post": { + "operationId": "ticket_queue", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserQueueTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketQueueOutcome" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/ready": { + "post": { + "operationId": "ticket_ready", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketMarkReadyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/record": { + "get": { + "operationId": "ticket_record_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ticket" + } + } + }, + "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}/tickets/{id}/relation-view": { + "get": { + "operationId": "ticket_relation_view", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketRelationView" + } + } + }, + "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}/tickets/{id}/relations": { + "delete": { + "operationId": "ticket_relation_remove", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketRelationRemoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketRelation" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "ticket_relation_record", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewTicketRelation" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketRelation" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/show": { + "post": { + "operationId": "ticket_show", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketShowRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "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}/tickets/{id}/state": { + "post": { + "operationId": "ticket_state_transition", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserTransitionTicketStateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/state-changes": { + "post": { + "operationId": "ticket_state_change_add", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketStateChange" + } + } + }, + "required": true + }, + "responses": { + "204": { + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/state-fields/{field}": { + "post": { + "operationId": "ticket_state_field_set", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "field", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketStateChange" + } + } + }, + "required": true + }, + "responses": { + "204": { + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/thread-events": { + "post": { + "operationId": "ticket_thread_event_add", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewTicketEvent" + } + } + }, + "required": true + }, + "responses": { + "204": { + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/workflow-state": { + "post": { + "operationId": "ticket_workflow_state_set", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketStateChange" + } + } + }, + "required": true + }, + "responses": { + "204": { + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/workflow/close": { + "post": { + "operationId": "ticket_close_record", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkdownText" + } + } + }, + "required": true + }, + "responses": { + "204": { + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready": { + "post": { + "operationId": "ticket_mark_ready_record", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketMarkReadyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ticket" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{id}/workflow/queue": { + "post": { + "operationId": "ticket_queue_record", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketQueueOutcome" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/workers": { + "get": { + "operationId": "workspace_worker_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/WorkerListResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_worker_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/CreateWorkspaceWorkerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserCreateWorkerResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/workers/launch-options": { + "get": { + "operationId": "workspace_worker_launch_options", + "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/WorkerLaunchOptionsResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/workers/{worker_ref}": { + "get": { + "operationId": "workspace_worker_detail", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_ref", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerSummary" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/working-directories": { + "get": { + "operationId": "workspace_workdir_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/WorkingDirectoryListResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_workdir_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/WorkingDirectoryCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryCreateResponse" + } + } + }, + "description": "Alternate successful response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryCreateResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/working-directories/{working_directory_id}": { + "delete": { + "operationId": "workspace_workdir_cleanup", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "working_directory_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryRemovalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryRemovalResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "get": { + "operationId": "workspace_workdir_detail", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "working_directory_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkingDirectoryDetailResponse" + } + } + }, + "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" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/workspace": { + "get": { + "operationId": "workspace_scoped", + "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/WorkspaceResponse" + } + } + }, + "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/workers": { + "get": { + "operationId": "workspace_worker_list_alias", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerListResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_worker_create_alias", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceWorkerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserCreateWorkerResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/workers/launch-options": { + "get": { + "operationId": "workspace_worker_launch_options_alias", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerLaunchOptionsResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/workspace": { + "get": { + "operationId": "workspace_current", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/workspace-deletions/{operation_id}": { + "get": { + "operationId": "workspace_deletion_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "operation_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDeletionOperationResponse" + } + } + }, + "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/workspaces": { + "get": { + "operationId": "workspace_catalog_list", + "parameters": [ + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/WorkspaceListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceCatalogListResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_catalog_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceCreateResponse" + } + } + }, + "description": "Alternate successful response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceCreateResponse" + } + } + }, + "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" + }, + "409": { + "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/workspaces/{workspace_id}/deletion": { + "get": { + "operationId": "workspace_deletion_preflight", + "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/WorkspaceDeletionPreflightResponse" + } + } + }, + "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": "workspace_deletion_start", + "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/WorkspaceDeletionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDeletionOperationResponse" + } + } + }, + "description": "Alternate successful response" + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDeletionOperationResponse" + } + } + }, + "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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/health": { + "get": { + "operationId": "health", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } } } } diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index d803355bd..a184160d6 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -4,6 +4,6 @@ Use the existing Merge Request as the routine authority for review requests, ver Do not add a Ticket comment for each review or fix iteration. Add a Ticket comment only when a blocker or decision requires Orchestrator attention, or once after approval to hand off the final implementation and validation evidence. -Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. +Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Only the Ticket's sole `read_write` target is an implementation/publish/Merge Request repository; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write. Before opening a Merge Request, publish only the committed Ticket work branch with a normal non-force push and verify that the Ticket repository remote resolves it to the exact local `HEAD`; a local branch name or dirty Workdir is not immutable review evidence. Do not push the target branch, tags, or unrelated refs, and never force-push. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index d5053a9cb..10d8ba079 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -2,7 +2,7 @@ You are the Ticket Orchestrator role. {% include "common.git" %} -Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, current assignment, Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If spawn or finalization fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication of the Ticket source work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. +Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable semantic aliases such as `checkout` for the sole `read_write` target and repository-derived aliases for `read_only` targets. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from the sole `read_write` target's Ticket work branch, guarded integration of the current approved Merge Request, recording completion, and closing the Ticket to the Workspace Orchestrator by default; do not wait for a second merge confirmation. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope. The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker. diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 7ecc4b5bd..9f4346bfe 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,12 +6,14 @@ "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/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/route-ownership.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-workdir-meta.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/api/ticket-browser.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/route-ownership.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-workdir-meta.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/config-source/api.test.ts tests/memory-api.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", "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" + "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check", + "generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types", + "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check" }, "imports": { "$lib/": "./src/lib/", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 76cc6231c..e6cfb44d9 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,30 +1,36 @@ -// Generated by `cargo run -p server-api --features typescript --example generate_companion_api_types`. -// Do not edit manually. +// @generated by yoi-companion-openapi-typescript@1. Do not edit. +// Input: openapi/server-api.json +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) +// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output +// Output: web/workspace/src/lib/generated/companion-api.ts +// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check -export type DiagnosticSeverity = "info" | "warning" | "error"; - -export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: 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 CompanionCancelRequest = { reason?: string | null; }; export type CompanionLifecycleState = "idle" | "running" | "stopped"; export type CompanionMessageDisposition = "accepted" | "rejected"; -export type CompanionTransportSummary = { mode: string, available: boolean, }; +export type CompanionMessageRequest = { content: string; }; -export type CompanionStatusResponse = { state: CompanionLifecycleState, worker: WorkspaceWorkerDiscoveryItem | null, transport: CompanionTransportSummary, diagnostics: Array, }; +export type CompanionMessageResponse = { message: string; state: CompanionMessageDisposition; }; -export type CompanionMessageRequest = { content: string, }; +export type CompanionStatusResponse = { diagnostics?: Array; state: CompanionLifecycleState; transport: CompanionTransportSummary; worker?: WorkspaceWorkerDiscoveryItem | null; }; -export type CompanionCancelRequest = { reason?: string | null, }; +export type CompanionTranscriptItem = { content: string; created_at: string; role: CompanionTranscriptRole; sequence: number; }; -export type CompanionMessageResponse = { state: CompanionMessageDisposition, message: string, }; +export type CompanionTranscriptProjection = { items: Array; limit: number; next?: number | null; start: number; state: CompanionLifecycleState; total: number; }; export type CompanionTranscriptRole = "user" | "assistant"; -export type CompanionTranscriptItem = { sequence: number, role: CompanionTranscriptRole, content: string, created_at: string, }; +export type CompanionTransportSummary = { available: boolean; mode: string; }; -export type CompanionTranscriptProjection = { state: CompanionLifecycleState, start: number, limit: number, total: number, next: number | null, items: Array, }; +export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; + +export type WorkspaceWorkerDiscoveryItem = { display_name: string; profile?: string | null; resource_key: string; status?: string | null; subject: WorkspaceWorkerSubject; }; + +export type WorkspaceWorkerSubject = { kind: "runtime_worker"; runtime_id: string; worker_id: string; }; diff --git a/web/workspace/src/lib/generated/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts index 7c4b0b9df..2dd94fea6 100644 --- a/web/workspace/src/lib/generated/legacy-server-api.ts +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -1,140 +1,361 @@ // 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"; +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 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 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 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 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 WorkspacePermissionSummary = { + manage_repositories: boolean; + manage_secrets: boolean; + manage_runtimes: boolean; + delete_workspace: boolean; +}; -export type WorkspaceDeletionState = "queued" | "running" | "blocked" | "failed" | "succeeded"; +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 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 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 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 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 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 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 Diagnostic = { + code: string; + severity: DiagnosticSeverity; + message: string; +}; -export type WorkspaceExtensionPointState = { status: string, note: string, diagnostics: Array, }; +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 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 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 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 UpdateWorkspaceMetadataRequest = { + display_name: string; + revision: string; +}; -export type WorkspaceMetadataMutationResponse = { workspace: WorkspaceMetadataSettingsResponse, diagnostics: Array, }; +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 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 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 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 ConfigContentType = "decodal" | "text"; -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 ConfigEntry = { + path: string; + content_type: ConfigContentType; + content: string; + content_digest: string; +}; -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 ConfigTreeSnapshot = { + revision: number; + digest: string; + entries: { [key in string]: ConfigEntry }; +}; + +export type ConfigTreeChange = + | { + "kind": "create"; + path: string; + content_type: ConfigContentType; + content: string; + } + | { "kind": "update"; path: string; expected_digest: string; content: string } + | { "kind": "rename"; from: string; to: string; expected_digest: string } + | { "kind": "delete"; path: string; expected_digest: string }; + +export type ConfigProjectionValidator = { + "kind": "static_template_catalog"; + namespace: string; + key_aliases?: { [key in string]: string }; +}; + +export type ConfigSchemaContribution = { + provider_id: string; + namespace: string; + version: string; + source: string; + projection_validator?: ConfigProjectionValidator; + source_digest: string; +}; + +export type WorkspaceConfigSchemaBundle = { + contributions: Array; + source: string; + fingerprint: string; +}; + +export type ToolchainContract = { + contract_version: number; + decodal_version: string; + schema_version: number; + entrypoints: Array; + import_policy_version: number; + schema_bundle: WorkspaceConfigSchemaBundle; + fingerprint: string; +}; + +export type WorkspaceConfigTreeResponse = { + snapshot: ConfigTreeSnapshot; + contract: ToolchainContract; + projection_digest: string; +}; + +export type ConfigCommitRequest = { + base_revision: number; + base_digest: string; + changes: Array; + entrypoints: Array; +}; + +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 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 RepositorySshConnectionProbeRequest = { runtime_id: string }; -export type RepositorySshHostKeyCandidate = { algorithm: string, host_key: string, fingerprint: string, }; +export type RepositorySshHostKeyCandidate = { + algorithm: string; + host_key: string; + fingerprint: string; +}; -export type RepositorySshConnectionTrustState = "untrusted" | "verified" | "changed"; +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 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 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, }; +export type RepositoryLogResponse = { + workspace_id: string; + repository_key: string; + default_selector?: string | null; + limit: number; + items: Array; + diagnostics: Array; +}; diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index d1fd56c6d..c01a71858 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -1,26 +1,108 @@ // 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 const MEMORY_API_LIMITS = { + maxResponseBytes: 8388608, + maxDocumentBytes: 4194304, + maxCollectionItems: 500, + maxStringBytes: 1048576, + maxIdentifierBytes: 512, +} as const; + 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/repository-access-api.ts b/web/workspace/src/lib/generated/repository-access-api.ts index 2d0eb9d07..f84c9200e 100644 --- a/web/workspace/src/lib/generated/repository-access-api.ts +++ b/web/workspace/src/lib/generated/repository-access-api.ts @@ -1,26 +1,93 @@ // 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 index aa6a289fa..e6a5d5591 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,10 +1,12 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef) +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) // 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 ConfirmRepositorySshHostTrustRequest = { expected_host_trust_revision?: number | null; host_key: string; operation_id: string; runtime_id: string; }; + export type CreateWorkspaceRepositoryRequest = { default_ref?: string | null; repository_key: string; source: string; }; export type CreateWorkspaceRepositoryResponse = { replayed: boolean; repository_key: string; workspace_id: string; }; @@ -13,10 +15,18 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe export type DiagnosticSeverity = "info" | "warning" | "error"; +export type GitCommitSummary = { author_date: string; author_email: string; author_name: string; hash: string; parents: Array; refs: Array; short_hash: string; summary: string; }; + 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 HostListResponse = { diagnostics: Array; items: Array; limit: number; source: string; workspace_id: string; }; + +export type HostSummary = { arch: string; diagnostics: Array; host_id: string; kind: string; label: string; last_seen_at?: string | null; observed_at: string; os: string; runtime_id: string; status: string; }; + +export type HostWorkerListResponse = { diagnostics: Array; items: Array; limit: number; source: string; workspace_id: string; }; + export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; export type RepositoryDetailResponse = { item: RepositorySummary; source: string; workspace_id: string; }; @@ -25,10 +35,54 @@ export type RepositoryDiagnostic = { code: string; message: string; severity: st export type RepositoryListResponse = { diagnostics: Array; items: Array; source: string; workspace_id: string; }; +export type RepositoryLogResponse = { default_selector?: string | null; diagnostics: Array; items: Array; limit: number; repository_key: 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 RepositorySshConnectionProbeRequest = { runtime_id: string; }; + +export type RepositorySshConnectionProbeResponse = { candidates: Array; expected_host_trust_revision?: number | null; host_trust_id: string; hostname: string; port: number; repository_key: string; runtime_id: string; trust_state: RepositorySshConnectionTrustState; workspace_id: string; }; + +export type RepositorySshConnectionTrustState = "untrusted" | "verified" | "changed"; + +export type RepositorySshHostKeyCandidate = { algorithm: string; fingerprint: string; host_key: string; }; + +export type RepositorySshHostTrust = { created_at: string; current_revision: number; fingerprint: string; host_key: string; host_trust_id: string; hostname: string; key_algorithm: string; port: number; referenced_repositories?: Array; updated_at: string; workspace_id: string; }; + 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; }; + +export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: "maintenance"; state: WorkerMaintenanceState; }; + +export type WorkerCapabilitySummary = { can_spawn_followup: boolean; can_stop: boolean; }; + +export type WorkerImplementationSummary = { display_hint: string; kind: string; }; + +export type WorkerMaintenanceState = "compacting"; + +export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling"; + +export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; }; + +export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; }; + +export type WorkerSummary = { capabilities: WorkerCapabilitySummary; diagnostics?: Array; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array; workdir_attachments?: Array; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; }; + +export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; }; + +export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; }; + +export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; }; + +export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; + +export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; }; + +export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; }; + +export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; + +export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts new file mode 100644 index 000000000..feeaadae9 --- /dev/null +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -0,0 +1,188 @@ +// @generated by yoi-runtime-openapi-typescript@1. Do not edit. +// Input: openapi/server-api.json +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) +// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output +// Output: web/workspace/src/lib/generated/runtime-api.ts +// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check + +export type AttachmentUploadCancelResponse = { cancelled: boolean; }; + +export type AttachmentUploadGrantResponse = { expires_at_ms: number; upload_id: string; }; + +export type CleanupTargetKind = "worker_delete" | "workdir_clean_cleanup" | "workdir_dirty_discard" | "workdir_record_delete"; + +export type CleanupWorkdirCandidate = { action: CleanupTargetKind; blocking_reason?: string | null; cleanliness: CleanupWorkdirCleanliness; estimated_reclaim_bytes?: number | null; file_status: CleanupWorkdirFileStatus; linked_running_worker_ids: Array; linked_worker_ids: Array; pinned_linked: boolean; reason: string; repository_key: string; running_linked: boolean; runtime_id: string; target_id: string; workdir_id: string; }; + +export type CleanupWorkdirCleanliness = "clean" | "dirty" | "unknown"; + +export type CleanupWorkdirFileStatus = "pending" | "present" | "active" | "cleanup_pending" | "not_found" | "corrupted" | "failed" | "unknown"; + +export type CleanupWorkerCandidate = { action: CleanupTargetKind; blocking_reason?: string | null; estimated_reclaim_bytes?: number | null; linked_workdir_ids: Array; pinned: boolean; reason: string; retention_state: string; running_linked: boolean; runtime_id: string; runtime_worker_id: string; target_id: string; worker_id: string; }; + +export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_revision?: number | null; public_bundle: RuntimePublicIdentityBundle; }; + +export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?: Array; expected_plan_digest: string; expected_plan_revision: string; workdir_target_ids?: Array; worker_target_ids?: Array; }; + +export type RemoveRuntimeRequest = { expected_binding_revision: number; operation_id: string; }; + +export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; + +export type RevokeRuntimeTrustKeyRequest = { expected_revision: number; }; + +export type RuntimeCleanupExecutionResponse = { diagnostics: Array; executed_at: string; plan_after: RuntimeCleanupPlanResponse; results: Array; runtime_id: string; workspace_id: string; }; + +export type RuntimeCleanupExecutionResult = { action: CleanupTargetKind; message: string; status: string; target_id: string; }; + +export type RuntimeCleanupPlanResponse = { diagnostics: Array; digest: string; generated_at: string; revision: string; runtime_id: string; workdirs: Array; workers: Array; workspace_id: string; }; + +export type RuntimeConfigBundleResult = { availability?: unknown; diagnostics?: Array; state: WorkerOperationState; }; + +export type RuntimeConfigBundleSyncRequest = { bundle: unknown; }; + +export type RuntimeConnectionDisplayState = "configured" | "verified" | "unavailable" | "revoked"; + +export type RuntimeConnectionTestFailureKind = "authentication" | "authorization" | "network_unreachable" | "timeout" | "tls_or_transport" | "malformed_response" | "protocol_version_mismatch" | "runtime_identity_mismatch" | "configuration"; + +export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_revision: number; checked_at: string; connection_state: RuntimeConnectionDisplayState; diagnostics?: Array; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; verification?: RuntimeVerificationEvidenceSummary | null; workspace_id: string; }; + +export type RuntimeConnectionTestStatus = "compatible" | "failed"; + +export type RuntimeIdentityAuthority = "runtime_registry_projection" | "server_runtime_configuration"; + +export type RuntimeListResponse = { diagnostics?: Array; items: Array; limit: number; source: string; workspace_id: string; }; + +export type RuntimeManagementApiError = { current_fingerprint?: string | null; current_revision?: number | null; diagnostics?: Array; error: string; message: string; }; + +export type RuntimeManagementSummary = { binding?: WorkspaceRuntimeBindingSummary | null; built_in: boolean; config_managed: boolean; endpoint_configured: boolean; removable: boolean; token_ref_configured: boolean; }; + +export type RuntimeProfileSelector = { kind: "builtin"; value: string; } | { kind: "named"; value: string; }; + +export type RuntimePublicIdentityBundle = { identity_id: string; public_key: string; }; + +export type RuntimeRemovalOperationResponse = { binding_removed: boolean; completed_at?: string | null; created_at: string; failure_category?: string | null; operation_id: string; runtime_id: string; runtime_registration_removed?: boolean | null; state: RuntimeRemovalOperationState; updated_at: string; workspace_id: string; }; + +export type RuntimeRemovalOperationState = "pending" | "cleanup_pending" | "succeeded" | "failed"; + +export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http"; + +export type RuntimeSourceStatus = "active" | "reserved"; + +export type RuntimeSourceSummary = { identity_authority: RuntimeIdentityAuthority; kind: RuntimeSourceKind; note: string; status: RuntimeSourceStatus; }; + +export type RuntimeSummary = { arch: string; diagnostics?: Array; host_ids?: Array; kind: string; label: string; os: string; runtime_id: string; source: RuntimeSourceSummary; status: string; worker_creation_available: boolean; }; + +export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "coder" | "reviewer"; + +export type RuntimeTrustAuditAction = "created" | "replaced" | "reactivated" | "revoked"; + +export type RuntimeTrustAuditEntry = { action: RuntimeTrustAuditAction; actor_account_id: string; at: string; new_fingerprint?: string | null; old_fingerprint?: string | null; revision: number; }; + +export type RuntimeTrustKeyRevealResponse = { public_key: string; }; + +export type RuntimeTrustKeyState = { created_at?: string | null; fingerprint?: string | null; revision?: number | null; revoked_at?: string | null; status: RuntimeTrustKeyStatus; updated_at?: string | null; }; + +export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked"; + +export type RuntimeVerificationEvidenceSummary = { binding_revision: number; last_checked_at: string; last_outcome: RuntimeVerificationOutcome; runtime_identity_revision: number; runtime_public_key_fingerprint: string; verified_at?: string | null; workspace_identity_revision: number; workspace_key_id: string; workspace_trust_generation: number; }; + +export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed"; + +export type RuntimeWorkerCompletionsRequest = { kind: unknown; prefix?: string; }; + +export type RuntimeWorkerCompletionsResult = { diagnostics?: Array; entries: Array; kind: unknown; prefix: string; runtime_id: string; worker_id: string; }; + +export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array | null; }; + +export type RuntimeWorkerInputResult = { diagnostics?: Array; runtime_id: string; state: WorkerOperationState; worker_id: string; }; + +export type RuntimeWorkerLifecycleRequest = { reason?: string | null; ticket_assignment?: RuntimeWorkerTicketAssignmentRequest | null; }; + +export type RuntimeWorkerLifecycleResult = { diagnostics?: Array; runtime_id: string; state: WorkerOperationState; worker_id: string; }; + +export type RuntimeWorkerListResponse = { diagnostics?: Array; items: Array; limit: number; source: string; workspace_id: string; }; + +export type RuntimeWorkerShowResponse = { capabilities: WorkerCapabilitySummary; diagnostics?: Array; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array; updated_at: string; workdir_attachments?: Array; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; }; + +export type RuntimeWorkerSpawnAcceptanceEvidence = { detail: string; kind: string; }; + +export type RuntimeWorkerSpawnAcceptanceRequirement = { kind: "socket_ready"; } | { expected_segments: number; kind: "run_accepted"; }; + +export type RuntimeWorkerSpawnIntent = { kind: "workspace_companion"; } | { kind: "workspace_orchestrator"; } | { kind: "workspace_coding"; } | { kind: "ticket_role"; role: RuntimeTicketWorkerRole; ticket_id: string; }; + +export type RuntimeWorkerSpawnRequest = { acceptance: RuntimeWorkerSpawnAcceptanceRequirement; initial_submit?: Array; intent: RuntimeWorkerSpawnIntent; profile: RuntimeProfileSelector; requested_worker_name?: string | null; ticket_assignment?: RuntimeWorkerTicketAssignmentRequest | null; workdir_attachment_requests?: Array; }; + +export type RuntimeWorkerSpawnResponse = { acceptance_evidence?: Array; diagnostics?: Array; state: WorkerOperationState; worker?: RuntimeWorkerSummary | null; }; + +export type RuntimeWorkerSpawnWorkingDirectoryAttachmentRequest = { alias: string; working_directory: RuntimeWorkerSpawnWorkingDirectoryRequest; }; + +export type RuntimeWorkerSpawnWorkingDirectoryRequest = { repository_key: string; selector?: string | null; }; + +export type RuntimeWorkerSummary = { capabilities: WorkerCapabilitySummary; diagnostics?: Array; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array; workdir_attachments?: Array; worker_id: string; worker_state?: unknown; workspace: WorkerWorkspaceSummary; }; + +export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; }; + +export type UpdateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; }; + +export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed"; + +export type UploadedFileRef = { artifact_id: string; availability: UploadedFileAvailability; byte_len: number; created_at_ms: number; file_name: string; media_type: string; sha256: string; source_entry_id?: string | null; }; + +export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: "maintenance"; state: WorkerMaintenanceState; }; + +export type WorkerCapabilitySummary = { can_spawn_followup: boolean; can_stop: boolean; }; + +export type WorkerFileDeleteResponse = { deleted: boolean; }; + +export type WorkerFileUploadResponse = { file: UploadedFileRef; }; + +export type WorkerImplementationSummary = { display_hint: string; kind: string; }; + +export type WorkerMaintenanceState = "compacting"; + +export type WorkerOperationState = "accepted" | "unsupported" | "rejected"; + +export type WorkerRestoreResponse = { result: WorkerRestoreResult; runtime_id: string; worker_id: string; workspace_id: string; }; + +export type WorkerRestoreResult = { diagnostics?: Array; state: WorkerRestoreState; worker?: WorkerSummary | null; }; + +export type WorkerRestoreState = "accepted" | "rejected" | "rolled_back" | "reconciliation_required"; + +export type WorkerRetentionResponse = { pinned: boolean; retention_state: string; runtime_id: string; worker_id: string; workspace_id: string; }; + +export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling"; + +export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; }; + +export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; }; + +export type WorkerSummary = { capabilities: WorkerCapabilitySummary; diagnostics?: Array; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array; workdir_attachments?: Array; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; }; + +export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; }; + +export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; }; + +export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; }; + +export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; + +export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; }; + +export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; }; + +export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; + +export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; + +export type WorkspaceRuntimeBindingState = "configured" | "verified" | "revoked"; + +export type WorkspaceRuntimeBindingSummary = { connection_state: RuntimeConnectionDisplayState; revision: number; state: WorkspaceRuntimeBindingState; verification?: RuntimeVerificationEvidenceSummary | null; workspace_key_generation?: number | null; workspace_key_id?: string | null; }; + +export type WorkspaceRuntimeDetail = { endpoint?: string | null; recent_audit?: Array; runtime: WorkspaceRuntimeResource; trust_key: RuntimeTrustKeyState; workspace_id: string; }; + +export type WorkspaceRuntimeListResponse = { diagnostics?: Array; items: Array; limit: number; source: string; workspace_id: string; }; + +export type WorkspaceRuntimeResource = { arch: string; diagnostics?: Array; host_ids?: Array; kind: string; label: string; management: RuntimeManagementSummary; os: string; runtime_id: string; source: RuntimeSourceSummary; status: string; worker_creation_available: boolean; }; diff --git a/web/workspace/src/lib/generated/skill-api.ts b/web/workspace/src/lib/generated/skill-api.ts index 3f75a4e86..f73ae0c95 100644 --- a/web/workspace/src/lib/generated/skill-api.ts +++ b/web/workspace/src/lib/generated/skill-api.ts @@ -20,22 +20,76 @@ 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; +}; + +export type SkillActivationResponse = { + name: string; + provenance: SkillProvenance; + diagnostics: Array; + body: string; +}; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 1901ee158..7f9514be6 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,54 +1,138 @@ -// Generated from yoi-workspace-server. Do not edit by hand. -// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts +// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. +// Input: openapi/server-api.json +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) +// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output +// Output: web/workspace/src/lib/generated/ticket-api.ts +// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check -export type InvalidProjectRecord = { label: string, reason: string, }; +export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; -export type TicketSummary = { id: string, resource_key: string, title: string, state: string, priority: string, updated_at: string | null, queued_by: string | null, queued_at: string | null, workspace_action_priority: string, record_source: string, }; +export type BrowserCloseTicketRequest = { resolution: string; }; -export type TicketListResponse = { workspace_id: string, limit: number, items: Array, page: QueryPage, invalid_records: Array, record_authority: string, }; +export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; }; -export type QueryPage = { limit: number, returned: number, has_more: boolean, next_cursor: string | null, sort: string, source_limit: number | null, source_truncated: boolean, }; +export type BrowserQueueTicketRequest = { [key: string]: never; }; -export type TicketEventDetail = { sequence: number, event_ref: string, kind: string, author: string | null, at: string | null, status: string | null, from: string | null, to: string | null, reason: string | null, state_field: string | null, heading: string | null, body: string | null, attributes: { [key in string]: string }, references: Array, }; +export type BrowserTicketTargetEdit = { action: "set"; targets: Array; } | { action: "clear"; }; -export type ObjectiveLinkSummary = { id: string, resource_key: string, title: string, state: string, }; +export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report"; -export type TicketEvidenceEvent = { event_ref: string, sequence: number, kind: string, at: string | null, author: string | null, excerpt: string, }; +export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed"; -export type TicketAssignmentSummary = { assignment_id: string, runtime_id: string, worker_id: string, worker_resource_key?: string | null, }; +export type BrowserTransitionTicketStateRequest = { author?: string | null; body?: string | null; reason?: string | null; state: BrowserTicketWorkflowState; }; -export type TicketRoleAssignmentSummary = { assignment_id: string, role: string, principal: TicketAssignmentPrincipalSummary, assigned_by: string, assigned_at: string, }; +export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; }; -export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, }; +export type ConflictResolution = "none" | "clean" | "conflicts_resolved"; -export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array, blockers: Array, }; +export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; -export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, }; +export type DiagnosticSeverity = "info" | "warning" | "error"; -export type MergeRequestRefDiagnostic = { code: string, message: string, }; +export type FindingSeverity = "blocker" | "major" | "minor" | "note"; -export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array, thread_event_count: number, ref_diagnostics?: Array, }; +export type InvalidProjectRecord = { label: string; reason: string; }; -export type MergeRequestListResponse = { items: Array, next_cursor: string | null, }; +export type MarkdownText = string; -export type TicketEvidenceSummary = { has_merge_request: boolean, has_current_subject_ref: boolean, has_review_request: boolean, has_commit: boolean, review_status: string | null, approved_current_subject: boolean, review_after_rescope: boolean, unresolved_request_changes: boolean, complete_for_integration: boolean, missing: Array, }; +export type MergeRequestDetailResponse = { created_at: string; linked_tickets: Array; merge_request_id: string; repository_key: string; selector_from?: string | null; selector_to: string; source: MergeRequestRefResponse; state: MergeRequestState; target: MergeRequestRefResponse; thread: Array; ticket_ids: Array; updated_at: string; workspace_id: string; }; -export type TicketQueryRequest = { query: string | null, states: Array, event_kinds: Array, evidence: Array, review_status: string | null, attention: Array, related_ticket_id: string | null, relation_kind: string | null, linked_objective_id: string | null, updated_after: string | null, updated_before: string | null, sort: string | null, limit: number | null, cursor: string | null, }; +export type MergeRequestLinkedTicketResponse = { key?: string | null; ticket_id: string; }; -export type TicketQueryItem = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, workspace_action_priority: string, matched_fields: Array, snippet: string | null, matching_event: TicketEvidenceEvent | null, linked_objective_ids: Array, relation_count: number, blocker_count: number, unresolved_blocker_count: number, unresolved_review_count: number, evidence: TicketEvidenceSummary, merge_request: TicketMergeRequestSummary | null, }; +export type MergeRequestListItem = { ref_diagnostics?: Array; summary: TicketMergeRequestSummary; thread_event_count: number; ticket_ids: Array; }; -export type TicketQueryResponse = { items: Array, page: QueryPage, record_authority: string, }; +export type MergeRequestListResponse = { items: Array; next_cursor?: string | null; }; -export type TicketShowRequest = { event_limit: number | null, event_cursor: string | null, }; +export type MergeRequestRefDiagnostic = { code: string; message: string; }; -export type TicketRelation = { ticket_id: string, kind: string, target: string, target_resource_key?: string | null, note: string | null, author: string, at: string, }; +export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; }; -export type DerivedTicketRelation = { source_ticket: string, source_resource_key?: string | null, inverse_kind: string, forward_kind: string, note: string | null, author: string, at: string, }; +export type MergeRequestState = "open" | "merged" | "closed"; -export type TicketRelationBlocker = { blocking_ticket: string, blocking_resource_key?: string | null, reason_kind: string, relation_kind: string, note: string | null, blocking_state: string, }; +export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; }; -export type TicketRelationNotice = { related_ticket: string, kind: string, message: string, }; +export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; }; -export type TicketRelationView = { outgoing: Array, incoming: Array, blockers: Array, notices: Array, }; +export type MergeStrategy = "fast_forward" | "merge"; -export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_key: string | null, ref_selector: string | null, risk_flags: Array, body: string, body_truncated: boolean, event_count: number, events: Array, event_page: QueryPage, artifact_count: number, artifacts: Array, relations: TicketRelationView, linked_objectives: Array, implementation_reports: Array, assignments: Array, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, }; +export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array; slug?: string | null; targets?: Array; title: string; workflow_state?: TicketWorkflowState | null; }; + +export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array; id: string; linked_ticket_summaries: Array; linked_tickets: Array; record_source: string; resource_key: string; resources: Array; revision: string; state: string; title: string; updated_at?: string | null; }; + +export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; }; + +export type ObjectiveLinkSummary = { id: string; resource_key: string; state: string; title: string; }; + +export type ObjectiveLinkedTicketSummary = { id: string; resource_key: string; state: string; title: string; }; + +export type ObjectiveListResponse = { invalid_records: Array; items: Array; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; record_authority: string; workspace_id: string; }; + +export type ObjectiveResourceSummary = { bytes: number; media_type?: string | null; path: string; updated_at: string; }; + +export type ObjectiveSummary = { created_at?: string | null; id: string; linked_tickets: Array; record_source: string; resource_key: string; state: string; summary: string; title: string; updated_at?: string | null; }; + +export type QueryPage = { has_more: boolean; limit: number; next_cursor?: string | null; returned: number; sort: string; source_limit?: number | null; source_truncated: boolean; }; + +export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; + +export type ReviewDecision = "approve" | "request_changes"; + +export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; }; + +export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; }; + +export type TicketActionEligibility = { blockers: Array; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_coder: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array; }; + +export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; }; + +export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contributor"; + +export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; + +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; item_revision: string; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; + +export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; + +export type TicketDetailRelation = { at: string; author: string; kind: string; note?: string | null; target: string; target_resource_key?: string | null; ticket_id: string; }; + +export type TicketDetailRelationBlocker = { blocking_resource_key?: string | null; blocking_state: string; blocking_ticket: string; note?: string | null; reason_kind: string; relation_kind: string; }; + +export type TicketDetailRelationNotice = { kind: string; message: string; related_ticket: string; }; + +export type TicketDetailRelationView = { blockers: Array; incoming: Array; notices: Array; outgoing: Array; }; + +export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; event_ref: string; from?: string | null; heading?: string | null; kind: string; reason?: string | null; references: Array; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; }; + +export type TicketEvidenceEvent = { at?: string | null; author?: string | null; event_ref: string; excerpt: string; kind: string; sequence: number; }; + +export type TicketEvidenceSummary = { approved_current_subject: boolean; complete_for_integration: boolean; has_commit: boolean; has_current_subject_ref: boolean; has_merge_request: boolean; has_review_request: boolean; missing: Array; review_after_rescope: boolean; review_status?: string | null; unresolved_request_changes: boolean; }; + +export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array; }; + +export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; }; + +export type TicketListResponse = { invalid_records: Array; items: Array; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; }; + +export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | null; operation_key: string; reason?: string | null; }; + +export type TicketMergeRequestSummary = { current_subject_ref?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; state: string; updated_at: string; }; + +export type TicketQueueOutcome = { queued_tickets: Array; requested_ticket: string; }; + +export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; }; + +export type TicketReference = { kind: string; target: string; }; + +export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssignmentRecord | null; ticket_id: string; workspace_id: string; }; + +export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: TicketAssignmentRole; ticket_id: string; workspace_id: string; }; + +export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; }; + +export type TicketStatus = "Open" | "Closed"; + +export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; }; + +export type TicketTargetAccess = "read_only" | "read_write"; + +export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed"; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 385624fec..e0874bdac 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,34 +1,42 @@ -// 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 +// @generated by yoi-workdir-openapi-typescript@1. Do not edit. +// Input: openapi/server-api.json +// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) +// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output +// Output: web/workspace/src/lib/generated/workdir-api.ts +// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check + +export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; +export type ExternalWorkdirGrantCreateRequest = { display_name: string; provider_instance_id: string; read_only: boolean; ttl_seconds: number; }; + +export type ExternalWorkdirGrantResponse = { display_name: string; expires_at: string; generation: number; grant_id: string; permissions: string; provider_instance_id: string; status: string; working_directory_id: string; workspace_id: string; }; + +export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; + +export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; }; + +export type WorkingDirectoryCreateRequest = { display_name?: string | null; operation_id?: string | null; repository_key: string; runtime_id?: string | null; selector?: string | null; }; + +export type WorkingDirectoryCreateResponse = { diagnostics: Array; item: WorkingDirectorySummary; runtime_id: string; workspace_id: string; }; + +export type WorkingDirectoryDetailResponse = { diagnostics: Array; item: WorkingDirectorySummary; runtime_id?: string | null; workspace_id: string; }; + +export type WorkingDirectoryListResponse = { diagnostics: Array; items: Array; workspace_id: string; }; export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; +export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; }; + +export type WorkingDirectoryRemovalDisposition = "removed" | "retained" | "attention_required"; + +export type WorkingDirectoryRemovalRequest = { reason: string; }; + +export type WorkingDirectoryRemovalResponse = { disposition: WorkingDirectoryRemovalDisposition; failure_category?: string | null; retryable: boolean; working_directory_id: string; }; + +export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; }; + export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; -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 WorkingDirectorySource = { "kind": "repository", repository_key: string, } | { "kind": "external_grant", grant_id: string, }; - -export type WorkingDirectorySummary = { working_directory_id: string, -/** - * Optional human-facing label, never a routing key. - */ -display_name?: string | null, source: WorkingDirectorySource, 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, occupied_by?: WorkingDirectoryOccupancy | null, }; - -export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, -/** - * Optional human-facing label, independent of every Worker attachment alias. - */ -display_name?: string | null, repository_key: string, selector?: string | null, operation_id?: string | null, }; - -export type WorkingDirectoryListResponse = { workspace_id: string, items: Array, diagnostics: Array, }; - -export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id?: string | null, item: WorkingDirectorySummary, diagnostics: Array, }; - -export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; +export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index ab24375d9..79b39f7b6 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,68 +1,84 @@ -// 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 +// @generated by yoi-worker-openapi-typescript@1. Do not edit. +// Input: openapi/server-api.json +// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c) +// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output +// Output: web/workspace/src/lib/generated/worker-launch-api.ts +// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check -import type { Segment } from "./protocol"; +export type BrowserCreateWorkerResponse = { console_href: string; diagnostics: Array; runtime_id: string; worker: WorkerLaunchWorkerSummary; worker_id: string; workspace_id: string; }; + +export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_cwd?: string | null; working_directory_id: string; }; + +export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; }; + +export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; initial_submit?: Array; profile?: string | null; runtime_id: string; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array; }; + +export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; }; + +export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; +export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed"; + +export type PasteArtifactMediaType = "text_plain_utf8"; + +export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; }; + +export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; + +export type RuntimeWorkerWorkdirAttachmentSummary = { alias: string; working_directory: RuntimeWorkingDirectorySummary; }; + +export type RuntimeWorkingDirectoryCleanupTarget = { kind: string; repository_id: string; working_directory_id: string; }; + +export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; repository_id: string; status: WorkingDirectoryStatusKind; working_directory_id: string; }; + +export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; }; + +export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed"; + +export type UploadedFileRef = { artifact_id: string; availability: UploadedFileAvailability; byte_len: number; created_at_ms: number; file_name: string; media_type: string; sha256: string; source_entry_id?: string | null; }; + +export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: "maintenance"; state: WorkerMaintenanceState; }; + +export type WorkerCapabilitySummary = { can_spawn_followup: boolean; can_stop: boolean; }; + +export type WorkerImplementationSummary = { display_hint: string; kind: string; }; + +export type WorkerLaunchOptionsResponse = { default_profile?: string | null; diagnostics: Array; profiles: Array; repositories: Array; runtimes: Array; working_directories: Array; workspace_id: string; }; + +export type WorkerLaunchProfileCandidate = { description: string; id: string; label: string; }; + +export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array; display_name: string; runtime_id: string; status: string; worker_creation_available: boolean; working_directory_required: boolean; }; + +export type WorkerLaunchWorkerSummary = { capabilities: WorkerCapabilitySummary; diagnostics?: Array; display_name: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned: boolean; profile?: string | null; retention_state: string; runtime_id: string; singleton_key?: string | null; state: string; tags: Array; workdir_attachments?: Array; worker_id: string; workspace: WorkerWorkspaceSummary; }; + +export type WorkerListResponse = { diagnostics?: Array; items: Array; limit: number; source: string; workspace_id: string; }; + +export type WorkerMaintenanceState = "compacting"; + +export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling"; + +export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; }; + +export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; }; + +export type WorkerSummary = { capabilities: WorkerCapabilitySummary; diagnostics?: Array; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array; workdir_attachments?: Array; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; }; + +export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; }; + +export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; }; + +export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; }; export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; +export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; }; + +export type WorkingDirectoryRepositoryOption = { default_selector?: string | null; repository_key: string; }; + +export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; }; + export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; -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 RuntimeWorkingDirectorySummary = { working_directory_id: string, -/** - * Optional human-facing label, never a routing key. - */ -display_name?: string | null, 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, occupied_by?: WorkingDirectoryOccupancy | null, }; - -export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, }; - -export type WorkingDirectorySource = { "kind": "repository", repository_key: string, } | { "kind": "external_grant", grant_id: string, }; - -export type WorkingDirectorySummary = { working_directory_id: string, -/** - * Optional human-facing label, never a routing key. - */ -display_name?: string | null, source: WorkingDirectorySource, 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, occupied_by?: WorkingDirectoryOccupancy | null, }; - -export type WorkerWorkspaceSummary = { visibility: string, identity: string, workspace_id?: string | null, }; - -export type WorkerImplementationSummary = { kind: string, display_hint: string, }; - -export type WorkerCapabilitySummary = { can_stop: boolean, can_spawn_followup: boolean, }; - -export type RuntimeWorkerWorkdirAttachmentSummary = { alias: string, working_directory: RuntimeWorkingDirectorySummary, }; - -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, workdir_attachments?: Array, 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 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 BrowserWorkerWorkingDirectorySelection = { -/** - * Stable Worker-local routing alias. - */ -alias: string, working_directory_id: string, relative_cwd: string | null, }; - -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, workdir_attachments: Array, -/** - * 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 BrowserWorkspaceOrchestratorResponse = { workspace_id: string, online: boolean, disposition: string, worker?: WorkerLaunchWorkerSummary | null, diagnostics: Array, }; +export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 15e240d34..0ee2e483f 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -177,23 +177,41 @@ export async function readBoundedJson( return JSON.parse(text) as unknown; } -async function requireJson(response: Response, path: string): Promise { +async function requireJson( + response: Response, + path: string, + parse: (value: unknown) => T, + maxResponseBytes?: number, +): Promise { if (!response.ok) { + if (maxResponseBytes !== undefined) { + await response.body?.cancel(); + throw new Error(`${path} request failed (${response.status})`); + } const text = await response.text(); throw new Error(text || `${path} request failed (${response.status})`); } - return (await response.json()) as T; + const value: unknown = maxResponseBytes === undefined + ? await response.json() + : await readBoundedJson(response, maxResponseBytes); + return parse(value); } -export async function workspaceApiJson(path: string): Promise { - return requireJson(await fetch(path), path); +export async function workspaceApiJson( + path: string, + parse: (value: unknown) => T = (value) => value as T, + maxResponseBytes?: number, +): Promise { + return requireJson(await fetch(path), path, parse, maxResponseBytes); } export async function workspaceApiJsonWithBody( path: string, init: RequestInit, + parse: (value: unknown) => T = (value) => value as T, + maxResponseBytes?: number, ): Promise { - return requireJson( + return requireJson( await fetch(path, { headers: { "content-type": "application/json", @@ -202,6 +220,8 @@ export async function workspaceApiJsonWithBody( ...init, }), path, + parse, + maxResponseBytes, ); } diff --git a/web/workspace/src/lib/workspace/api/merge-requests.ts b/web/workspace/src/lib/workspace/api/merge-requests.ts index a8a68f37d..0373c51a6 100644 --- a/web/workspace/src/lib/workspace/api/merge-requests.ts +++ b/web/workspace/src/lib/workspace/api/merge-requests.ts @@ -1,48 +1,13 @@ -import type { MergeRequestListResponse } from "$lib/generated/ticket-api"; +import type { + MergeRequestDetailResponse, + MergeRequestListResponse, + MergeRequestThreadEvent, +} from "$lib/generated/ticket-api"; import { workspaceApiPath } from "./http"; -export type MergeRequestState = "open" | "merged" | "closed"; - -export type MergeRequestActor = { - runtime_id: string; - worker_id: string; - assignment_id: string; -}; - -export type MergeRequestThreadEvent = { - kind: string; - sequence: number; - at: string; - [key: string]: unknown; -}; - -export type MergeRequestRecord = { - merge_request_id: string; - workspace_id: string; - repository_key: string; - selector_from: string | null; - selector_to: string; - ticket_ids: string[]; - state: MergeRequestState; - opened_by: MergeRequestActor; - created_at: string; - updated_at: string; - thread: MergeRequestThreadEvent[]; -}; - -export type MergeRequestRefObservation = { - status: string; - ref: string | null; - observed_at: string; -}; - -export type MergeRequestDetail = MergeRequestRecord & { - source: MergeRequestRefObservation; - target: MergeRequestRefObservation; - linked_tickets: Array<{ ticket_id: string; key: string | null }>; -}; - +export type MergeRequestDetail = MergeRequestDetailResponse; export type MergeRequestListPage = MergeRequestListResponse; +export type { MergeRequestThreadEvent }; export function mergeRequestCollectionPath(workspaceId: string): string { return workspaceApiPath(workspaceId, "/merge-requests"); diff --git a/web/workspace/src/lib/workspace/api/repository-access-loader.ts b/web/workspace/src/lib/workspace/api/repository-access-loader.ts index 7908a8443..26c5384e5 100644 --- a/web/workspace/src/lib/workspace/api/repository-access-loader.ts +++ b/web/workspace/src/lib/workspace/api/repository-access-loader.ts @@ -1,5 +1,9 @@ import { error } from "@sveltejs/kit"; -import { RepositoryAccessSchemaError } from "./repository-access.ts"; +import { readBoundedJson } from "./http.ts"; +import { + REPOSITORY_ACCESS_MAX_RESPONSE_BYTES, + RepositoryAccessSchemaError, +} from "./repository-access.ts"; export async function loadRepositoryAccessJson( fetcher: typeof fetch, @@ -27,7 +31,10 @@ export async function loadRepositoryAccessJson( let payload: unknown; try { - payload = await response.json(); + payload = await readBoundedJson( + response, + REPOSITORY_ACCESS_MAX_RESPONSE_BYTES, + ); } catch { error(502, { message: "Repository Access returned an invalid JSON response.", diff --git a/web/workspace/src/lib/workspace/api/repository-access.ts b/web/workspace/src/lib/workspace/api/repository-access.ts index 6d78ed45d..e18c7b224 100644 --- a/web/workspace/src/lib/workspace/api/repository-access.ts +++ b/web/workspace/src/lib/workspace/api/repository-access.ts @@ -5,6 +5,10 @@ import type { RepositorySshPublicKey, } from "../../generated/repository-access-api.ts"; +export const REPOSITORY_ACCESS_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const MAX_COLLECTION_ITEMS = 1000; +const MAX_STRING_BYTES = 1024 * 1024; + export class RepositoryAccessSchemaError extends Error { constructor(path: string, expected: string) { super( @@ -165,7 +169,7 @@ function readRecord( ); if (unknownKey !== undefined) { throw new RepositoryAccessSchemaError( - `${path}.${unknownKey}`, + `${path}.${unknownKey.slice(0, 128)}`, "no unknown field", ); } @@ -173,7 +177,7 @@ function readRecord( } function readArray(value: unknown, path: string): unknown[] { - if (!Array.isArray(value)) { + if (!Array.isArray(value) || value.length > MAX_COLLECTION_ITEMS) { throw new RepositoryAccessSchemaError(path, "an array"); } return value; @@ -185,8 +189,11 @@ function readString( path: string, ): string { const value = record[key]; - if (typeof value !== "string") { - throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string"); + if ( + typeof value !== "string" || + new TextEncoder().encode(value).byteLength > MAX_STRING_BYTES + ) { + throw new RepositoryAccessSchemaError(`${path}.${key}`, "a bounded string"); } return value; } @@ -197,8 +204,15 @@ function readNullableString( path: string, ): string | null { const value = record[key]; - if (value !== null && typeof value !== "string") { - throw new RepositoryAccessSchemaError(`${path}.${key}`, "a string or null"); + if ( + value !== null && + (typeof value !== "string" || + new TextEncoder().encode(value).byteLength > MAX_STRING_BYTES) + ) { + throw new RepositoryAccessSchemaError( + `${path}.${key}`, + "a bounded string or null", + ); } return value; } @@ -210,10 +224,13 @@ function readStringArray( ): string[] { const values = readArray(record[key], `${path}.${key}`); values.forEach((value, index) => { - if (typeof value !== "string") { + if ( + typeof value !== "string" || + new TextEncoder().encode(value).byteLength > MAX_STRING_BYTES + ) { throw new RepositoryAccessSchemaError( `${path}.${key}[${index}]`, - "a string", + "a bounded string", ); } }); diff --git a/web/workspace/src/lib/workspace/api/runtime-connection.ts b/web/workspace/src/lib/workspace/api/runtime-connection.ts index ada1ff6aa..7f308e685 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/legacy-server-api"; +} from "$lib/generated/runtime-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 92cd1ba0a..b16355ed0 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -5,6 +5,7 @@ import type { RevokeRuntimeTrustKeyRequest, RuntimeConnectionDisplayState, RuntimeIdentityAuthority, + RuntimeManagementApiError, RuntimeManagementSummary, RuntimeRemovalOperationResponse, RuntimeRemovalOperationState, @@ -13,8 +14,6 @@ import type { RuntimeSourceSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry, - RuntimeTrustConflictKind, - RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus, @@ -23,12 +22,24 @@ import type { WorkspaceRuntimeBindingState, WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail, + WorkspaceRuntimeListResponse, WorkspaceRuntimeResource, -} from "$lib/generated/legacy-server-api.ts"; -import type { ListResponse } from "$lib/workspace/sidebar/types"; +} from "$lib/generated/runtime-api.ts"; import { workspaceApiPath } from "./http.ts"; -export type WorkspaceRuntimeList = ListResponse; +export type WorkspaceRuntimeList = Omit & { + diagnostics: Diagnostic[]; +}; +export type ValidatedWorkspaceRuntimeDetail = Omit< + WorkspaceRuntimeDetail, + "recent_audit" +> & { + recent_audit: RuntimeTrustAuditEntry[]; +}; +export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; +export type RuntimeTrustConflictResponse = RuntimeManagementApiError & { + error: RuntimeTrustConflictKind; +}; const LIMITS = { runtimeItems: 200, @@ -690,7 +701,7 @@ export function parseWorkspaceRuntimeList( export function parseWorkspaceRuntimeDetail( value: unknown, -): WorkspaceRuntimeDetail { +): ValidatedWorkspaceRuntimeDetail { const response = object(value, "Runtime detail response"); exactKeys( response, diff --git a/web/workspace/src/lib/workspace/api/runtime-workers.ts b/web/workspace/src/lib/workspace/api/runtime-workers.ts new file mode 100644 index 000000000..18878ce66 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/runtime-workers.ts @@ -0,0 +1,356 @@ +import type { + CleanupTargetKind, + CleanupWorkdirCandidate, + CleanupWorkdirCleanliness, + CleanupWorkdirFileStatus, + CleanupWorkerCandidate, + Diagnostic, + RuntimeCleanupExecutionResponse, + RuntimeCleanupExecutionResult, + RuntimeCleanupPlanResponse, + RuntimeWorkerLifecycleResult, + WorkerOperationState, + WorkerRetentionResponse, +} from "$lib/generated/runtime-api"; + +const MAX_STRING_BYTES = 4_096; +const MAX_CANDIDATES = 1_000; +const MAX_RESULTS = 2_000; +const MAX_DIAGNOSTICS = 64; +const encoder = new TextEncoder(); +type JsonObject = Record; + +const TARGET_KINDS = new Set([ + "worker_delete", + "workdir_clean_cleanup", + "workdir_dirty_discard", + "workdir_record_delete", +]); +const FILE_STATUSES = new Set([ + "pending", + "present", + "active", + "cleanup_pending", + "not_found", + "corrupted", + "failed", + "unknown", +]); +const CLEANLINESS = new Set([ + "clean", + "dirty", + "unknown", +]); + +export class RuntimeWorkerValidationError extends Error { + constructor(message: string) { + super(message.slice(0, 256)); + this.name = "RuntimeWorkerValidationError"; + } +} + +function fail(path: string, message: string): never { + throw new RuntimeWorkerValidationError(`${path} ${message}`); +} + +function object(value: unknown, path: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return fail(path, "must be an object"); + } + return value as JsonObject; +} + +function exactKeys( + value: JsonObject, + required: readonly string[], + optional: readonly string[], + path: string, +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail(`${path}.${key}`, "is not part of the wire contract"); + } + for (const key of required) { + if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required"); + } +} + +function boundedString(value: unknown, path: string, allowEmpty = false): string { + if (typeof value !== "string") return fail(path, "must be a string"); + if (!allowEmpty && value.length === 0) return fail(path, "must not be empty"); + if (encoder.encode(value).byteLength > MAX_STRING_BYTES) { + return fail(path, `must be at most ${MAX_STRING_BYTES} UTF-8 bytes`); + } + return value; +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") return fail(path, "must be a boolean"); + return value; +} + +function array(value: unknown, path: string, max: number): unknown[] { + if (!Array.isArray(value)) return fail(path, "must be an array"); + if (value.length > max) return fail(path, `must contain at most ${max} items`); + return value; +} + +function strings(value: unknown, path: string): string[] { + return array(value, path, MAX_CANDIDATES).map((entry, index) => + boundedString(entry, `${path}[${index}]`) + ); +} + +function optionalNullableString(value: unknown, path: string): string | null | undefined { + if (value === undefined || value === null) return value; + return boundedString(value, path); +} + +function optionalNullableSafeInteger(value: unknown, path: string): number | null | undefined { + if (value === undefined || value === null) return value; + if (!Number.isSafeInteger(value) || (value as number) < 0) { + return fail(path, "must be a non-negative safe integer"); + } + return value as number; +} + +function targetKind(value: unknown, path: string): CleanupTargetKind { + if (typeof value !== "string" || !TARGET_KINDS.has(value as CleanupTargetKind)) { + return fail(path, "has an unknown cleanup action"); + } + return value as CleanupTargetKind; +} + +function diagnostic(value: unknown, path: string): Diagnostic { + const item = object(value, path); + exactKeys(item, ["code", "severity", "message"], [], path); + const severity = boundedString(item.severity, `${path}.severity`); + if (severity !== "info" && severity !== "warning" && severity !== "error") { + fail(`${path}.severity`, "has an unknown value"); + } + return { + code: boundedString(item.code, `${path}.code`), + severity, + message: boundedString(item.message, `${path}.message`, true), + }; +} + +function diagnostics(value: unknown, path: string): Diagnostic[] { + return array(value, path, MAX_DIAGNOSTICS).map((entry, index) => + diagnostic(entry, `${path}[${index}]`) + ); +} + +function workerCandidate(value: unknown, path: string): CleanupWorkerCandidate { + const item = object(value, path); + exactKeys( + item, + [ + "target_id", + "action", + "worker_id", + "runtime_worker_id", + "runtime_id", + "reason", + "pinned", + "retention_state", + "linked_workdir_ids", + "running_linked", + ], + ["blocking_reason", "estimated_reclaim_bytes"], + path, + ); + return { + target_id: boundedString(item.target_id, `${path}.target_id`), + action: targetKind(item.action, `${path}.action`), + worker_id: boundedString(item.worker_id, `${path}.worker_id`), + runtime_worker_id: boundedString(item.runtime_worker_id, `${path}.runtime_worker_id`), + runtime_id: boundedString(item.runtime_id, `${path}.runtime_id`), + reason: boundedString(item.reason, `${path}.reason`, true), + blocking_reason: optionalNullableString(item.blocking_reason, `${path}.blocking_reason`), + pinned: boolean(item.pinned, `${path}.pinned`), + retention_state: boundedString(item.retention_state, `${path}.retention_state`), + linked_workdir_ids: strings(item.linked_workdir_ids, `${path}.linked_workdir_ids`), + running_linked: boolean(item.running_linked, `${path}.running_linked`), + estimated_reclaim_bytes: optionalNullableSafeInteger( + item.estimated_reclaim_bytes, + `${path}.estimated_reclaim_bytes`, + ), + }; +} + +function workdirCandidate(value: unknown, path: string): CleanupWorkdirCandidate { + const item = object(value, path); + exactKeys( + item, + [ + "target_id", + "action", + "workdir_id", + "runtime_id", + "repository_key", + "reason", + "linked_worker_ids", + "linked_running_worker_ids", + "running_linked", + "pinned_linked", + "file_status", + "cleanliness", + ], + ["blocking_reason", "estimated_reclaim_bytes"], + path, + ); + const fileStatus = boundedString(item.file_status, `${path}.file_status`); + const cleanliness = boundedString(item.cleanliness, `${path}.cleanliness`); + if (!FILE_STATUSES.has(fileStatus as CleanupWorkdirFileStatus)) { + fail(`${path}.file_status`, "has an unknown value"); + } + if (!CLEANLINESS.has(cleanliness as CleanupWorkdirCleanliness)) { + fail(`${path}.cleanliness`, "has an unknown value"); + } + return { + target_id: boundedString(item.target_id, `${path}.target_id`), + action: targetKind(item.action, `${path}.action`), + workdir_id: boundedString(item.workdir_id, `${path}.workdir_id`), + runtime_id: boundedString(item.runtime_id, `${path}.runtime_id`), + repository_key: boundedString(item.repository_key, `${path}.repository_key`), + reason: boundedString(item.reason, `${path}.reason`, true), + blocking_reason: optionalNullableString(item.blocking_reason, `${path}.blocking_reason`), + linked_worker_ids: strings(item.linked_worker_ids, `${path}.linked_worker_ids`), + linked_running_worker_ids: strings( + item.linked_running_worker_ids, + `${path}.linked_running_worker_ids`, + ), + running_linked: boolean(item.running_linked, `${path}.running_linked`), + pinned_linked: boolean(item.pinned_linked, `${path}.pinned_linked`), + file_status: fileStatus as CleanupWorkdirFileStatus, + cleanliness: cleanliness as CleanupWorkdirCleanliness, + estimated_reclaim_bytes: optionalNullableSafeInteger( + item.estimated_reclaim_bytes, + `${path}.estimated_reclaim_bytes`, + ), + }; +} + +export function parseWorkerRetentionResponse(value: unknown): WorkerRetentionResponse { + const item = object(value, "Worker retention response"); + exactKeys( + item, + ["workspace_id", "runtime_id", "worker_id", "pinned", "retention_state"], + [], + "Worker retention response", + ); + return { + workspace_id: boundedString(item.workspace_id, "Worker retention response.workspace_id"), + runtime_id: boundedString(item.runtime_id, "Worker retention response.runtime_id"), + worker_id: boundedString(item.worker_id, "Worker retention response.worker_id"), + pinned: boolean(item.pinned, "Worker retention response.pinned"), + retention_state: boundedString( + item.retention_state, + "Worker retention response.retention_state", + ), + }; +} + +const WORKER_OPERATION_STATES = new Set([ + "accepted", + "unsupported", + "rejected", +]); + +export function parseRuntimeWorkerLifecycleResult( + value: unknown, +): RuntimeWorkerLifecycleResult { + const item = object(value, "Runtime Worker lifecycle result"); + exactKeys( + item, + ["state", "runtime_id", "worker_id", "diagnostics"], + [], + "Runtime Worker lifecycle result", + ); + const state = boundedString(item.state, "Runtime Worker lifecycle result.state"); + if (!WORKER_OPERATION_STATES.has(state as WorkerOperationState)) { + fail("Runtime Worker lifecycle result.state", "has an unknown value"); + } + return { + state: state as WorkerOperationState, + runtime_id: boundedString( + item.runtime_id, + "Runtime Worker lifecycle result.runtime_id", + ), + worker_id: boundedString( + item.worker_id, + "Runtime Worker lifecycle result.worker_id", + ), + diagnostics: diagnostics( + item.diagnostics, + "Runtime Worker lifecycle result.diagnostics", + ), + }; +} + +export function parseRuntimeCleanupPlan(value: unknown): RuntimeCleanupPlanResponse { + const item = object(value, "Runtime cleanup plan"); + exactKeys( + item, + [ + "workspace_id", + "runtime_id", + "generated_at", + "revision", + "digest", + "workers", + "workdirs", + "diagnostics", + ], + [], + "Runtime cleanup plan", + ); + return { + workspace_id: boundedString(item.workspace_id, "Runtime cleanup plan.workspace_id"), + runtime_id: boundedString(item.runtime_id, "Runtime cleanup plan.runtime_id"), + generated_at: boundedString(item.generated_at, "Runtime cleanup plan.generated_at"), + revision: boundedString(item.revision, "Runtime cleanup plan.revision"), + digest: boundedString(item.digest, "Runtime cleanup plan.digest"), + workers: array(item.workers, "Runtime cleanup plan.workers", MAX_CANDIDATES).map( + (entry, index) => workerCandidate(entry, `Runtime cleanup plan.workers[${index}]`), + ), + workdirs: array(item.workdirs, "Runtime cleanup plan.workdirs", MAX_CANDIDATES).map( + (entry, index) => workdirCandidate(entry, `Runtime cleanup plan.workdirs[${index}]`), + ), + diagnostics: diagnostics(item.diagnostics, "Runtime cleanup plan.diagnostics"), + }; +} + +function executionResult(value: unknown, path: string): RuntimeCleanupExecutionResult { + const item = object(value, path); + exactKeys(item, ["target_id", "action", "status", "message"], [], path); + return { + target_id: boundedString(item.target_id, `${path}.target_id`), + action: targetKind(item.action, `${path}.action`), + status: boundedString(item.status, `${path}.status`), + message: boundedString(item.message, `${path}.message`, true), + }; +} + +export function parseRuntimeCleanupExecution( + value: unknown, +): RuntimeCleanupExecutionResponse { + const item = object(value, "Runtime cleanup execution"); + exactKeys( + item, + ["workspace_id", "runtime_id", "executed_at", "results", "plan_after", "diagnostics"], + [], + "Runtime cleanup execution", + ); + return { + workspace_id: boundedString(item.workspace_id, "Runtime cleanup execution.workspace_id"), + runtime_id: boundedString(item.runtime_id, "Runtime cleanup execution.runtime_id"), + executed_at: boundedString(item.executed_at, "Runtime cleanup execution.executed_at"), + results: array(item.results, "Runtime cleanup execution.results", MAX_RESULTS).map( + (entry, index) => executionResult(entry, `Runtime cleanup execution.results[${index}]`), + ), + plan_after: parseRuntimeCleanupPlan(item.plan_after), + diagnostics: diagnostics(item.diagnostics, "Runtime cleanup execution.diagnostics"), + }; +} diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts new file mode 100644 index 000000000..aa6ef8279 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -0,0 +1,246 @@ +declare const Deno: { + test(name: string, fn: () => Promise | void): void; +}; + +import { + parseMergeRequestDetailResponse, + parseObjectiveListResponse, + parseTicketDetail, + parseTicketListResponse, + parseTicketRecordRef, +} from "./ticket-browser.ts"; + +function assertEquals(actual: unknown, expected: unknown): void { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`Expected ${expectedJson}, received ${actualJson}`); + } +} + +function assertThrows(operation: () => unknown, message: string): void { + try { + operation(); + } catch (error) { + if (error instanceof Error && error.message.includes(message)) return; + throw error; + } + throw new Error(`Expected operation to throw: ${message}`); +} + +const page = { + has_more: false, + limit: 30, + next_cursor: null, + returned: 1, + sort: "updated_desc", + source_limit: null, + source_truncated: false, +}; + +Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => { + const parsed = parseTicketListResponse({ + workspace_id: "workspace-a", + limit: 30, + items: [{ + id: "ticket-id", + resource_key: "T-1", + title: "Ticket", + state: "ready", + priority: "P2", + updated_at: null, + queued_by: null, + queued_at: null, + workspace_action_priority: "ready", + record_source: "sqlite", + }], + page, + invalid_records: [], + record_authority: "sqlite", + }); + assertEquals(parsed.items[0]?.resource_key, "T-1"); +}); + +Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits", () => { + const fixture = { + workspace_id: "workspace-a", + limit: 30, + items: [], + page: { ...page, returned: 0 }, + invalid_records: [], + record_authority: "sqlite", + }; + assertThrows( + () => parseTicketListResponse({ ...fixture, stale: true }), + "unknown field stale", + ); + assertThrows( + () => parseTicketListResponse({ ...fixture, limit: 1001 }), + "0 through 1000", + ); +}); + +Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => { + const fixture = { + action_eligibility: { + blockers: [], + can_assign_orchestrator: false, + can_queue: false, + can_start_manual_coder: false, + can_unassign_orchestrator: false, + queue_tickets: [], + }, + artifact_count: 0, + artifacts: [], + assignment_diagnostics: [], + assignments: [], + body: "Body", + body_truncated: false, + current_coder: null, + event_count: 0, + event_page: { ...page, returned: 0 }, + events: [], + evidence: { + approved_current_subject: false, + complete_for_integration: false, + has_commit: false, + has_current_subject_ref: false, + has_merge_request: false, + has_review_request: false, + missing: [], + review_after_rescope: false, + review_status: null, + unresolved_request_changes: false, + }, + id: "ticket-id", + implementation_reports: [], + item_revision: "revision-1", + linked_objectives: [], + merge_request: null, + priority: "P2", + queued_at: null, + queued_by: null, + readiness: null, + record_source: "sqlite", + relations: { blockers: [], incoming: [], notices: [], outgoing: [] }, + resolution: null, + resource_key: "T-1", + risk_flags: [], + state: "planning", + targets: [ + { + repository_key: "docs", + ref_selector: "main", + access: "read_only", + }, + { + repository_key: "main", + ref_selector: "develop", + access: "read_write", + }, + ], + title: "Ticket", + }; + + const parsed = parseTicketDetail(fixture); + assertEquals(parsed.targets.length, 2); + assertEquals(parsed.targets[0]?.repository_key, "docs"); + assertEquals(parsed.targets[0]?.access, "read_only"); + assertEquals(parsed.targets[1]?.repository_key, "main"); + assertEquals(parsed.targets[1]?.access, "read_write"); + assertThrows( + () => + parseTicketDetail({ + ...fixture, + targets: [{ ...fixture.targets[0], access: "write" }], + }), + "targets[0].access is invalid", + ); + assertThrows( + () => + parseTicketDetail({ + ...fixture, + repository_key: "main", + ref_selector: "develop", + }), + "unknown field repository_key", + ); +}); + +Deno.test("Ticket Browser parser validates Ticket create responses", () => { + const parsed = parseTicketRecordRef({ + id: "ticket-id", + resource_key: "T-1", + slug: "ticket", + status: "Open", + }); + assertEquals(parsed.resource_key, "T-1"); + assertThrows( + () => parseTicketRecordRef({ ...parsed, status: "open" }), + "status is invalid", + ); +}); + +Deno.test("Ticket Browser parser accepts the generated Objective list shape", () => { + const parsed = parseObjectiveListResponse({ + workspace_id: "workspace-a", + limit: 100, + items: [{ + id: "objective-id", + resource_key: "O-1", + title: "Objective", + state: "active", + created_at: null, + updated_at: null, + summary: "Summary", + linked_tickets: ["T-1"], + record_source: "sqlite", + }], + invalid_records: [], + record_authority: "sqlite", + }); + assertEquals(parsed.items[0]?.resource_key, "O-1"); +}); + +Deno.test("Ticket Browser parser validates the canonical Merge Request thread", () => { + const parsed = parseMergeRequestDetailResponse({ + workspace_id: "workspace-a", + merge_request_id: "mr-1", + repository_key: "main", + state: "open", + selector_from: "work/T-1", + selector_to: "develop", + ticket_ids: ["ticket-id"], + created_at: "2026-09-22T00:00:00Z", + updated_at: "2026-09-22T00:00:00Z", + thread: [{ + kind: "review_requested", + event_id: "event-1", + sequence: 1, + subject_ref: "abc123", + requested_by: { runtime_id: "runtime-a", worker_id: "worker-a" }, + reviewer: { runtime_id: "runtime-a", worker_id: "reviewer-a" }, + created_at: "2026-09-22T00:00:00Z", + }], + source: { + status: "resolved", + ref: "abc123", + observed_at: "2026-09-22T00:00:00Z", + }, + target: { + status: "resolved", + ref: "def456", + observed_at: "2026-09-22T00:00:00Z", + }, + linked_tickets: [{ ticket_id: "ticket-id", key: "T-1" }], + }); + assertEquals(parsed.thread[0]?.kind, "review_requested"); + assertThrows( + () => + parseMergeRequestDetailResponse({ + ...parsed, + thread: [{ ...parsed.thread[0], unexpected: true }], + }), + "unknown field unexpected", + ); +}); diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts new file mode 100644 index 000000000..4e0b38ac8 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -0,0 +1,1294 @@ +import type { + InvalidProjectRecord, + MergeRequestDetailResponse, + MergeRequestLinkedTicketResponse, + MergeRequestListItem, + MergeRequestListResponse, + MergeRequestRefDiagnostic, + MergeRequestRefResponse, + MergeRequestThreadEvent, + MergeRequestWorkerIdentity, + ObjectiveDetail, + ObjectiveEventDetail, + ObjectiveLinkedTicketSummary, + ObjectiveListResponse, + ObjectiveResourceSummary, + ObjectiveSummary, + QueryPage, + ReviewFinding, + TicketActionEligibility, + TicketAssignmentPrincipal, + TicketAssignmentSummary, + TicketDetail, + TicketDetailDerivedRelation, + TicketDetailRelation, + TicketDetailRelationBlocker, + TicketDetailRelationNotice, + TicketDetailRelationView, + TicketEventDetail, + TicketEvidenceEvent, + TicketEvidenceSummary, + TicketListItemSummary, + TicketListResponse, + TicketMergeRequestSummary, + TicketQueueOutcome, + TicketRef, + TicketRoleAssignmentMutationResponse, + TicketRoleAssignmentRecord, + TicketRoleAssignmentSummary, + TicketTarget, +} from "$lib/generated/ticket-api.ts"; + +export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +export const TICKET_BROWSER_API_LOAD_POLICY = { + diagnosticLabel: "Ticket Browser API", + maxResponseBytes: TICKET_BROWSER_API_MAX_RESPONSE_BYTES, +} as const; + +const MAX_STRING_LENGTH = 262_144; +const MAX_COLLECTION_LENGTH = 4_096; +const MAX_MAP_ENTRIES = 4_096; + +function object(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + allowed: readonly string[], + label: string, +): void { + const unexpected = Object.keys(value).find((key) => !allowed.includes(key)); + if (unexpected !== undefined) { + throw new Error(`${label} contains unknown field ${unexpected}`); + } +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string`); + if (value.length > MAX_STRING_LENGTH) { + throw new Error(`${label} exceeds its length limit`); + } + return value; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); + return value; +} + +function integer( + value: unknown, + label: string, + minimum = 0, + maximum = Number.MAX_SAFE_INTEGER, +): number { + if ( + typeof value !== "number" || !Number.isSafeInteger(value) || + value < minimum || value > maximum + ) { + throw new Error( + `${label} must be an integer from ${minimum} through ${maximum}`, + ); + } + return value; +} + +function nullableString(value: unknown, label: string): string | null { + return value === null ? null : string(value, label); +} + +function optionalNullableString( + value: unknown, + label: string, +): string | null | undefined { + return value === undefined ? undefined : nullableString(value, label); +} + +function array( + value: unknown, + label: string, + parse: (item: unknown, label: string) => T, +): T[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + if (value.length > MAX_COLLECTION_LENGTH) { + throw new Error(`${label} exceeds its item limit`); + } + return value.map((item, index) => parse(item, `${label}[${index}]`)); +} + +function strings(value: unknown, label: string): string[] { + return array(value, label, string); +} + +function stringMap(value: unknown, label: string): Record { + const source = object(value, label); + const entries = Object.entries(source); + if (entries.length > MAX_MAP_ENTRIES) { + throw new Error(`${label} exceeds its entry limit`); + } + return Object.fromEntries( + entries.map(([key, item]) => [ + string(key, `${label} key`), + string(item, `${label}.${key}`), + ]), + ); +} + +function parseQueryPage(value: unknown, label: string): QueryPage { + const item = object(value, label); + exact(item, [ + "has_more", + "limit", + "next_cursor", + "returned", + "sort", + "source_limit", + "source_truncated", + ], label); + return { + has_more: boolean(item.has_more, `${label}.has_more`), + limit: integer(item.limit, `${label}.limit`), + next_cursor: optionalNullableString( + item.next_cursor, + `${label}.next_cursor`, + ), + returned: integer(item.returned, `${label}.returned`), + sort: string(item.sort, `${label}.sort`), + source_limit: item.source_limit === undefined + ? undefined + : item.source_limit === null + ? null + : integer(item.source_limit, `${label}.source_limit`), + source_truncated: boolean( + item.source_truncated, + `${label}.source_truncated`, + ), + }; +} + +function parseInvalidProjectRecord( + value: unknown, + label: string, +): InvalidProjectRecord { + const item = object(value, label); + exact(item, ["label", "reason"], label); + return { + label: string(item.label, `${label}.label`), + reason: string(item.reason, `${label}.reason`), + }; +} + +function parseTicketListItem( + value: unknown, + label: string, +): TicketListItemSummary { + const item = object(value, label); + exact(item, [ + "id", + "priority", + "queued_at", + "queued_by", + "record_source", + "resource_key", + "state", + "title", + "updated_at", + "workspace_action_priority", + ], label); + return { + id: string(item.id, `${label}.id`), + priority: string(item.priority, `${label}.priority`), + queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), + queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), + record_source: string(item.record_source, `${label}.record_source`), + resource_key: string(item.resource_key, `${label}.resource_key`), + state: string(item.state, `${label}.state`), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + workspace_action_priority: string( + item.workspace_action_priority, + `${label}.workspace_action_priority`, + ), + }; +} + +export function parseTicketListResponse(value: unknown): TicketListResponse { + const item = object(value, "Ticket list response"); + exact(item, [ + "invalid_records", + "items", + "limit", + "page", + "record_authority", + "workspace_id", + ], "Ticket list response"); + const limit = integer(item.limit, "Ticket list response.limit", 0, 1000); + return { + invalid_records: array( + item.invalid_records, + "Ticket list response.invalid_records", + parseInvalidProjectRecord, + ), + items: array( + item.items, + "Ticket list response.items", + parseTicketListItem, + ), + limit: limit as TicketListResponse["limit"], + page: parseQueryPage(item.page, "Ticket list response.page"), + record_authority: string( + item.record_authority, + "Ticket list response.record_authority", + ), + workspace_id: string( + item.workspace_id, + "Ticket list response.workspace_id", + ), + }; +} + +function parseTicketEvent(value: unknown, label: string): TicketEventDetail { + const item = object(value, label); + exact(item, [ + "at", + "attributes", + "author", + "body", + "event_ref", + "from", + "heading", + "kind", + "reason", + "references", + "sequence", + "state_field", + "status", + "to", + ], label); + return { + at: optionalNullableString(item.at, `${label}.at`), + attributes: stringMap(item.attributes, `${label}.attributes`), + author: optionalNullableString(item.author, `${label}.author`), + body: optionalNullableString(item.body, `${label}.body`), + event_ref: string(item.event_ref, `${label}.event_ref`), + from: optionalNullableString(item.from, `${label}.from`), + heading: optionalNullableString(item.heading, `${label}.heading`), + kind: string(item.kind, `${label}.kind`), + reason: optionalNullableString(item.reason, `${label}.reason`), + references: strings(item.references, `${label}.references`), + sequence: integer(item.sequence, `${label}.sequence`), + state_field: optionalNullableString( + item.state_field, + `${label}.state_field`, + ), + status: optionalNullableString(item.status, `${label}.status`), + to: optionalNullableString(item.to, `${label}.to`), + }; +} + +function parseTicketEvidenceEvent( + value: unknown, + label: string, +): TicketEvidenceEvent { + const item = object(value, label); + exact( + item, + ["at", "author", "event_ref", "excerpt", "kind", "sequence"], + label, + ); + return { + at: optionalNullableString(item.at, `${label}.at`), + author: optionalNullableString(item.author, `${label}.author`), + event_ref: string(item.event_ref, `${label}.event_ref`), + excerpt: string(item.excerpt, `${label}.excerpt`), + kind: string(item.kind, `${label}.kind`), + sequence: integer(item.sequence, `${label}.sequence`), + }; +} + +function parseTicketAssignmentPrincipal( + value: unknown, + label: string, +): TicketAssignmentPrincipal { + const item = object(value, label); + const kind = string(item.kind, `${label}.kind`); + if (kind === "user") { + exact(item, ["account_id", "kind"], label); + return { kind, account_id: string(item.account_id, `${label}.account_id`) }; + } + if (kind === "worker") { + exact(item, ["kind", "runtime_id", "worker_id"], label); + return { + kind, + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + worker_id: string(item.worker_id, `${label}.worker_id`), + }; + } + if (kind === "workspace_agent") { + exact(item, ["agent_key", "kind"], label); + return { kind, agent_key: string(item.agent_key, `${label}.agent_key`) }; + } + throw new Error(`${label}.kind is invalid`); +} + +function parseTicketAssignmentSummary( + value: unknown, + label: string, +): TicketAssignmentSummary { + const item = object(value, label); + exact(item, [ + "assignment_id", + "runtime_id", + "worker_id", + "worker_resource_key", + ], label); + return { + assignment_id: string(item.assignment_id, `${label}.assignment_id`), + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + worker_id: string(item.worker_id, `${label}.worker_id`), + worker_resource_key: optionalNullableString( + item.worker_resource_key, + `${label}.worker_resource_key`, + ), + }; +} + +function parseTicketRoleAssignmentSummary( + value: unknown, + label: string, +): TicketRoleAssignmentSummary { + const item = object(value, label); + exact(item, [ + "assigned_at", + "assigned_by", + "assignment_id", + "principal", + "role", + ], label); + return { + assigned_at: string(item.assigned_at, `${label}.assigned_at`), + assigned_by: string(item.assigned_by, `${label}.assigned_by`), + assignment_id: string(item.assignment_id, `${label}.assignment_id`), + principal: parseTicketAssignmentPrincipal( + item.principal, + `${label}.principal`, + ), + role: string(item.role, `${label}.role`), + }; +} + +function parseTicketRoleAssignmentRecord( + value: unknown, + label: string, +): TicketRoleAssignmentRecord { + const item = object(value, label); + exact(item, [ + "assigned_at", + "assigned_by", + "assignment_id", + "principal", + "role", + "ticket_id", + "workspace_id", + ], label); + const role = string(item.role, `${label}.role`); + if (!["orchestrator", "coder", "owner", "contributor"].includes(role)) { + throw new Error(`${label}.role is invalid`); + } + return { + assigned_at: string(item.assigned_at, `${label}.assigned_at`), + assigned_by: string(item.assigned_by, `${label}.assigned_by`), + assignment_id: string(item.assignment_id, `${label}.assignment_id`), + principal: parseTicketAssignmentPrincipal( + item.principal, + `${label}.principal`, + ), + role: role as TicketRoleAssignmentRecord["role"], + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + }; +} + +function parseActionEligibility( + value: unknown, + label: string, +): TicketActionEligibility { + const item = object(value, label); + exact(item, [ + "blockers", + "can_assign_orchestrator", + "can_queue", + "can_start_manual_coder", + "can_unassign_orchestrator", + "queue_tickets", + ], label); + return { + blockers: strings(item.blockers, `${label}.blockers`), + can_assign_orchestrator: boolean( + item.can_assign_orchestrator, + `${label}.can_assign_orchestrator`, + ), + can_queue: boolean(item.can_queue, `${label}.can_queue`), + can_start_manual_coder: boolean( + item.can_start_manual_coder, + `${label}.can_start_manual_coder`, + ), + can_unassign_orchestrator: boolean( + item.can_unassign_orchestrator, + `${label}.can_unassign_orchestrator`, + ), + queue_tickets: strings(item.queue_tickets, `${label}.queue_tickets`), + }; +} + +function parseTicketMergeRequestSummary( + value: unknown, + label: string, +): TicketMergeRequestSummary { + const item = object(value, label); + exact(item, [ + "current_subject_ref", + "merge_request_id", + "repository_key", + "review_excerpt", + "review_requested_at", + "review_status", + "review_subject_ref", + "review_submitted_at", + "selector_from", + "selector_to", + "state", + "updated_at", + ], label); + return { + current_subject_ref: optionalNullableString( + item.current_subject_ref, + `${label}.current_subject_ref`, + ), + merge_request_id: string( + item.merge_request_id, + `${label}.merge_request_id`, + ), + repository_key: string(item.repository_key, `${label}.repository_key`), + review_excerpt: optionalNullableString( + item.review_excerpt, + `${label}.review_excerpt`, + ), + review_requested_at: optionalNullableString( + item.review_requested_at, + `${label}.review_requested_at`, + ), + review_status: string(item.review_status, `${label}.review_status`), + review_subject_ref: optionalNullableString( + item.review_subject_ref, + `${label}.review_subject_ref`, + ), + review_submitted_at: optionalNullableString( + item.review_submitted_at, + `${label}.review_submitted_at`, + ), + selector_from: optionalNullableString( + item.selector_from, + `${label}.selector_from`, + ), + selector_to: string(item.selector_to, `${label}.selector_to`), + state: string(item.state, `${label}.state`), + updated_at: string(item.updated_at, `${label}.updated_at`), + }; +} + +function parseTicketEvidenceSummary( + value: unknown, + label: string, +): TicketEvidenceSummary { + const item = object(value, label); + exact(item, [ + "approved_current_subject", + "complete_for_integration", + "has_commit", + "has_current_subject_ref", + "has_merge_request", + "has_review_request", + "missing", + "review_after_rescope", + "review_status", + "unresolved_request_changes", + ], label); + return { + approved_current_subject: boolean( + item.approved_current_subject, + `${label}.approved_current_subject`, + ), + complete_for_integration: boolean( + item.complete_for_integration, + `${label}.complete_for_integration`, + ), + has_commit: boolean(item.has_commit, `${label}.has_commit`), + has_current_subject_ref: boolean( + item.has_current_subject_ref, + `${label}.has_current_subject_ref`, + ), + has_merge_request: boolean( + item.has_merge_request, + `${label}.has_merge_request`, + ), + has_review_request: boolean( + item.has_review_request, + `${label}.has_review_request`, + ), + missing: strings(item.missing, `${label}.missing`), + review_after_rescope: boolean( + item.review_after_rescope, + `${label}.review_after_rescope`, + ), + review_status: optionalNullableString( + item.review_status, + `${label}.review_status`, + ), + unresolved_request_changes: boolean( + item.unresolved_request_changes, + `${label}.unresolved_request_changes`, + ), + }; +} + +function parseRelation(value: unknown, label: string): TicketDetailRelation { + const item = object(value, label); + exact(item, [ + "at", + "author", + "kind", + "note", + "target", + "target_resource_key", + "ticket_id", + ], label); + return { + at: string(item.at, `${label}.at`), + author: string(item.author, `${label}.author`), + kind: string(item.kind, `${label}.kind`), + note: optionalNullableString(item.note, `${label}.note`), + target: string(item.target, `${label}.target`), + target_resource_key: optionalNullableString( + item.target_resource_key, + `${label}.target_resource_key`, + ), + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + }; +} + +function parseDerivedRelation( + value: unknown, + label: string, +): TicketDetailDerivedRelation { + const item = object(value, label); + exact(item, [ + "at", + "author", + "forward_kind", + "inverse_kind", + "note", + "source_resource_key", + "source_ticket", + ], label); + return { + at: string(item.at, `${label}.at`), + author: string(item.author, `${label}.author`), + forward_kind: string(item.forward_kind, `${label}.forward_kind`), + inverse_kind: string(item.inverse_kind, `${label}.inverse_kind`), + note: optionalNullableString(item.note, `${label}.note`), + source_resource_key: optionalNullableString( + item.source_resource_key, + `${label}.source_resource_key`, + ), + source_ticket: string(item.source_ticket, `${label}.source_ticket`), + }; +} + +function parseRelationBlocker( + value: unknown, + label: string, +): TicketDetailRelationBlocker { + const item = object(value, label); + exact(item, [ + "blocking_resource_key", + "blocking_state", + "blocking_ticket", + "note", + "reason_kind", + "relation_kind", + ], label); + return { + blocking_resource_key: optionalNullableString( + item.blocking_resource_key, + `${label}.blocking_resource_key`, + ), + blocking_state: string(item.blocking_state, `${label}.blocking_state`), + blocking_ticket: string(item.blocking_ticket, `${label}.blocking_ticket`), + note: optionalNullableString(item.note, `${label}.note`), + reason_kind: string(item.reason_kind, `${label}.reason_kind`), + relation_kind: string(item.relation_kind, `${label}.relation_kind`), + }; +} + +function parseRelationNotice( + value: unknown, + label: string, +): TicketDetailRelationNotice { + const item = object(value, label); + exact(item, ["kind", "message", "related_ticket"], label); + return { + kind: string(item.kind, `${label}.kind`), + message: string(item.message, `${label}.message`), + related_ticket: string(item.related_ticket, `${label}.related_ticket`), + }; +} + +function parseRelationView( + value: unknown, + label: string, +): TicketDetailRelationView { + const item = object(value, label); + exact(item, ["blockers", "incoming", "notices", "outgoing"], label); + return { + blockers: array(item.blockers, `${label}.blockers`, parseRelationBlocker), + incoming: array(item.incoming, `${label}.incoming`, parseDerivedRelation), + notices: array(item.notices, `${label}.notices`, parseRelationNotice), + outgoing: array(item.outgoing, `${label}.outgoing`, parseRelation), + }; +} + +function parseObjectiveLink(value: unknown, label: string) { + const item = object(value, label); + exact(item, ["id", "resource_key", "state", "title"], label); + return { + id: string(item.id, `${label}.id`), + resource_key: string(item.resource_key, `${label}.resource_key`), + state: string(item.state, `${label}.state`), + title: string(item.title, `${label}.title`), + }; +} + +function parseTicketTarget(value: unknown, label: string): TicketTarget { + const item = object(value, label); + exact(item, ["access", "ref_selector", "repository_key"], label); + const access = string(item.access, `${label}.access`); + if (access !== "read_only" && access !== "read_write") { + throw new Error(`${label}.access is invalid`); + } + return { + access, + ref_selector: optionalNullableString( + item.ref_selector, + `${label}.ref_selector`, + ), + repository_key: string(item.repository_key, `${label}.repository_key`), + }; +} + +export function parseTicketDetail(value: unknown): TicketDetail { + const label = "Ticket detail"; + const item = object(value, label); + exact(item, [ + "action_eligibility", + "artifact_count", + "artifacts", + "assignment_diagnostics", + "assignments", + "body", + "body_truncated", + "created_at", + "current_coder", + "event_count", + "event_page", + "events", + "evidence", + "id", + "implementation_reports", + "item_revision", + "linked_objectives", + "merge_request", + "priority", + "queued_at", + "queued_by", + "readiness", + "record_source", + "relations", + "resolution", + "resource_key", + "risk_flags", + "state", + "targets", + "title", + "updated_at", + ], label); + return { + action_eligibility: parseActionEligibility( + item.action_eligibility, + `${label}.action_eligibility`, + ), + artifact_count: integer(item.artifact_count, `${label}.artifact_count`), + artifacts: strings(item.artifacts, `${label}.artifacts`), + assignment_diagnostics: strings( + item.assignment_diagnostics, + `${label}.assignment_diagnostics`, + ), + assignments: array( + item.assignments, + `${label}.assignments`, + parseTicketRoleAssignmentSummary, + ), + body: string(item.body, `${label}.body`), + body_truncated: boolean(item.body_truncated, `${label}.body_truncated`), + created_at: optionalNullableString(item.created_at, `${label}.created_at`), + current_coder: item.current_coder === undefined + ? undefined + : item.current_coder === null + ? null + : parseTicketAssignmentSummary( + item.current_coder, + `${label}.current_coder`, + ), + event_count: integer(item.event_count, `${label}.event_count`), + event_page: parseQueryPage(item.event_page, `${label}.event_page`), + events: array(item.events, `${label}.events`, parseTicketEvent), + evidence: parseTicketEvidenceSummary(item.evidence, `${label}.evidence`), + id: string(item.id, `${label}.id`), + implementation_reports: array( + item.implementation_reports, + `${label}.implementation_reports`, + parseTicketEvidenceEvent, + ), + item_revision: string(item.item_revision, `${label}.item_revision`), + linked_objectives: array( + item.linked_objectives, + `${label}.linked_objectives`, + parseObjectiveLink, + ), + merge_request: item.merge_request === undefined + ? undefined + : item.merge_request === null + ? null + : parseTicketMergeRequestSummary( + item.merge_request, + `${label}.merge_request`, + ), + priority: string(item.priority, `${label}.priority`), + queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), + queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), + readiness: optionalNullableString(item.readiness, `${label}.readiness`), + record_source: string(item.record_source, `${label}.record_source`), + relations: parseRelationView(item.relations, `${label}.relations`), + resolution: optionalNullableString(item.resolution, `${label}.resolution`), + resource_key: string(item.resource_key, `${label}.resource_key`), + risk_flags: strings(item.risk_flags, `${label}.risk_flags`), + state: string(item.state, `${label}.state`), + targets: array(item.targets, `${label}.targets`, parseTicketTarget), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + }; +} + +export function parseTicketRecordRef(value: unknown): TicketRef { + const label = "Ticket create response"; + const item = object(value, label); + exact(item, ["id", "resource_key", "slug", "status"], label); + const status = string(item.status, `${label}.status`); + if (status !== "Open" && status !== "Closed") { + throw new Error(`${label}.status is invalid`); + } + return { + id: string(item.id, `${label}.id`), + resource_key: optionalNullableString( + item.resource_key, + `${label}.resource_key`, + ), + slug: string(item.slug, `${label}.slug`), + status, + }; +} + +export function parseTicketQueueOutcome(value: unknown): TicketQueueOutcome { + const item = object(value, "Ticket queue outcome"); + exact(item, ["queued_tickets", "requested_ticket"], "Ticket queue outcome"); + return { + queued_tickets: strings( + item.queued_tickets, + "Ticket queue outcome.queued_tickets", + ), + requested_ticket: string( + item.requested_ticket, + "Ticket queue outcome.requested_ticket", + ), + }; +} + +export function parseTicketRoleAssignmentMutationResponse( + value: unknown, +): TicketRoleAssignmentMutationResponse { + const label = "Ticket assignment response"; + const item = object(value, label); + exact(item, ["assignment", "ticket_id", "workspace_id"], label); + return { + assignment: item.assignment === undefined + ? undefined + : item.assignment === null + ? null + : parseTicketRoleAssignmentRecord(item.assignment, `${label}.assignment`), + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + }; +} + +function parseObjectiveSummary( + value: unknown, + label: string, +): ObjectiveSummary { + const item = object(value, label); + exact(item, [ + "created_at", + "id", + "linked_tickets", + "record_source", + "resource_key", + "state", + "summary", + "title", + "updated_at", + ], label); + return { + created_at: optionalNullableString(item.created_at, `${label}.created_at`), + id: string(item.id, `${label}.id`), + linked_tickets: strings(item.linked_tickets, `${label}.linked_tickets`), + record_source: string(item.record_source, `${label}.record_source`), + resource_key: string(item.resource_key, `${label}.resource_key`), + state: string(item.state, `${label}.state`), + summary: string(item.summary, `${label}.summary`), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + }; +} + +export function parseObjectiveListResponse( + value: unknown, +): ObjectiveListResponse { + const label = "Objective list response"; + const item = object(value, label); + exact(item, [ + "invalid_records", + "items", + "limit", + "record_authority", + "workspace_id", + ], label); + const limit = integer(item.limit, `${label}.limit`, 0, 1000); + return { + invalid_records: array( + item.invalid_records, + `${label}.invalid_records`, + parseInvalidProjectRecord, + ), + items: array(item.items, `${label}.items`, parseObjectiveSummary), + limit: limit as ObjectiveListResponse["limit"], + record_authority: string( + item.record_authority, + `${label}.record_authority`, + ), + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + }; +} + +function parseObjectiveEvent( + value: unknown, + label: string, +): ObjectiveEventDetail { + const item = object(value, label); + exact(item, ["body", "created_at", "event_ref", "kind"], label); + return { + body: optionalNullableString(item.body, `${label}.body`), + created_at: string(item.created_at, `${label}.created_at`), + event_ref: string(item.event_ref, `${label}.event_ref`), + kind: string(item.kind, `${label}.kind`), + }; +} + +function parseObjectiveLinkedTicket( + value: unknown, + label: string, +): ObjectiveLinkedTicketSummary { + return parseObjectiveLink(value, label); +} + +function parseObjectiveResource( + value: unknown, + label: string, +): ObjectiveResourceSummary { + const item = object(value, label); + exact(item, ["bytes", "media_type", "path", "updated_at"], label); + return { + bytes: integer(item.bytes, `${label}.bytes`), + media_type: optionalNullableString(item.media_type, `${label}.media_type`), + path: string(item.path, `${label}.path`), + updated_at: string(item.updated_at, `${label}.updated_at`), + }; +} + +export function parseObjectiveDetail(value: unknown): ObjectiveDetail { + const label = "Objective detail"; + const item = object(value, label); + exact(item, [ + "body", + "body_truncated", + "created_at", + "event_page", + "events", + "id", + "linked_ticket_summaries", + "linked_tickets", + "record_source", + "resource_key", + "resources", + "revision", + "state", + "title", + "updated_at", + ], label); + return { + body: string(item.body, `${label}.body`), + body_truncated: boolean(item.body_truncated, `${label}.body_truncated`), + created_at: optionalNullableString(item.created_at, `${label}.created_at`), + event_page: parseQueryPage(item.event_page, `${label}.event_page`), + events: array(item.events, `${label}.events`, parseObjectiveEvent), + id: string(item.id, `${label}.id`), + linked_ticket_summaries: array( + item.linked_ticket_summaries, + `${label}.linked_ticket_summaries`, + parseObjectiveLinkedTicket, + ), + linked_tickets: strings(item.linked_tickets, `${label}.linked_tickets`), + record_source: string(item.record_source, `${label}.record_source`), + resource_key: string(item.resource_key, `${label}.resource_key`), + resources: array( + item.resources, + `${label}.resources`, + parseObjectiveResource, + ), + revision: string(item.revision, `${label}.revision`), + state: string(item.state, `${label}.state`), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + }; +} + +function parseMergeRequestDiagnostic( + value: unknown, + label: string, +): MergeRequestRefDiagnostic { + const item = object(value, label); + exact(item, ["code", "message"], label); + return { + code: string(item.code, `${label}.code`), + message: string(item.message, `${label}.message`), + }; +} + +function parseMergeRequestListItem( + value: unknown, + label: string, +): MergeRequestListItem { + const item = object(value, label); + exact(item, [ + "ref_diagnostics", + "summary", + "thread_event_count", + "ticket_ids", + ], label); + return { + ref_diagnostics: item.ref_diagnostics === undefined ? undefined : array( + item.ref_diagnostics, + `${label}.ref_diagnostics`, + parseMergeRequestDiagnostic, + ), + summary: parseTicketMergeRequestSummary(item.summary, `${label}.summary`), + thread_event_count: integer( + item.thread_event_count, + `${label}.thread_event_count`, + ), + ticket_ids: strings(item.ticket_ids, `${label}.ticket_ids`), + }; +} + +export function parseMergeRequestListResponse( + value: unknown, +): MergeRequestListResponse { + const label = "Merge Request list response"; + const item = object(value, label); + exact(item, ["items", "next_cursor"], label); + return { + items: array(item.items, `${label}.items`, parseMergeRequestListItem), + next_cursor: optionalNullableString( + item.next_cursor, + `${label}.next_cursor`, + ), + }; +} + +function parseMergeRequestWorker( + value: unknown, + label: string, +): MergeRequestWorkerIdentity { + const item = object(value, label); + exact(item, ["runtime_id", "worker_id"], label); + return { + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + worker_id: string(item.worker_id, `${label}.worker_id`), + }; +} + +function parseReviewFinding(value: unknown, label: string): ReviewFinding { + const item = object(value, label); + exact(item, ["body", "code", "line", "path", "severity"], label); + const severity = string(item.severity, `${label}.severity`); + if (!["blocker", "major", "minor", "note"].includes(severity)) { + throw new Error(`${label}.severity is invalid`); + } + return { + body: string(item.body, `${label}.body`), + code: optionalNullableString(item.code, `${label}.code`), + line: item.line === undefined + ? undefined + : item.line === null + ? null + : integer(item.line, `${label}.line`, 0, 4_294_967_295), + path: optionalNullableString(item.path, `${label}.path`), + severity: severity as ReviewFinding["severity"], + }; +} + +function parseMergeRequestThreadEvent( + value: unknown, + label: string, +): MergeRequestThreadEvent { + const item = object(value, label); + const kind = string(item.kind, `${label}.kind`); + const base = ["created_at", "event_id", "kind", "sequence"]; + const eventBase = { + created_at: string(item.created_at, `${label}.created_at`), + event_id: string(item.event_id, `${label}.event_id`), + sequence: integer(item.sequence, `${label}.sequence`), + }; + if (kind === "review_requested") { + exact(item, [...base, "requested_by", "reviewer", "subject_ref"], label); + return { + ...eventBase, + kind, + requested_by: parseMergeRequestWorker( + item.requested_by, + `${label}.requested_by`, + ), + reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; + } + if (kind === "review") { + exact(item, [ + ...base, + "body", + "decision", + "findings", + "request_event_id", + "reviewer", + "subject_ref", + ], label); + const decision = string(item.decision, `${label}.decision`); + if (!["approve", "request_changes"].includes(decision)) { + throw new Error(`${label}.decision is invalid`); + } + return { + ...eventBase, + kind, + body: string(item.body, `${label}.body`), + decision: decision as "approve" | "request_changes", + findings: array(item.findings, `${label}.findings`, parseReviewFinding), + request_event_id: string( + item.request_event_id, + `${label}.request_event_id`, + ), + reviewer: parseMergeRequestWorker(item.reviewer, `${label}.reviewer`), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; + } + if (kind === "review_revoked") { + exact(item, [ + ...base, + "reason", + "review_event_id", + "revoked_by", + "subject_ref", + ], label); + return { + ...eventBase, + kind, + reason: string(item.reason, `${label}.reason`), + review_event_id: string(item.review_event_id, `${label}.review_event_id`), + revoked_by: parseMergeRequestWorker( + item.revoked_by, + `${label}.revoked_by`, + ), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; + } + if (kind === "review_cancelled") { + exact(item, [...base, "reason", "request_event_id", "subject_ref"], label); + return { + ...eventBase, + kind, + reason: string(item.reason, `${label}.reason`), + request_event_id: string( + item.request_event_id, + `${label}.request_event_id`, + ), + subject_ref: string(item.subject_ref, `${label}.subject_ref`), + }; + } + if (kind === "comment") { + exact(item, [...base, "author", "body"], label); + return { + ...eventBase, + kind, + author: parseMergeRequestWorker(item.author, `${label}.author`), + body: string(item.body, `${label}.body`), + }; + } + if (kind === "merge") { + exact(item, [ + ...base, + "approval_event_id", + "approved_source_ref", + "merged_by", + "operation_id", + "resolution", + "strategy", + "target_ref_after", + "target_ref_before", + ], label); + const strategy = string(item.strategy, `${label}.strategy`); + const resolution = string(item.resolution, `${label}.resolution`); + if (!["fast_forward", "merge"].includes(strategy)) { + throw new Error(`${label}.strategy is invalid`); + } + if (!["none", "clean", "conflicts_resolved"].includes(resolution)) { + throw new Error(`${label}.resolution is invalid`); + } + return { + ...eventBase, + kind, + approval_event_id: string( + item.approval_event_id, + `${label}.approval_event_id`, + ), + approved_source_ref: string( + item.approved_source_ref, + `${label}.approved_source_ref`, + ), + merged_by: parseMergeRequestWorker(item.merged_by, `${label}.merged_by`), + operation_id: string(item.operation_id, `${label}.operation_id`), + resolution: resolution as "none" | "clean" | "conflicts_resolved", + strategy: strategy as "fast_forward" | "merge", + target_ref_after: string( + item.target_ref_after, + `${label}.target_ref_after`, + ), + target_ref_before: string( + item.target_ref_before, + `${label}.target_ref_before`, + ), + }; + } + throw new Error(`${label}.kind is invalid`); +} + +function parseMergeRequestRef( + value: unknown, + label: string, +): MergeRequestRefResponse { + const item = object(value, label); + exact(item, ["diagnostic", "observed_at", "ref", "status"], label); + return { + diagnostic: item.diagnostic === undefined + ? undefined + : item.diagnostic === null + ? null + : parseMergeRequestDiagnostic(item.diagnostic, `${label}.diagnostic`), + observed_at: string(item.observed_at, `${label}.observed_at`), + ref: optionalNullableString(item.ref, `${label}.ref`), + status: string(item.status, `${label}.status`), + }; +} + +function parseLinkedTicket( + value: unknown, + label: string, +): MergeRequestLinkedTicketResponse { + const item = object(value, label); + exact(item, ["key", "ticket_id"], label); + return { + key: optionalNullableString(item.key, `${label}.key`), + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + }; +} + +export function parseMergeRequestDetailResponse( + value: unknown, +): MergeRequestDetailResponse { + const label = "Merge Request detail"; + const item = object(value, label); + exact(item, [ + "created_at", + "linked_tickets", + "merge_request_id", + "repository_key", + "selector_from", + "selector_to", + "source", + "state", + "target", + "thread", + "ticket_ids", + "updated_at", + "workspace_id", + ], label); + const state = string(item.state, `${label}.state`); + if (!["open", "merged", "closed"].includes(state)) { + throw new Error(`${label}.state is invalid`); + } + return { + created_at: string(item.created_at, `${label}.created_at`), + linked_tickets: array( + item.linked_tickets, + `${label}.linked_tickets`, + parseLinkedTicket, + ), + merge_request_id: string( + item.merge_request_id, + `${label}.merge_request_id`, + ), + repository_key: string(item.repository_key, `${label}.repository_key`), + selector_from: optionalNullableString( + item.selector_from, + `${label}.selector_from`, + ), + selector_to: string(item.selector_to, `${label}.selector_to`), + source: parseMergeRequestRef(item.source, `${label}.source`), + state: state as MergeRequestDetailResponse["state"], + target: parseMergeRequestRef(item.target, `${label}.target`), + thread: array(item.thread, `${label}.thread`, parseMergeRequestThreadEvent), + ticket_ids: strings(item.ticket_ids, `${label}.ticket_ids`), + updated_at: string(item.updated_at, `${label}.updated_at`), + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + }; +} diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index f085c4727..66d66371a 100644 --- a/web/workspace/src/lib/workspace/api/workers.ts +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -6,15 +6,20 @@ import type { CreateWorkspaceWorkerTicketAssignmentRequest, Diagnostic, DiagnosticSeverity, + RepositoryApiError, + RuntimeWorkerWorkdirAttachmentSummary, RuntimeWorkingDirectoryCleanupTarget, RuntimeWorkingDirectorySummary, - RuntimeWorkerWorkdirAttachmentSummary, WorkerCapabilitySummary, WorkerImplementationSummary, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, + WorkerListResponse, + WorkerStateSnapshot, + WorkerSummary, + WorkerWorkdirAttachmentSummary, WorkerWorkspaceSummary, WorkingDirectoryRepositoryOption, } from "$lib/generated/worker-launch-api"; @@ -45,8 +50,14 @@ function exact( } } +const MAX_STRING_LENGTH = 65_536; +const MAX_COLLECTION_LENGTH = 4_096; + function string(value: unknown, label: string): string { if (typeof value !== "string") throw new Error(`${label} must be a string`); + if (value.length > MAX_STRING_LENGTH) { + throw new Error(`${label} exceeds its length limit`); + } return value; } @@ -56,8 +67,8 @@ function boolean(value: unknown, label: string): boolean { } function number(value: unknown, label: string): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new Error(`${label} must be a finite number`); + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw new Error(`${label} must be a safe integer`); } return value; } @@ -72,6 +83,9 @@ function array( parse: (item: unknown, label: string) => T, ): T[] { if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + if (value.length > MAX_COLLECTION_LENGTH) { + throw new Error(`${label} exceeds its item limit`); + } return value.map((item, index) => parse(item, `${label}[${index}]`)); } @@ -165,6 +179,18 @@ function repositoryOption( }; } +export function parseWorkerApiError(value: unknown): RepositoryApiError { + const item = record(value, "Worker API error"); + exact(item, ["error", "message", "diagnostics"], "Worker API error"); + return { + error: string(item.error, "error"), + message: string(item.message, "message"), + diagnostics: item.diagnostics === undefined + ? [] + : array(item.diagnostics, "diagnostics", diagnostic), + }; +} + export function parseWorkerLaunchOptionsResponse( value: unknown, ): WorkerLaunchOptionsResponse { @@ -237,6 +263,181 @@ function capabilitySummary( }; } +function workerStateSnapshot( + value: unknown, + label: string, +): WorkerStateSnapshot { + const snapshot = record(value, label); + exact(snapshot, ["last_command_id", "state"], label); + const state = record(snapshot.state, `${label}.state`); + const kind = string(state.kind, `${label}.state.kind`); + if (kind === "idle") { + exact(state, ["kind"], `${label}.state`); + return { + last_command_id: unsignedInteger( + snapshot.last_command_id, + `${label}.last_command_id`, + ), + state: { kind: "idle" }, + }; + } + if (kind !== "busy") throw new Error(`${label}.state.kind is invalid`); + exact(state, ["kind", "state"], `${label}.state`); + const busy = record(state.state, `${label}.state.state`); + exact(busy, ["kind", "state"], `${label}.state.state`); + const busyKind = string(busy.kind, `${label}.state.state.kind`); + const busyState = string(busy.state, `${label}.state.state.state`); + if ( + (busyKind === "run" && + ["running", "pausing", "paused", "cancelling"].includes(busyState)) || + (busyKind === "maintenance" && busyState === "compacting") + ) { + return { + last_command_id: unsignedInteger( + snapshot.last_command_id, + `${label}.last_command_id`, + ), + state: { + kind: "busy", + state: busyKind === "run" + ? { + kind: "run", + state: busyState as "running" | "pausing" | "paused" | "cancelling", + } + : { kind: "maintenance", state: "compacting" }, + }, + }; + } + throw new Error(`${label}.state.state is invalid`); +} + +function workerWorkdirAttachment( + value: unknown, + label: string, +): WorkerWorkdirAttachmentSummary { + const item = record(value, label); + exact(item, ["alias", "working_directory"], label); + return { + alias: string(item.alias, `${label}.alias`), + working_directory: parseWorkingDirectorySummary(item.working_directory), + }; +} + +export type ParsedWorkerSummary = + & Omit< + WorkerSummary, + "display_name" | "tags" | "diagnostics" + > + & { + display_name: string; + tags: string[]; + diagnostics: Diagnostic[]; + }; + +export type ParsedWorkerListResponse = Omit & { + items: ParsedWorkerSummary[]; +}; + +export function parseWorkerSummary( + value: unknown, + label = "Worker summary", +): ParsedWorkerSummary { + const item = record(value, label); + exact( + item, + [ + "runtime_id", + "worker_id", + "resource_key", + "host_id", + "display_name", + "label", + "profile", + "singleton_key", + "tags", + "workspace", + "state", + "worker_state", + "last_seen_at", + "pinned", + "retention_state", + "implementation", + "capabilities", + "workdir_attachments", + "diagnostics", + ], + label, + ); + return { + runtime_id: string(item.runtime_id, `${label}.runtime_id`), + worker_id: string(item.worker_id, `${label}.worker_id`), + resource_key: string(item.resource_key, `${label}.resource_key`), + host_id: string(item.host_id, `${label}.host_id`), + display_name: item.display_name === undefined + ? "" + : string(item.display_name, `${label}.display_name`), + label: string(item.label, `${label}.label`), + profile: optional(item.profile, `${label}.profile`, string), + singleton_key: optional( + item.singleton_key, + `${label}.singleton_key`, + string, + ), + tags: item.tags === undefined + ? [] + : array(item.tags, `${label}.tags`, string), + workspace: workspaceSummary(item.workspace, `${label}.workspace`), + state: string(item.state, `${label}.state`), + worker_state: optional( + item.worker_state, + `${label}.worker_state`, + workerStateSnapshot, + ), + last_seen_at: optional(item.last_seen_at, `${label}.last_seen_at`, string), + pinned: item.pinned === undefined + ? false + : boolean(item.pinned, `${label}.pinned`), + retention_state: item.retention_state === undefined + ? "" + : string(item.retention_state, `${label}.retention_state`), + implementation: implementationSummary( + item.implementation, + `${label}.implementation`, + ), + capabilities: capabilitySummary(item.capabilities, `${label}.capabilities`), + workdir_attachments: item.workdir_attachments === undefined + ? undefined + : array( + item.workdir_attachments, + `${label}.workdir_attachments`, + workerWorkdirAttachment, + ), + diagnostics: item.diagnostics === undefined + ? [] + : array(item.diagnostics, `${label}.diagnostics`, diagnostic), + }; +} + +export function parseWorkerListResponse( + value: unknown, +): ParsedWorkerListResponse { + const item = record(value, "Worker list response"); + exact( + item, + ["workspace_id", "limit", "items", "source", "diagnostics"], + "Worker list response", + ); + return { + workspace_id: string(item.workspace_id, "workspace_id"), + limit: unsignedInteger(item.limit, "limit"), + items: array(item.items, "items", parseWorkerSummary), + source: string(item.source, "source"), + diagnostics: item.diagnostics === undefined + ? [] + : array(item.diagnostics, "diagnostics", diagnostic), + }; +} + function runtimeCleanupTarget( value: unknown, label: string, diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 4287c9a31..90516e893 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -138,15 +138,18 @@ async function fetchJson( if (!response.ok) { let detail = `${response.status} ${response.statusText}`.trim(); try { - const body = await response.json(); - if (typeof body?.message === "string") detail = body.message; - else if (typeof body?.error === "string") detail = body.error; + const body: unknown = await response.json(); + if (typeof body === "object" && body !== null && !Array.isArray(body)) { + const record = body as Record; + if (typeof record.message === "string") detail = record.message.slice(0, 512); + else if (typeof record.error === "string") detail = record.error.slice(0, 512); + } } catch { // Preserve the bounded status text when the Backend did not return JSON. } throw new WorkspaceCatalogError(response.status, detail); } - return await response.json() as unknown; + return await response.json(); } function errorMessage(error: unknown): string { diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index 0665f423b..e7e7af8e8 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -1,10 +1,5 @@ import type { ApiResult } from "$lib/workspace/api/http"; import type { - GitCommitSummary, - RepositoryLogResponse, - RepositorySshConnectionProbeResponse, - RepositorySshConnectionTrustState, - RepositorySshHostKeyCandidate, WorkspaceAuthConfig, WorkspaceCatalogListResponse, WorkspaceCreateResponse, @@ -24,22 +19,25 @@ import type { import type { CreateWorkspaceRepositoryResponse, Diagnostic, + GitCommitSummary, GitRemoteSummary, GitRepositorySummary, + HostListResponse, + HostSummary, RepositoryApiError, RepositoryDetailResponse, RepositoryDiagnostic, RepositoryListResponse, + RepositoryLogResponse, RepositorySource, RepositorySourceKind, + RepositorySshConnectionProbeResponse, + RepositorySshConnectionTrustState, + RepositorySshHostKeyCandidate, RepositorySummary, } from "$lib/generated/repository-api.ts"; export type { - GitCommitSummary, - RepositoryLogResponse, - RepositorySshConnectionProbeResponse, - RepositorySshHostKeyCandidate, WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceDeletionOperationResponse, @@ -51,13 +49,19 @@ export type { export type { CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, + GitCommitSummary, GitRemoteSummary, GitRepositorySummary, + HostListResponse, + HostSummary, RepositoryApiError, RepositoryDetailResponse, RepositoryListResponse, + RepositoryLogResponse, RepositorySource, RepositorySourceKind, + RepositorySshConnectionProbeResponse, + RepositorySshHostKeyCandidate, RepositorySummary, } from "$lib/generated/repository-api.ts"; @@ -395,6 +399,27 @@ function workspaceRepositoryRecord( }; } +function workspaceString(value: unknown, path: string): string { + const parsed = string(value, path); + if (parsed.length > REPOSITORY_API_LIMITS.maxStringCodeUnits) { + throw new Error(`${path} exceeds the Workspace API string limit`); + } + return parsed; +} + +function workspaceDiagnostics(value: unknown, path: string): Diagnostic[] { + const entries = array(value, path); + if (entries.length > 100) { + throw new Error(`${path} exceeds the Workspace API collection limit`); + } + return entries.map((entry, index) => { + const parsed = diagnostic(entry, `${path}[${index}]`); + workspaceString(parsed.code, `${path}[${index}].code`); + workspaceString(parsed.message, `${path}[${index}].message`); + return parsed; + }); +} + function extensionPoint( value: unknown, path: string, @@ -402,12 +427,9 @@ function extensionPoint( const item = object(value, path); exactKeys(item, ["status", "note", "diagnostics"], path); return { - status: string(item.status, `${path}.status`), - note: string(item.note, `${path}.note`), - diagnostics: array(item.diagnostics, `${path}.diagnostics`).map(( - entry, - index, - ) => diagnostic(entry, `${path}.diagnostics[${index}]`)), + status: workspaceString(item.status, `${path}.status`), + note: workspaceString(item.note, `${path}.note`), + diagnostics: workspaceDiagnostics(item.diagnostics, `${path}.diagnostics`), }; } @@ -423,7 +445,7 @@ function extensionPoints( "companion_console", ], path); return { - store: string(item.store, `${path}.store`), + store: workspaceString(item.store, `${path}.store`), event_stream: extensionPoint(item.event_stream, `${path}.event_stream`), host_worker_bridge: extensionPoint( item.host_worker_bridge, @@ -447,13 +469,16 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig { ); return { Passkey: { - rp_id: string(passkey.rp_id, `${path}.Passkey.rp_id`), - origin: string(passkey.origin, `${path}.Passkey.origin`), - public_base_url: string( + rp_id: workspaceString(passkey.rp_id, `${path}.Passkey.rp_id`), + origin: workspaceString(passkey.origin, `${path}.Passkey.origin`), + public_base_url: workspaceString( passkey.public_base_url, `${path}.Passkey.public_base_url`, ), - cookie_name: string(passkey.cookie_name, `${path}.Passkey.cookie_name`), + cookie_name: workspaceString( + passkey.cookie_name, + `${path}.Passkey.cookie_name`, + ), }, }; } @@ -575,23 +600,27 @@ export function parseWorkspaceResponse(value: unknown): WorkspaceResponse { ], "workspace response", ); + const schemaVersion = integer( + response.schema_version, + "workspace response.schema_version", + ); + if (schemaVersion < 0) { + throw new Error("workspace response.schema_version must be non-negative"); + } return { - workspace_id: string( + workspace_id: workspaceString( response.workspace_id, "workspace response.workspace_id", ), - display_name: string( + display_name: workspaceString( response.display_name, "workspace response.display_name", ), - record_authority: string( + record_authority: workspaceString( response.record_authority, "workspace response.record_authority", ), - schema_version: integer( - response.schema_version, - "workspace response.schema_version", - ), + schema_version: schemaVersion, auth: authConfig(response.auth, "workspace response.auth"), permissions: permissions( response.permissions, @@ -1069,6 +1098,77 @@ export function parseWorkspaceDeletionOperationResponse( }; } +export function parseHostListResponse(value: unknown): HostListResponse { + const response = object(value, "host list response"); + exactKeys( + response, + ["workspace_id", "limit", "items", "source", "diagnostics"], + "host list response", + ); + const limit = repositorySourceRevision( + response.limit, + "host list response.limit", + ); + return { + workspace_id: repositoryString( + response.workspace_id, + "host list response.workspace_id", + ), + limit, + items: repositoryArray(response.items, "host list response.items").map( + (item, index) => parseHostSummary(item, `host list response.items[${index}]`), + ), + source: repositoryString(response.source, "host list response.source"), + diagnostics: repositoryArray( + response.diagnostics, + "host list response.diagnostics", + ).map((item, index) => + repositoryApiDiagnostic( + item, + `host list response.diagnostics[${index}]`, + ) + ), + }; +} + +function parseHostSummary(value: unknown, path: string): HostSummary { + const host = object(value, path); + exactKeys( + host, + [ + "runtime_id", + "host_id", + "label", + "kind", + "status", + "observed_at", + "last_seen_at", + "os", + "arch", + "diagnostics", + ], + path, + ); + return { + runtime_id: repositoryString(host.runtime_id, `${path}.runtime_id`), + host_id: repositoryString(host.host_id, `${path}.host_id`), + label: repositoryString(host.label, `${path}.label`), + kind: repositoryString(host.kind, `${path}.kind`), + status: repositoryString(host.status, `${path}.status`), + observed_at: repositoryString(host.observed_at, `${path}.observed_at`), + last_seen_at: optionalNullableRepositoryString( + host.last_seen_at, + `${path}.last_seen_at`, + ), + os: repositoryString(host.os, `${path}.os`), + arch: repositoryString(host.arch, `${path}.arch`), + diagnostics: repositoryArray(host.diagnostics, `${path}.diagnostics`).map( + (item, index) => + repositoryApiDiagnostic(item, `${path}.diagnostics[${index}]`), + ), + }; +} + export function parseRepositoryLogResponse( value: unknown, ): RepositoryLogResponse { diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index dfa09dfaa..479ba0b9b 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -1,20 +1,267 @@ import type { ConfigCommitRequest, + ConfigContentType, ConfigEntry, + ConfigProjectionValidator, + ConfigSchemaContribution, ConfigTreeSnapshot, + ToolchainContract, + WorkspaceConfigSchemaBundle, WorkspaceConfigTreeResponse, } from "./types.ts"; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +const MAX_ERROR_BYTES = 4 * 1024; +const MAX_ENTRY_COUNT = 256; +const MAX_ENTRY_BYTES = 256 * 1024; +const MAX_PATH_BYTES = 512; +const MAX_STRING_BYTES = 4 * 1024; +const MAX_DIGEST_BYTES = 256; +const MAX_COLLECTION_ITEMS = 256; + +export class ConfigSourceApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ConfigSourceApiError"; + } +} + +type JsonRecord = Record; + +type Parser = (value: unknown) => T; + function sourceTreeUrl(workspaceId: string): string { return `/api/w/${encodeURIComponent(workspaceId)}/config/source-tree`; } -async function readJson(response: Response): Promise { - if (!response.ok) { - const body = await response.text(); - throw new Error(body || `${response.status} ${response.statusText}`); +async function readJson( + response: Response, + parser: Parser, +): Promise { + const limit = response.ok ? MAX_RESPONSE_BYTES : MAX_ERROR_BYTES; + const declared = response.headers.get("content-length"); + if (declared !== null) { + const bytes = Number(declared); + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > limit) { + throw new ConfigSourceApiError( + response.ok + ? "Workspace configuration response is too large." + : "Workspace configuration request failed.", + response.ok ? 502 : response.status, + ); + } } - return await response.json() as T; + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > limit) { + throw new ConfigSourceApiError( + response.ok + ? "Workspace configuration response is too large." + : "Workspace configuration request failed.", + response.ok ? 502 : response.status, + ); + } + const text = new TextDecoder().decode(bytes); + if (!response.ok) { + throw new ConfigSourceApiError( + text || `${response.status} ${response.statusText}`, + response.status, + ); + } + let value: unknown; + try { + value = JSON.parse(text) as unknown; + } catch { + throw schemaError(); + } + return parser(value); +} + +function schemaError(): ConfigSourceApiError { + return new ConfigSourceApiError( + "Workspace configuration returned an invalid response.", + 502, + ); +} + +function objectValue(value: unknown): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw schemaError(); + } + return value as JsonRecord; +} + +function record( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw schemaError(); + } + const result = value as JsonRecord; + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !(key in result)) || + Object.keys(result).some((key) => !allowed.has(key)) + ) { + throw schemaError(); + } + return result; +} + +function boundedString(value: unknown, maxBytes = MAX_STRING_BYTES): string { + if ( + typeof value !== "string" || + new TextEncoder().encode(value).byteLength > maxBytes + ) { + throw schemaError(); + } + return value; +} + +function safeInteger(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw schemaError(); + } + return value as number; +} + +function boundedArray(value: unknown): unknown[] { + if (!Array.isArray(value) || value.length > MAX_COLLECTION_ITEMS) { + throw schemaError(); + } + return value; +} + +function parseContentType(value: unknown): ConfigContentType { + if (value !== "decodal" && value !== "text") throw schemaError(); + return value; +} + +export function parseConfigEntry(value: unknown): ConfigEntry { + const item = record(value, [ + "path", + "content_type", + "content", + "content_digest", + ]); + return { + path: boundedString(item.path, MAX_PATH_BYTES), + content_type: parseContentType(item.content_type), + content: boundedString(item.content, MAX_ENTRY_BYTES), + content_digest: boundedString(item.content_digest, MAX_DIGEST_BYTES), + }; +} + +function parseStringMap(value: unknown): Record { + const item = objectValue(value); + const entries = Object.entries(item); + if (entries.length > MAX_COLLECTION_ITEMS) throw schemaError(); + const result: Record = {}; + for (const [key, entry] of entries) { + const boundedKey = boundedString(key, MAX_PATH_BYTES); + result[boundedKey] = boundedString(entry, MAX_PATH_BYTES); + } + return result; +} + +function parseProjectionValidator(value: unknown): ConfigProjectionValidator { + const item = record(value, ["kind", "namespace"], ["key_aliases"]); + if (item.kind !== "static_template_catalog") throw schemaError(); + return { + kind: "static_template_catalog", + namespace: boundedString(item.namespace, MAX_PATH_BYTES), + key_aliases: item.key_aliases === undefined + ? {} + : parseStringMap(item.key_aliases), + }; +} + +function parseContribution(value: unknown): ConfigSchemaContribution { + const item = record( + value, + ["provider_id", "namespace", "version", "source", "source_digest"], + ["projection_validator"], + ); + const projection = item.projection_validator === undefined + ? undefined + : parseProjectionValidator(item.projection_validator); + return { + provider_id: boundedString(item.provider_id), + namespace: boundedString(item.namespace), + version: boundedString(item.version), + source: boundedString(item.source), + ...(projection === undefined ? {} : { projection_validator: projection }), + source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES), + }; +} + +function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle { + const item = record(value, ["contributions", "source", "fingerprint"]); + return { + contributions: boundedArray(item.contributions).map(parseContribution), + source: boundedString(item.source), + fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES), + }; +} + +function parseToolchainContract(value: unknown): ToolchainContract { + const item = record(value, [ + "contract_version", + "decodal_version", + "schema_version", + "entrypoints", + "import_policy_version", + "schema_bundle", + "fingerprint", + ]); + return { + contract_version: safeInteger(item.contract_version), + decodal_version: boundedString(item.decodal_version), + schema_version: safeInteger(item.schema_version), + entrypoints: boundedArray(item.entrypoints).map((entry) => + boundedString(entry, MAX_PATH_BYTES) + ), + import_policy_version: safeInteger(item.import_policy_version), + schema_bundle: parseSchemaBundle(item.schema_bundle), + fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES), + }; +} + +export function parseConfigTreeSnapshot(value: unknown): ConfigTreeSnapshot { + const item = record(value, ["revision", "digest", "entries"]); + const entriesRecord = objectValue(item.entries); + const entries = Object.entries(entriesRecord); + if (entries.length > MAX_ENTRY_COUNT) throw schemaError(); + const parsedEntries: Record = {}; + for (const [path, entry] of entries) { + const boundedPath = boundedString(path, MAX_PATH_BYTES); + const parsed = parseConfigEntry(entry); + if (parsed.path !== boundedPath) throw schemaError(); + parsedEntries[boundedPath] = parsed; + } + return { + revision: safeInteger(item.revision), + digest: boundedString(item.digest, MAX_DIGEST_BYTES), + entries: parsedEntries, + }; +} + +export function parseWorkspaceConfigTreeResponse( + value: unknown, +): WorkspaceConfigTreeResponse { + const item = record(value, ["snapshot", "contract", "projection_digest"]); + return { + snapshot: parseConfigTreeSnapshot(item.snapshot), + contract: parseToolchainContract(item.contract), + projection_digest: boundedString( + item.projection_digest, + MAX_DIGEST_BYTES, + ), + }; } export async function fetchConfigTree( @@ -25,6 +272,7 @@ export async function fetchConfigTree( await fetcher(sourceTreeUrl(workspaceId), { headers: { accept: "application/json" }, }), + parseWorkspaceConfigTreeResponse, ); } @@ -38,6 +286,7 @@ export async function fetchConfigEntry( `${sourceTreeUrl(workspaceId)}/entries/${encodeURIComponent(path)}`, { headers: { accept: "application/json" } }, ), + parseConfigEntry, ); } @@ -50,6 +299,7 @@ export async function fetchConfigRevision( await fetcher(`${sourceTreeUrl(workspaceId)}/revisions/${revision}`, { headers: { accept: "application/json" }, }), + parseConfigTreeSnapshot, ); } @@ -64,5 +314,6 @@ export async function commitConfigTree( headers: { "content-type": "application/json" }, body: JSON.stringify(request), }), + parseWorkspaceConfigTreeResponse, ); } diff --git a/web/workspace/src/lib/workspace/config-source/types.ts b/web/workspace/src/lib/workspace/config-source/types.ts index 73e44afe9..4559c1960 100644 --- a/web/workspace/src/lib/workspace/config-source/types.ts +++ b/web/workspace/src/lib/workspace/config-source/types.ts @@ -1,16 +1,21 @@ -// Core and HTTP DTOs are generated from Rust with ts-rs. -export type { ConfigContentType } from "./generated/types/ConfigContentType.ts"; +// Workspace REST DTOs are generated from the server-api Rust authority. +export type { + ConfigCommitRequest, + ConfigContentType, + ConfigEntry, + ConfigProjectionValidator, + ConfigSchemaContribution, + ConfigTreeChange, + ConfigTreeSnapshot, + ToolchainContract, + WorkspaceConfigSchemaBundle, + WorkspaceConfigTreeResponse, +} from "$lib/generated/legacy-server-api.ts"; + +// Browser-local Decodal/WASM projections remain owned by config-source. export type { ConfigDiagnostic } from "./generated/types/ConfigDiagnostic.ts"; export type { ConfigDiagnosticLabel } from "./generated/types/ConfigDiagnosticLabel.ts"; -export type { ConfigEntry } from "./generated/types/ConfigEntry.ts"; export type { ConfigSpan } from "./generated/types/ConfigSpan.ts"; -export type { ConfigTreeChange } from "./generated/types/ConfigTreeChange.ts"; -export type { ConfigSchemaContribution } from "./generated/types/ConfigSchemaContribution.ts"; -export type { WorkspaceConfigSchemaBundle } from "./generated/types/WorkspaceConfigSchemaBundle.ts"; -export type { ConfigTreeSnapshot } from "./generated/types/ConfigTreeSnapshot.ts"; export type { EvaluatedProjection } from "./generated/types/EvaluatedProjection.ts"; export type { EvaluationResult } from "./generated/types/EvaluationResult.ts"; -export type { ToolchainContract } from "./generated/types/ToolchainContract.ts"; export type { VirtualPath } from "./generated/types/VirtualPath.ts"; -export type { ConfigCommitRequest } from "./generated/types/ConfigCommitRequest.ts"; -export type { WorkspaceConfigState as WorkspaceConfigTreeResponse } from "./generated/types/WorkspaceConfigState.ts"; diff --git a/web/workspace/src/lib/workspace/console/composer-attachments.ts b/web/workspace/src/lib/workspace/console/composer-attachments.ts index 45d179a9a..4d4e557b5 100644 --- a/web/workspace/src/lib/workspace/console/composer-attachments.ts +++ b/web/workspace/src/lib/workspace/console/composer-attachments.ts @@ -1,3 +1,7 @@ +import type { + AttachmentUploadGrantResponse, + WorkerFileUploadResponse, +} from "$lib/generated/runtime-api.ts"; import type { UploadedFileRef } from "$lib/generated/protocol.ts"; export const MAX_UPLOADED_FILE_BYTES = 10 * 1024 * 1024; @@ -133,15 +137,24 @@ export function uploadAttachment( function isUploadGrantResponse( value: unknown, -): value is { upload_id: string; expires_at_ms: number } { - return !!value && typeof value === "object" && - "upload_id" in value && typeof value.upload_id === "string" && - "expires_at_ms" in value && typeof value.expires_at_ms === "number"; +): value is AttachmentUploadGrantResponse { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const keys = Object.keys(value); + if ( + keys.length !== 2 || !keys.includes("upload_id") || + !keys.includes("expires_at_ms") + ) { + return false; + } + const grant = value as Record; + return typeof grant.upload_id === "string" && grant.upload_id.length > 0 && + grant.upload_id.length <= 256 && Number.isSafeInteger(grant.expires_at_ms) && + (grant.expires_at_ms as number) >= 0; } function isUploadedFileResponse( value: unknown, -): value is { file: UploadedFileRef } { +): value is WorkerFileUploadResponse { if (!value || typeof value !== "object" || !("file" in value)) return false; const file = value.file; return !!file && typeof file === "object" && diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 98ebf4397..16cf57efa 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -397,7 +397,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Tickets, Objectives, and Merge Requests should each be a single primary sidebar link", ); assert( - ticketsLoad.includes("Object.entries(LANE_STATES)") && + ticketsLoad.includes("loadLane(fetch, workspaceId") && ticketsLoad.includes('limit: "30"') && ticketsLoad.includes('states: states.join(",")') && ticketsLoad.includes("/tickets?${search}") && @@ -417,9 +417,12 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Ticket Kanban should combine related states into independent 30-item display windows", ); assert( - generatedTicketApi.includes("Generated from yoi-workspace-server") && + generatedTicketApi.includes( + "@generated by yoi-ticket-browser-openapi-typescript@1", + ) && + generatedTicketApi.includes("Input contract: 0.1.0 (sha256:") && generatedTicketApi.includes("export type TicketListResponse") && - generatedTicketApi.includes("items: Array"), + generatedTicketApi.includes("items: Array"), "Ticket response types should come from the generated Server contract", ); assert( diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts index 5a209740d..cae6a9c32 100644 --- a/web/workspace/src/lib/workspace/memory/api.ts +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -1,3 +1,4 @@ +import { MEMORY_API_LIMITS } from "$lib/generated/memory-api"; import type { Diagnostic, DiagnosticSeverity, @@ -13,10 +14,10 @@ import type { MemoryStagingRecord, } from "$lib/generated/memory-api"; -const MAX_STAGING_ITEMS = 500; -const MAX_EVIDENCE_PER_RECORD = 500; -const MAX_SOURCE_REFS_PER_RECORD = 500; -const MAX_ORIGIN_VALUE_LENGTH = 512; +const MAX_STAGING_ITEMS = MEMORY_API_LIMITS.maxCollectionItems; +const MAX_EVIDENCE_PER_RECORD = MEMORY_API_LIMITS.maxCollectionItems; +const MAX_SOURCE_REFS_PER_RECORD = MEMORY_API_LIMITS.maxCollectionItems; +const MAX_ORIGIN_VALUE_LENGTH = MEMORY_API_LIMITS.maxIdentifierBytes; const candidateKinds = new Set([ "preference", @@ -51,7 +52,11 @@ export function parseMemoryDocumentResponse( "Memory document response", ); return { - body_md: requiredString(record, "body_md"), + body_md: requiredString( + record, + "body_md", + MEMORY_API_LIMITS.maxDocumentBytes, + ), created_at: requiredString(record, "created_at"), updated_at: requiredString(record, "updated_at"), bytes: requiredNonNegativeInteger(record, "bytes"), @@ -258,7 +263,10 @@ function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin { ) { if (key in record) { const text = nullableString(record, key); - if (text !== null && text.length > MAX_ORIGIN_VALUE_LENGTH) { + if ( + text !== null && + new TextEncoder().encode(text).byteLength > MAX_ORIGIN_VALUE_LENGTH + ) { invalid(`${key} exceeds the Memory origin limit`); } result[key] = text; @@ -328,11 +336,19 @@ function boundedArray( return value; } -function requiredString(record: Record, key: string): string { - if (typeof record[key] !== "string") { - invalid(`${key} must be a string`); +function requiredString( + record: Record, + key: string, + maxBytes: number = MEMORY_API_LIMITS.maxStringBytes, +): string { + const value = record[key]; + if ( + typeof value !== "string" || + new TextEncoder().encode(value).byteLength > maxBytes + ) { + invalid(`${key} must be a bounded string`); } - return record[key]; + return value; } function nullableString( @@ -340,8 +356,13 @@ function nullableString( key: string, ): string | null { const value = record[key]; - if (value !== null && typeof value !== "string") { - invalid(`${key} must be a string or null`); + if ( + value !== null && + (typeof value !== "string" || + new TextEncoder().encode(value).byteLength > + MEMORY_API_LIMITS.maxStringBytes) + ) { + invalid(`${key} must be a bounded string or null`); } return value; } diff --git a/web/workspace/src/lib/workspace/merge-request-status.ts b/web/workspace/src/lib/workspace/merge-request-status.ts index 536476e75..03a584e1b 100644 --- a/web/workspace/src/lib/workspace/merge-request-status.ts +++ b/web/workspace/src/lib/workspace/merge-request-status.ts @@ -3,11 +3,17 @@ import type { MergeRequestThreadEvent, } from "./api/merge-requests.ts"; +type ReviewEvent = Extract; +type SourceEvidenceEvent = Extract< + MergeRequestThreadEvent, + { kind: "review" | "review_requested" } +>; + function isCurrentSourceEvent( event: MergeRequestThreadEvent, source: string, ): boolean { - return event.subject_ref === source; + return "subject_ref" in event && event.subject_ref === source; } function requestHasTerminalOutcome( @@ -26,16 +32,18 @@ export function sourceReviewFreshness( const source = mergeRequest.source.ref; if (!source) return "Source review unavailable: selector_from is unresolved."; - const effectiveReview = [...mergeRequest.thread].reverse().find((event) => { - if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) { - return false; - } - return !mergeRequest.thread.some( - (candidate) => - candidate.kind === "review_revoked" && - candidate.review_event_id === event.event_id, - ); - }); + const effectiveReview = [...mergeRequest.thread].reverse().find( + (event): event is ReviewEvent => { + if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) { + return false; + } + return !mergeRequest.thread.some( + (candidate) => + candidate.kind === "review_revoked" && + candidate.review_event_id === event.event_id, + ); + }, + ); if (effectiveReview) { return effectiveReview.decision === "approve" ? `Current source approved at exact ref ${source}.` @@ -43,9 +51,8 @@ export function sourceReviewFreshness( } const latestEvidence = [...mergeRequest.thread].reverse().find( - (event) => - (event.kind === "review" || event.kind === "review_requested") && - typeof event.subject_ref === "string", + (event): event is SourceEvidenceEvent => + event.kind === "review" || event.kind === "review_requested", ); if (latestEvidence?.subject_ref && latestEvidence.subject_ref !== source) { return `Fresh source review required: selector_from moved from ${latestEvidence.subject_ref} to ${source}.`; diff --git a/web/workspace/src/lib/workspace/repositories/ssh-connection.ts b/web/workspace/src/lib/workspace/repositories/ssh-connection.ts index 7b01e33f0..67e29f0e8 100644 --- a/web/workspace/src/lib/workspace/repositories/ssh-connection.ts +++ b/web/workspace/src/lib/workspace/repositories/ssh-connection.ts @@ -1,4 +1,4 @@ -import type { WorkspaceRuntimeResource } from "$lib/generated/legacy-server-api"; +import type { WorkspaceRuntimeResource } from "$lib/generated/runtime-api"; export type RepositorySshProbeSelection = Readonly<{ changed: boolean; diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index 53383cc6c..88d1b0c3e 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -13,6 +13,12 @@ import type { WorkspaceSigningIdentityResponse, WorkspaceSigningIdentityState, } from "$lib/generated/legacy-server-api"; +import { readBoundedJson } from "$lib/workspace/api/http.ts"; + +const MAX_PROFILE_RESPONSE_BYTES = 4 * 1024 * 1024; +const MAX_PROFILE_ERROR_BYTES = 4 * 1024; +const MAX_PROFILE_STRING_BYTES = 1024 * 1024; +const MAX_PROFILE_COLLECTION_ITEMS = 1000; export class ProfileApiError extends Error { constructor( @@ -49,10 +55,7 @@ function exactKeys( } function stringValue(value: unknown, context: string): string { - if (typeof value !== "string") { - throw new ProfileApiError(`${context} returned an invalid response.`, 502); - } - return value; + return boundedStringValue(value, context, MAX_PROFILE_STRING_BYTES); } function boundedStringValue( @@ -60,7 +63,10 @@ function boundedStringValue( context: string, maxBytes: number, ): string { - const text = stringValue(value, context); + if (typeof value !== "string") { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + const text = value; if (new TextEncoder().encode(text).byteLength > maxBytes) { throw new ProfileApiError(`${context} returned an invalid response.`, 502); } @@ -107,7 +113,7 @@ function arrayValue( parser: (item: unknown) => T, context: string, ): T[] { - if (!Array.isArray(value)) { + if (!Array.isArray(value) || value.length > MAX_PROFILE_COLLECTION_ITEMS) { throw new ProfileApiError(`${context} returned an invalid response.`, 502); } return value.map(parser); @@ -502,17 +508,23 @@ export function parseWorkspaceSigningIdentityResponse( }; } +async function boundedErrorText(response: Response): Promise { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_PROFILE_ERROR_BYTES) return ""; + return new TextDecoder().decode(bytes); +} + async function parseResponse( response: Response, parser: (value: unknown) => T, ): Promise { if (!response.ok) { throw new ProfileApiError( - (await response.text()) || response.statusText, + (await boundedErrorText(response)) || response.statusText, response.status, ); } - return parser(await response.json() as unknown); + return parser(await readBoundedJson(response, MAX_PROFILE_RESPONSE_BYTES)); } export async function fetchWorkspaceMetadata( diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index b5734b243..f1c7bf0c9 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -1,11 +1,22 @@ +import type { + CleanupTargetKind, + CleanupWorkdirCandidate, + CleanupWorkerCandidate, + RuntimeCleanupExecutionResponse, + RuntimeCleanupPlanResponse, +} from "$lib/generated/runtime-api"; import type { BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse, BrowserWorkerWorkingDirectorySelection as SharedBrowserWorkerWorkingDirectorySelection, + Diagnostic as SharedDiagnostic, + WorkerCapabilitySummary as SharedWorkerCapabilitySummary, WorkerLaunchOptionsResponse as SharedWorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate as SharedWorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption as SharedWorkerLaunchRuntimeOption, - WorkerLaunchWorkerSummary as WorkerSummary, + WorkerLaunchWorkerSummary, + WorkerSummary as SharedWorkerSummary, + WorkerWorkdirAttachmentSummary as SharedWorkerWorkdirAttachmentSummary, WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption, } from "$lib/generated/worker-launch-api"; import type { @@ -46,11 +57,7 @@ export type { }; export type WorkspaceResponse = SharedWorkspaceResponse; -export type Diagnostic = { - code: string; - severity: string; - message: string; -}; +export type Diagnostic = SharedDiagnostic; export type Runtime = { runtime_id: string; @@ -71,50 +78,21 @@ export type Runtime = { }; }; -export type Host = { - runtime_id: string; - host_id: string; - label: string; - kind: string; - status: string; - observed_at: string; - last_seen_at: string | null; - os: string; - arch: string; - diagnostics: Diagnostic[]; -}; +export type WorkerCapabilities = SharedWorkerCapabilitySummary; -export type WorkerCapabilities = { - can_stop: boolean; - can_spawn_followup: boolean; -}; +export type WorkerWorkdirAttachment = SharedWorkerWorkdirAttachmentSummary; -export type WorkerWorkdirAttachment = { - alias: string; - working_directory: WorkingDirectorySummary; -}; - -export type Worker = { - runtime_id: string; - worker_id: string; - resource_key: string; - host_id: string; - display_name: string; - label: string; - profile?: string | null; - singleton_key?: string | null; - tags: string[]; - workspace: { visibility: string; identity: string }; - state: string; - worker_state?: WorkerStateSnapshot | null; - pinned?: boolean; - retention_state?: string; - last_seen_at?: string | null; - implementation: { kind: string; display_hint: string }; - capabilities: WorkerCapabilities; - workdir_attachments?: WorkerWorkdirAttachment[]; - diagnostics: Diagnostic[]; -}; +export type Worker = + & Omit< + SharedWorkerSummary, + "display_name" | "tags" | "worker_state" | "diagnostics" + > + & { + display_name: string; + tags: string[]; + worker_state?: WorkerStateSnapshot | null; + diagnostics: Diagnostic[]; + }; export type WorkerOperationState = "accepted" | "unsupported" | "rejected"; @@ -127,7 +105,7 @@ export type WorkerRestoreState = export type WorkerRestoreResult = { state: WorkerRestoreState; - worker?: WorkerSummary | null; + worker?: WorkerLaunchWorkerSummary | null; diagnostics: Diagnostic[]; }; @@ -136,67 +114,12 @@ export type WorkerLaunchProfileCandidate = SharedWorkerLaunchProfileCandidate; export type WorkingDirectoryRepositoryOption = SharedWorkingDirectoryRepositoryOption; -export type CleanupTargetKind = - | "worker_delete" - | "workdir_clean_cleanup" - | "workdir_dirty_discard" - | "workdir_record_delete"; - -export type CleanupWorkerCandidate = { - target_id: string; - action: CleanupTargetKind; - worker_id: string; - runtime_worker_id: string; - runtime_id: string; - reason: string; - blocking_reason?: string | null; - pinned: boolean; - retention_state: string; - linked_workdir_ids: string[]; - running_linked: boolean; - estimated_reclaim_bytes?: number | null; -}; - -export type CleanupWorkdirCandidate = { - target_id: string; - action: CleanupTargetKind; - workdir_id: string; - runtime_id: string; - repository_key: string; - reason: string; - blocking_reason?: string | null; - linked_worker_ids: string[]; - linked_running_worker_ids: string[]; - running_linked: boolean; - pinned_linked: boolean; - file_status: string; - cleanliness: string; - estimated_reclaim_bytes?: number | null; -}; - -export type RuntimeCleanupPlanResponse = { - workspace_id: string; - runtime_id: string; - generated_at: string; - revision: string; - digest: string; - workers: CleanupWorkerCandidate[]; - workdirs: CleanupWorkdirCandidate[]; - diagnostics: Diagnostic[]; -}; - -export type RuntimeCleanupExecutionResponse = { - workspace_id: string; - runtime_id: string; - executed_at: string; - results: { - target_id: string; - action: CleanupTargetKind; - status: string; - message: string; - }[]; - plan_after: RuntimeCleanupPlanResponse; - diagnostics: Diagnostic[]; +export type { + CleanupTargetKind, + CleanupWorkdirCandidate, + CleanupWorkerCandidate, + RuntimeCleanupExecutionResponse, + RuntimeCleanupPlanResponse, }; export type BrowserWorkerWorkingDirectorySelection = @@ -228,61 +151,22 @@ export type RepositoryDetailResponse = SharedRepositoryDetailResponse; export type RepositoryLogResponse = SharedRepositoryLogResponse; export type { - DerivedTicketRelation, + ObjectiveDetail, + ObjectiveLinkedTicketSummary, + ObjectiveListResponse, + ObjectiveSummary, TicketDetail, TicketEventDetail, TicketListResponse, - TicketRelation, - TicketRelationBlocker, - TicketRelationNotice, - TicketRelationView, - TicketSummary, } from "$lib/generated/ticket-api"; - -export type ObjectiveSummary = { - id: string; - resource_key: string; - title: string; - state: string; - updated_at?: string | null; - summary: string; - linked_tickets?: string[]; - record_source?: string; -}; - -export type ObjectiveLinkedTicketSummary = { - id: string; - resource_key: string; - title: string; - state: string; -}; - -export type ObjectiveDetail = { - id: string; - resource_key: string; - title: string; - state: string; - created_at?: string | null; - updated_at?: string | null; - linked_tickets: string[]; - linked_ticket_summaries: ObjectiveLinkedTicketSummary[]; - body: string; - body_truncated: boolean; - record_source: string; -}; - -export type InvalidProjectRecord = { - label: string; - reason: string; -}; - -export type ObjectiveListResponse = { - workspace_id: string; - limit: number; - items: ObjectiveSummary[]; - invalid_records: InvalidProjectRecord[]; - record_authority: string; -}; +export type { + TicketDetailDerivedRelation as DerivedTicketRelation, + TicketDetailRelation as TicketRelation, + TicketDetailRelationBlocker as TicketRelationBlocker, + TicketDetailRelationNotice as TicketRelationNotice, + TicketDetailRelationView as TicketRelationView, + TicketListItemSummary as TicketSummary, +} from "$lib/generated/ticket-api"; export type { CompanionCancelRequest, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-actions.ts b/web/workspace/src/lib/workspace/sidebar/worker-actions.ts index c76d41f6a..3f3dcccf2 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-actions.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-actions.ts @@ -1,19 +1,17 @@ import { workspaceApiPath } from "$lib/workspace/api/http"; +import { + parseRuntimeCleanupExecution, + parseRuntimeCleanupPlan, + parseRuntimeWorkerLifecycleResult, +} from "$lib/workspace/api/runtime-workers"; import type { Diagnostic, - RuntimeCleanupExecutionResponse, - RuntimeCleanupPlanResponse, Worker, } from "./types"; type FetchFn = typeof fetch; type WorkerActionTarget = Pick; -type WorkerLifecycleResponse = { - state: string; - diagnostics?: Diagnostic[]; -}; - function workerPath(workspaceId: string, worker: WorkerActionTarget): string { return workspaceApiPath( workspaceId, @@ -26,11 +24,18 @@ function workerPath(workspaceId: string, worker: WorkerActionTarget): string { async function responseError(response: Response): Promise { const fallback = `${response.status} ${response.statusText}`.trim(); try { - const payload = await response.json() as { - message?: string; - error?: { message?: string }; - }; - return payload.error?.message ?? payload.message ?? fallback; + const payload: unknown = await response.json(); + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return fallback; + } + const record = payload as Record; + if (typeof record.message === "string") return record.message.slice(0, 512); + const nested = record.error; + if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) { + const message = (nested as Record).message; + if (typeof message === "string") return message.slice(0, 512); + } + return fallback; } catch { return fallback; } @@ -62,7 +67,7 @@ export async function stopSidebarWorker( }); if (!response.ok) throw new Error(await responseError(response)); - const result = await response.json() as WorkerLifecycleResponse; + const result = parseRuntimeWorkerLifecycleResult(await response.json()); if (result.state !== "accepted") { throw new Error( diagnosticMessage(result.diagnostics, `Worker stop was ${result.state}`), @@ -81,7 +86,7 @@ export async function deleteSidebarWorker( ); if (!planResponse.ok) throw new Error(await responseError(planResponse)); - const plan = await planResponse.json() as RuntimeCleanupPlanResponse; + const plan = parseRuntimeCleanupPlan(await planResponse.json()); const candidate = plan.workers.find((item) => item.runtime_id === worker.runtime_id && item.runtime_worker_id === worker.worker_id @@ -107,8 +112,7 @@ export async function deleteSidebarWorker( throw new Error(await responseError(executionResponse)); } - const execution = await executionResponse - .json() as RuntimeCleanupExecutionResponse; + const execution = parseRuntimeCleanupExecution(await executionResponse.json()); const outcome = execution.results.find((result) => result.target_id === candidate.target_id ); diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index 8790b87be..2ceeb0954 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -342,6 +342,27 @@ display: grid; gap: 0.75rem; } + .ticket-target-list, .ticket-target-row { + display: grid; + gap: 0.75rem; + } + .ticket-target-row { + min-width: 0; + margin: 0; + border: 0; + border-top: 1px solid var(--line); + padding: var(--space-3) 0 0; + } + .ticket-target-row:first-child { + border-top: 0; + padding-top: 0; + } + .ticket-target-row legend { + color: var(--text-strong); + font-size: var(--font-size-compact); + font-weight: 700; + padding: 0; + } .ticket-editor label, .ticket-control-form label { display: grid; gap: 0.3rem; @@ -349,6 +370,7 @@ font-size: var(--font-size-body); } .ticket-editor input, + .ticket-editor select, .ticket-editor textarea, .ticket-control-form input, .ticket-control-form select, diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index 520f4e900..5908f7d51 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -7,7 +7,7 @@ import { } from "./ticket-panel.ts"; import type { TicketDetail, - TicketSummary, + TicketListItemSummary as TicketSummary, } from "../../generated/ticket-api.ts"; declare const Deno: { @@ -88,8 +88,18 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket const ticket = { id: "00001KYRRDVH9", title: "Ticket panel API", - repository_key: "main repo", - ref_selector: "work/ticket", + targets: [ + { + repository_key: "docs", + ref_selector: "main", + access: "read_only", + }, + { + repository_key: "main repo", + ref_selector: "work/ticket", + access: "read_write", + }, + ], } as TicketDetail; assertEquals( @@ -110,6 +120,38 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket ); }); +Deno.test("ticket worker launch never selects a read_only or ambiguous write target", () => { + const readOnly = { + id: "ticket-read-only", + title: "Read only", + targets: [{ + repository_key: "docs", + ref_selector: "main", + access: "read_only", + }], + } as TicketDetail; + const readOnlyUrl = new URL( + ticketWorkerLaunchHref("workspace", readOnly, "coder"), + "https://example.test", + ); + assertEquals(readOnlyUrl.searchParams.has("repositoryKey"), false); + assertEquals(readOnlyUrl.searchParams.has("refSelector"), false); + + const ambiguous = { + ...readOnly, + targets: [ + { repository_key: "main", ref_selector: "develop", access: "read_write" }, + { repository_key: "docs", ref_selector: "main", access: "read_write" }, + ], + } as TicketDetail; + const ambiguousUrl = new URL( + ticketWorkerLaunchHref("workspace", ambiguous, "coder"), + "https://example.test", + ); + assertEquals(ambiguousUrl.searchParams.has("repositoryKey"), false); + assertEquals(ambiguousUrl.searchParams.has("refSelector"), false); +}); + Deno.test("ticket detail uses server-derived role assignment actions", async () => { const source = await Deno.readTextFile( new URL( @@ -120,7 +162,10 @@ Deno.test("ticket detail uses server-derived role assignment actions", async () assertEquals(source.includes("ticket.action_eligibility.can_queue"), true); assertEquals(source.includes("ticket.relations.blockers.length > 0"), true); - assertEquals(source.includes("ticket.action_eligibility.queue_tickets"), true); + assertEquals( + source.includes("ticket.action_eligibility.queue_tickets"), + true, + ); assertEquals(source.includes("This operation queues:"), true); assertEquals(source.includes("outcome.queued_tickets.join"), true); assertEquals( @@ -135,6 +180,26 @@ Deno.test("ticket detail uses server-derived role assignment actions", async () assertEquals(source.includes('kind: "workspace_agent"'), true); assertEquals(source.includes('kind: "worker"'), true); assertEquals(source.includes("ticket.assignee"), false); + assertEquals(source.includes('action: "set", targets'), true); + assertEquals(source.includes("updatedTicket.targets"), true); + assertEquals(source.includes("ticket.repository_key"), false); + assertEquals(source.includes("ticket.ref_selector"), false); +}); + +Deno.test("Ticket authoring creates the target collection without singular authority", async () => { + const source = await Deno.readTextFile( + new URL( + "../../../routes/w/[workspaceId]/tickets/+page.svelte", + import.meta.url, + ), + ); + + assertEquals(source.includes("const request: NewTicket"), true); + assertEquals(source.includes("targets: normalizedCreateTargets()"), true); + assertEquals(source.includes('value="read_write"'), true); + assertEquals(source.includes('value="read_only"'), true); + assertEquals(source.includes("repository_key: create"), false); + assertEquals(source.includes("ref_selector: create"), false); }); Deno.test("ticket detail keeps the operation rail outside main content", async () => { diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 428a9f1d3..113a60604 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -1,5 +1,8 @@ import type { BrowserWorkspaceOrchestratorResponse } from "$lib/generated/worker-launch-api"; -import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api"; +import type { + TicketDetail, + TicketListItemSummary, +} from "$lib/generated/ticket-api"; export const TICKET_STATES = [ "planning", @@ -38,7 +41,7 @@ export const TICKET_LANE_PAGE_SIZE = 30; export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number]; export type TicketLaneId = TicketLaneDefinition["id"]; export type TicketCardSummary = Pick< - TicketSummary, + TicketListItemSummary, "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" >; @@ -104,23 +107,26 @@ export function ticketWorkerMessage( export function ticketWorkerLaunchHref( workspaceId: string, - ticket: Pick< - TicketDetail, - "id" | "title" | "repository_key" | "ref_selector" - >, + ticket: Pick, role: TicketWorkerRole, ): string { + const implementationTargets = ticket.targets.filter((target) => + target.access === "read_write" + ); + const implementationTarget = implementationTargets.length === 1 + ? implementationTargets[0] + : null; const params = new URLSearchParams({ ticketId: ticket.id, ticketTitle: ticket.title, ticketRole: role, initialInput: ticketWorkerMessage(ticket.id, role), }); - if (ticket.repository_key) { - params.set("repositoryKey", ticket.repository_key); - } - if (ticket.ref_selector) { - params.set("refSelector", ticket.ref_selector); + if (implementationTarget) { + params.set("repositoryKey", implementationTarget.repository_key); + if (implementationTarget.ref_selector) { + params.set("refSelector", implementationTarget.ref_selector); + } } return `/w/${ encodeURIComponent(workspaceId) diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.ts b/web/workspace/src/routes/w/[workspaceId]/+layout.ts index 8f83bb8d1..5ffac59e3 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+layout.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.ts @@ -7,21 +7,18 @@ import type { LayoutLoad } from "./$types"; export const load: LayoutLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; const [workspaceResult, repositoryResult] = await Promise.all([ - loadJson(fetch, workspaceApiPath(workspaceId, "/workspace")), + loadJson( + fetch, + workspaceApiPath(workspaceId, "/workspace"), + undefined, + parseWorkspaceResponse, + { diagnosticLabel: "Workspace API", maxResponseBytes: 1024 * 1024 }, + ), loadWorkspaceRepositoryList(fetch, workspaceId), ]); - let workspace = null; - let workspaceError = workspaceResult.error; - if (workspaceResult.data !== null) { - try { - workspace = parseWorkspaceResponse(workspaceResult.data); - } catch (cause) { - workspaceError = cause instanceof Error - ? cause.message - : "invalid workspace response"; - } - } + const workspace = workspaceResult.data; + const workspaceError = workspaceResult.error; if (!workspace) { error(404, { message: workspaceError ?? `Workspace ${workspaceId} is unavailable`, diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/+page.ts index 7ebfb3374..daf3fc39f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+page.ts @@ -1,10 +1,24 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import type { Host, ListResponse } from "$lib/workspace/sidebar/types"; +import { + parseHostListResponse, + type HostListResponse, +} from "$lib/workspace/api/workspace-model"; import type { PageLoad } from "./$types"; +const HOST_API_LOAD_POLICY = { + diagnosticLabel: "Host API", + maxResponseBytes: 4 * 1024 * 1024, +} as const; + export const load: PageLoad = async ({ fetch, params }) => { const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); - const hosts = await loadJson>(fetch, apiPath("/hosts")); + const hosts = await loadJson( + fetch, + apiPath("/hosts"), + undefined, + parseHostListResponse, + HOST_API_LOAD_POLICY, + ); return { workspaceId: params.workspaceId, diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts index 9e25c8411..65205285c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts @@ -1,3 +1,4 @@ +import { MEMORY_API_LIMITS } from "$lib/generated/memory-api"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { parseMemoryDocumentResponse } from "$lib/workspace/memory/api"; import type { PageLoad } from "./$types"; @@ -10,6 +11,10 @@ export const load: PageLoad = async ({ fetch, params }) => { workspaceApiPath(params.workspaceId, "/memory"), undefined, parseMemoryDocumentResponse, + { + diagnosticLabel: "Memory API", + maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes, + }, ), }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.ts index 89a02e895..ecc56e866 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.ts @@ -1,3 +1,4 @@ +import { MEMORY_API_LIMITS } from "$lib/generated/memory-api"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { parseMemoryStagingListResponse } from "$lib/workspace/memory/api"; import type { PageLoad } from "./$types"; @@ -10,6 +11,10 @@ export const load: PageLoad = async ({ fetch, params }) => { `${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`, undefined, parseMemoryStagingListResponse, + { + diagnosticLabel: "Memory API", + maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes, + }, ), }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts b/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts index c8639c92d..7cc98ce4e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/+page.ts @@ -1,33 +1,22 @@ import type { PageLoad } from "./$types"; +import { loadJson } from "$lib/workspace/api/http"; +import { mergeRequestCollectionPath } from "$lib/workspace/api/merge-requests"; import { - mergeRequestCollectionPath, - type MergeRequestListPage, -} from "$lib/workspace/api/merge-requests"; + parseMergeRequestListResponse, + TICKET_BROWSER_API_LOAD_POLICY, +} from "$lib/workspace/api/ticket-browser"; export const load: PageLoad = async ({ params, fetch }) => { - try { - const response = await fetch( - `${mergeRequestCollectionPath(params.workspaceId)}?limit=100`, - ); - const body = await response.json().catch(() => ({})); - if (!response.ok) { - return { - workspaceId: params.workspaceId, - mergeRequests: null, - error: body?.error ?? body?.message ?? - `Request failed (${response.status})`, - }; - } - return { - workspaceId: params.workspaceId, - mergeRequests: body as MergeRequestListPage, - error: null, - }; - } catch (error) { - return { - workspaceId: params.workspaceId, - mergeRequests: null, - error: error instanceof Error ? error.message : String(error), - }; - } + const result = await loadJson( + fetch, + `${mergeRequestCollectionPath(params.workspaceId)}?limit=100`, + undefined, + parseMergeRequestListResponse, + TICKET_BROWSER_API_LOAD_POLICY, + ); + return { + workspaceId: params.workspaceId, + mergeRequests: result.data, + error: result.error, + }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte index 90dcf7087..93507274b 100644 --- a/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte @@ -1,4 +1,5 @@