chore: merge develop into hare/develop

This commit is contained in:
2026-09-24 01:01:35 +09:00
178 changed files with 61787 additions and 7023 deletions
Generated
+86
View File
@@ -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",
+1
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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"] }
+89 -3
View File
@@ -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<T>(
headers: &HeaderMap,
name: &'static str,
) -> Result<Option<T>, 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<T: fmt::Display>(
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<T: Serialize>(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<O, H, T, S>(router: Router<S>, handler: H) -> Router<S>
where
O: crate::WebSocketOperation,
H: Handler<T, S>,
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());
}
}
+153 -15
View File
@@ -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<T, E>` 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
//! `<trait_name>_responses` module. Only explicit body-less `304` declarations extend success
//! beyond 2xx. A public error inferred from `Result<T, E>` 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<utf8-hex>` 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<bytes::Bytes> for BinaryBody {
fn from(value: bytes::Bytes) -> Self {
Self(value)
}
}
impl From<Vec<u8>> for BinaryBody {
fn from(value: Vec<u8>) -> 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(<redacted>)")
}
}
/// 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<ResponseMetadata>,
/// 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
+54
View File
@@ -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<T: OpenApiSchema>(
&mut self,
status: u16,
@@ -409,6 +427,42 @@ impl OpenApiOperation<'_> {
self.insert_response(status, json!({ "description": description }))
}
pub fn response_header<T: OpenApiSchema>(
&mut self,
status: u16,
name: &'static str,
) -> Result<(), OpenApiError> {
let schema = self.parent.schema_ref::<T>()?;
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!(
+124 -9
View File
@@ -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<A> ClientCore<A> {
}
}
/// 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<u8>) -> 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", &"<redacted>")
.field("content_type", &self.content_type)
.finish()
}
}
impl<A: RequestAuthorizer> ClientCore<A> {
pub async fn send(
&self,
method: Method,
url: Url,
mut headers: HeaderMap,
body: Option<Vec<u8>>,
body: Option<EncodedBody>,
) -> Result<ReceivedResponse, ClientFailure> {
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<A: RequestAuthorizer> ClientCore<A> {
})
.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<A: RequestAuthorizer> ClientCore<A> {
.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<A: RequestAuthorizer> ClientCore<A> {
}
})?;
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<A: RequestAuthorizer> ClientCore<A> {
}
bytes.extend_from_slice(&chunk);
}
Ok(ReceivedResponse { status, bytes })
Ok(ReceivedResponse {
status,
headers,
bytes,
})
}
}
@@ -413,14 +458,34 @@ pub fn insert_header<T: fmt::Display>(
Ok(())
}
/// Serialize the request body once. These exact bytes are authorized and transmitted.
pub fn encode_json<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, ClientFailure> {
serde_json::to_vec(value).map_err(|_| ClientFailure::RequestEncoding)
/// Insert an optional string-form header value when present.
pub fn insert_optional_header<T: fmt::Display>(
headers: &mut HeaderMap,
name: &str,
value: &Option<T>,
) -> 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<T: Serialize + ?Sized>(value: &T) -> Result<EncodedBody, ClientFailure> {
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<u8>,
}
@@ -429,6 +494,24 @@ impl ReceivedResponse {
self.status
}
pub fn parse_header<T>(&self, name: &'static str) -> Result<T, ClientFailure>
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<T: DeserializeOwned>(&self, kind: DecodeKind) -> Result<T, ClientFailure> {
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", &"<redacted>")
.field("bytes", &"<redacted>")
.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<E> std::error::Error for ClientError<E> {}
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"));
}
}
+106 -7
View File
@@ -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<T>(std::marker::PhantomData<T>);
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<Widget>;
}
#[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 = <binary_api_operations::Upload as Operation>::METADATA;
assert_eq!(upload.request_body.wire_kind, WireKind::Binary);
assert_eq!(upload.parameters[0].location, ParameterLocation::Body);
fn assert_binary_request<O: Operation<RequestBody = BinaryBody>>() {}
assert_binary_request::<binary_api_operations::Upload>();
let conditional = <conditional_api_operations::Fetch as Operation>::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<ResponseBody = conditional_api_responses::Fetch>,
>() {
}
assert_conditional_response::<conditional_api_operations::Fetch>();
let delete = <widget_api_operations::Delete as Operation>::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<O: Operation<RequestBody = NoBody>>() {}
fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
assert_empty_request::<widget_api_operations::Lookup>();
assert_empty_response::<widget_api_operations::Delete>();
fn assert_websocket_types<
O: WebSocketOperation<
PathParameters = (u64,),
ClientToServerFrame = ClientFrame,
ServerToClientFrame = ServerFrame,
>,
>() {
}
assert_websocket_types::<mixed_transport_api_operations::WidgetEvents>();
let websocket = <mixed_transport_api_operations::WidgetEvents as WebSocketOperation>::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",
}
);
}
+376
View File
@@ -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<u8>,
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<BinaryReceipt, PublicError>;
}
#[derive(Clone)]
pub struct BinaryService;
impl BinaryApi for BinaryService {
async fn upload(
&self,
content_type: String,
body: api_macros::BinaryBody,
) -> Result<BinaryReceipt, PublicError> {
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<u64>,
) -> 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::<web_socket_only_api_operations::Events, _, _, _>(
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(<redacted>)"
);
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());
+69 -1
View File
@@ -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<String>;
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());
@@ -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() {}
@@ -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)])]
| ^^^^^^
@@ -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() {}
@@ -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]
| ^^^^^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: duplicate route for GET `/widgets/{widget_id}/events`
--> tests/ui/duplicate_websocket_route.rs:18:10
|
18 | type WidgetEvents;
| ^^^^^^^^^^^^
@@ -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() {}
@@ -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;
| ^^^^^^^^
@@ -0,0 +1,11 @@
use api_macros::api;
pub struct Response;
#[api]
pub trait InvalidBinaryType {
#[put("/payload")]
async fn upload(&self, #[binary] body: Vec<u8>) -> Response;
}
fn main() {}
@@ -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<u8>) -> Response;
| ^^^^^^^
@@ -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() {}
@@ -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)])]
| ^^^^^^^^^^^^
@@ -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() {}
@@ -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
| ^^^^^^^^^^^^^^^^
@@ -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() {}
@@ -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
| ^^^^^^
@@ -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() {}
@@ -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
| ^^^^
@@ -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() {}
@@ -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),
| ^^^^^^^^^^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: WebSocket operations require `method = GET`
--> tests/ui/invalid_websocket_method.rs:9:18
|
9 | method = POST,
| ^^^^
@@ -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() {}
@@ -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
| ^^^^^^
@@ -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() {}
@@ -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
| ^^^^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: WebSocket operations require `server_to_client = FrameType`
--> tests/ui/missing_websocket_frame.rs:8:9
|
8 | "/events",
| ^^^^^^^^^
@@ -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() {}
@@ -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,
| ^^^^^^
@@ -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() {}
@@ -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,
| ^^^^^^
@@ -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,
@@ -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() {}
@@ -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)]
| ^^^
@@ -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() {}
@@ -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 }
|
@@ -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() {}
@@ -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",
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: path parameter(s) have no matching placeholder: unused
--> tests/ui/websocket_unused_path_parameter.rs:8:9
|
8 | "/events",
| ^^^^^^^^^
+55 -46
View File
@@ -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<RepositoryApiError>),
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<reqwest::Error> 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, BackendAuthClientError> {
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<DeviceLoginStartResponse, BackendAuthClientError> {
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<DeviceLoginPollResponse, BackendAuthClientError> {
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<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
) -> Result<T, BackendAuthClientError> {
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::<T>().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!({
+172 -250
View File
@@ -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<u8>,
) -> Result<protocol::UploadedFileRef, BackendRuntimeClientError> {
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::<AttachmentUploadGrantResponse>()
.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::<UploadedFileResponse>()
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<server_api::RepositoryApiError>),
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<server_api::ServerApiClientError> for BackendRuntimeClientError {
}
}
impl From<server_api::client_support::ClientError<server_api::RepositoryApiError>>
for BackendRuntimeClientError
{
fn from(
value: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> Self {
Self::ContractApi(value)
}
}
impl From<BackendApiClientError> for BackendRuntimeClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
@@ -318,6 +292,34 @@ impl From<reqwest::Error> for BackendRuntimeClientError {
const WORKER_SESSION_RESPONSE_LIMIT: usize = 16 * 1024 * 1024;
fn runtime_server_api_client(
base_url: &str,
backend: &BackendApiClient,
) -> Result<server_api::ServerApiClient<StoredBearerAuthorizer>, 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<server_api::RepositoryApiError>,
) -> 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<server_api::WorkspaceWorkerSessionResponse, BackendRuntimeClientError> {
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<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
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::<BackendWorkerLaunchOptions>().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<BackendCreateWorkerResponse, BackendRuntimeClientError> {
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::<BackendCreateWorkerResponse>().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<BackendRuntimeListResponse<BackendWorkerSummary>, 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::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.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::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
.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::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.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::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.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<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
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::<BackendWorkerRestoreResponse>().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<BackendWorkerSummary> {
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::<BackendWorkerSummary>(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"
);
}
}
+115 -53
View File
@@ -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<server_api::RepositoryApiError>),
ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
RuntimeManagementApi(
server_api::client_support::ClientError<server_api::RuntimeManagementApiError>,
),
}
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<server_api::client_support::ClientError<server_api::RepositoryApiError
fn from(
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> 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<server_api::RepositoryApiError>,
) -> 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<server_api::RuntimeManagementApiError>,
) -> 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<server_api::ServerApiClient<ServerBearerAuthorizer>, 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<server_api::MemoryDocumentResponse, BackendWorkspaceClientError> {
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<server_api::MemoryStagingListResponse, BackendWorkspaceClientError> {
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<Vec<BackendWorkspace>, 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::<WorkspaceCatalogListResponse>()?.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<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let response = client
.request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()
.await?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>().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<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
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::<CreateBackendWorkspaceResponse>().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,
+352 -259
View File
@@ -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<ObjectiveSummary>,
invalid_records: Vec<BackendInvalidProjectRecord>,
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<T, F, Fut>(&self, operation: F) -> Result<T, BackendWorkspaceClientError>
where
F: FnOnce(
server_api::ServerApiClient<crate::backend_workspace::ServerBearerAuthorizer>,
) -> Fut,
Fut: std::future::Future<
Output = Result<
T,
server_api::client_support::ClientError<server_api::RepositoryApiError>,
>,
>,
{
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<Vec<TicketSummary>, 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<Ticket, BackendWorkspaceClientError> {
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<TicketRef, BackendWorkspaceClientError> {
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<TicketRelation, BackendWorkspaceClientError> {
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<TicketRelationKind>,
) -> Result<Vec<TicketRelation>, BackendWorkspaceClientError> {
#[derive(Serialize)]
struct Query<'a> {
ticket: Option<&'a TicketIdOrSlug>,
kind: Option<TicketRelationKind>,
}
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<TicketDoctorReport, BackendWorkspaceClientError> {
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<ListResponse<ObjectiveSummary>, 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<ObjectiveDetail, BackendWorkspaceClientError> {
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<ObjectiveDetail, BackendWorkspaceClientError> {
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<ObjectiveDetail, BackendWorkspaceClientError> {
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<ObjectiveDetail, BackendWorkspaceClientError> {
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<ObjectiveDetail, BackendWorkspaceClientError> {
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<ObjectiveDetail, BackendWorkspaceClientError> {
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<ListResponse<WorkspaceRuntimeResource>, 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<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
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<RuntimeTrustKeyRevealResponse, BackendWorkspaceClientError> {
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<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
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<MemoryDocumentResponse, BackendWorkspaceClientError> {
self.get_json("/memory")
crate::backend_workspace::memory_document_blocking(&self.api, &self.workspace_id)
}
pub fn list_memory_staging(
&self,
limit: usize,
) -> Result<MemoryStagingListResponse, BackendWorkspaceClientError> {
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<String, BackendWorkspaceClientError> {
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<String, BackendWorkspaceClientError> {
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<R: DeserializeOwned>(&self, path: &str) -> Result<R, BackendWorkspaceClientError> {
self.send_json::<(), R>(Method::GET, path, None)
}
fn send_json<B: Serialize + ?Sized, R: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> Result<R, BackendWorkspaceClientError> {
let response = self.request(method, path, body)?.send()?;
self.api.check_status(response.status())?;
response.json().map_err(BackendWorkspaceClientError::Http)
}
fn send_unit<B: Serialize + ?Sized>(
&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<B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
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<Ticket> {
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<TicketDependencyCheck> {
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<Ticket> {
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<ticket::TicketQueueOutcome> {
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<TicketRelation> {
#[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<TicketRelationView> {
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<OrchestrationPlanRecord> {
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<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
) -> ticket::Result<Vec<OrchestrationPlanRecord>> {
#[derive(Serialize)]
struct Query {
ticket: Option<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
}
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()
+1
View File
@@ -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
+42 -19
View File
@@ -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<u8>,
#[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<ContentHash>,
}
#[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<ListEntry>,
#[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<FsPath>,
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<String>,
pub file_type: Option<String>,
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,
}
+38 -15
View File
@@ -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<String>,
@@ -95,31 +96,39 @@ pub struct ModelAudit {
pub model_id: Option<String>,
}
#[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<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub total_tokens: Option<u64>,
#[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<u64>,
#[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<u64>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<crate::schema::JsonSafeU64Pair>")]
pub entry_range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<crate::schema::JsonSafeU64Pair>")]
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<String>,
@@ -127,13 +136,16 @@ pub struct ExtractAudit {
pub staging_paths: Vec<String>,
}
#[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<String>,
@@ -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<ConsolidationAudit>,
}
#[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<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub result_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[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<Utc>,
#[serde(flatten)]
pub payload: AuditPayload,
+19 -13
View File
@@ -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<String>,
}
#[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<String>,
@@ -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<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub limit: Option<usize>,
}
@@ -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<SourceEvidenceRef>,
}
#[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<usize>,
}
@@ -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<String>,
}
+2 -1
View File
@@ -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<crate::schema::JsonSafeU64Pair>")]
pub entry_range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<EvidenceOrigin>,
+12 -1
View File
@@ -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<String>,
#[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<u64>,
}
@@ -118,6 +128,7 @@ pub struct SourceEvidenceRef {
pub segment_id: Option<String>,
/// `[start_entry, end_entry]` inclusive range of session-store entry indices.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<crate::schema::JsonSafeU64Pair>")]
pub entry_range: Option<[u64; 2]>,
/// Host-assigned evidence id within the referenced evidence set.
#[serde(default, skip_serializing_if = "Option::is_none")]
+1
View File
@@ -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,
+1
View File
@@ -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
+22 -16
View File
@@ -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<u32>,
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<Utc>,
}
#[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<Utc>,
}
#[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<Utc>,
}
#[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<Utc>,
}
#[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<Utc>,
}
#[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<Utc>,
}
#[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<String>,
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<String>,
+45
View File
@@ -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,
},
+50 -1
View File
@@ -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<String>,
/// 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::<WorkingDirectoryAttachmentClaim>(value)
.unwrap()
.capabilities,
workdir::WorkdirSessionCapabilities::ALL
);
}
impl RuntimeApi for RoundTripService {
async fn ping(
&self,
+4 -4
View File
@@ -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"
@@ -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(())
}
@@ -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(())
}
@@ -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(())
}
@@ -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(())
}
@@ -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<bool, 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 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)
}
+6576 -326
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -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')),
+620 -197
View File
File diff suppressed because it is too large Load Diff
+304 -5
View File
@@ -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<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(sqlite_err)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.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::<std::result::Result<Vec<_>, _>>()
.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<BTreeMap<i64, String>> {
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<String>>(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::<serde_json::Value>(&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::<std::result::Result<BTreeMap<_, _>, _>>()
.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());
}
+65 -26
View File
@@ -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<String>,
/// Optional target Workspace repository id.
/// Ordered implementation targets. Selectors may be omitted while planning.
#[serde(default)]
repository_key: Option<String>,
/// Optional target Git ref selector. Requires `repository_key`.
#[serde(default)]
ref_selector: Option<String>,
targets: Vec<crate::TicketTarget>,
}
#[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<crate::TicketTargetEdit>,
targets: Option<crate::TicketTargetsEdit>,
}
#[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<crate::ResolvedTicketTarget> {
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",
+1
View File
@@ -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
+9 -8
View File
@@ -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<String>,
}
#[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,
+11 -4
View File
@@ -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,
+22 -9
View File
@@ -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<String>,
}
#[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<String>,
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<u64>,
pub stdout: CommandStreamSlice,
pub stderr: CommandStreamSlice,
#[schemars(range(min = -2147483648_i32, max = 2147483647_i32))]
pub exit_code: Option<i32>,
}
#[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<i32>,
pub timed_out: bool,
pub content: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub next_cursor: Option<usize>,
pub truncated: bool,
/// Complete output retained by the provider when `truncated` is true.
+17 -5
View File
@@ -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<WorkdirToolScopeRule>,
@@ -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<WorkdirToolScopeRule>,
@@ -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,
+15 -12
View File
@@ -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<WorkingDirectoryOccupancy>,
}
#[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.
+6 -1
View File
@@ -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<String>,
/// 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)]
+180 -2
View File
@@ -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<ConfigBundle> 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<ConfigBundleMetadata> 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<ConfigBundleProvenance> for server_api::WorkspaceRuntimeConfigProvenance {
fn from(provenance: ConfigBundleProvenance) -> Self {
Self {
source: provenance.source,
detail: provenance.detail,
}
}
}
impl From<ConfigProfileDescriptor> for server_api::WorkspaceRuntimeConfigProfile {
fn from(profile: ConfigProfileDescriptor) -> Self {
Self {
selector: profile.selector.into(),
label: profile.label,
}
}
}
impl From<ProfileSelector> 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<ConfigDeclaration> for server_api::WorkspaceRuntimeConfigDeclaration {
fn from(declaration: ConfigDeclaration) -> Self {
Self {
kind: declaration.kind.into(),
name: declaration.name,
reference: declaration.reference,
}
}
}
impl From<ConfigDeclarationKind> 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<ProfileSourceArchive> for server_api::WorkspaceRuntimeProfileSourceArchive {
fn from(archive: ProfileSourceArchive) -> Self {
Self {
reference: archive.reference.into(),
content: archive.content,
}
}
}
impl From<ProfileSourceArchiveRef> 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<ProfileSourceGraphSummary> 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<BackendResourceHandle> 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<BackendResourceKind> for server_api::WorkspaceRuntimeResourceKind {
fn from(kind: BackendResourceKind) -> Self {
match kind {
BackendResourceKind::ProfileSourceArchive => Self::ProfileSourceArchive,
BackendResourceKind::RepositorySshAccess => Self::RepositorySshAccess,
}
}
}
impl From<BackendResourceOperation> for server_api::WorkspaceRuntimeResourceOperation {
fn from(operation: BackendResourceOperation) -> Self {
match operation {
BackendResourceOperation::FetchArchive => Self::FetchArchive,
BackendResourceOperation::FetchOnce => Self::FetchOnce,
}
}
}
impl From<ResourceRedactionPolicy> 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,
+37 -1
View File
@@ -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,
}]
);
}
+60 -50
View File
@@ -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<BackendResourceFetchResponse, BackendResourceError> {
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::<BackendResourceFetchResponse>(&response.body).map_err(|err| {
BackendResourceError::InvalidResponse {
message: err.to_string(),
}
})
} else {
let status = response.status;
match serde_json::from_slice::<BackendResourceError>(&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 {
+55 -12
View File
@@ -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<LogicalWorkdirAttachment> {
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(_))
+117 -3
View File
@@ -755,11 +755,25 @@ async fn fetch_workspace_config_http<T>(
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<String, String>,
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
) -> 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<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>,
scope: manifest::SharedScope,
) -> Result<Arc<WorkdirSessionRouter>, 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<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
logical_attachments: &[crate::catalog::LogicalWorkdirAttachment],
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
scope: manifest::SharedScope,
workspace_client: Arc<dyn WorkspaceClient>,
) -> Result<Arc<WorkdirSessionRouter>, 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::<BTreeMap<_, _>>();
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::<BTreeMap<_, _>>();
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();
+222 -79
View File
@@ -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<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
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<T: serde::Serialize>(
response: Result<T, server_api::client_support::ClientError<server_api::RepositoryApiError>>,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
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<server_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
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();
+78 -2
View File
@@ -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<String>,
extra_headers: HeaderMap,
}
impl server_api::client_support::RequestAuthorizer for RuntimeWorkspaceRequestAuthorizer {
fn authorize(
&self,
request: server_api::client_support::AuthorizerRequest<'_>,
) -> Result<HeaderMap, server_api::client_support::AuthorizationError> {
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<String>,
worker_id: Option<String>,
extra_headers: HeaderMap,
timeout: Duration,
response_body_limit: usize,
) -> Result<
server_api::ServerApiClient<RuntimeWorkspaceRequestAuthorizer>,
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,
+136 -110
View File
@@ -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<WorkspaceResponse, WorkspaceClientError>;
async fn restore_worker(
&self,
runtime_id: String,
worker_id: String,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError>;
async fn execute_runtime(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError>;
async fn ensure_permission(
&self,
subject: &super::worker_observation::WorkerObservationSubjectRef,
@@ -144,11 +147,9 @@ impl WorkerControlService for WorkspaceWorkerControlService {
async fn list_workers(&self) -> Result<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError>;
}
#[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<String>,
}
#[derive(Debug, Clone)]
pub struct WorkerLifecycleSpawnRequest {
pub runtime_id: String,
pub working_directory_id: String,
pub relative_cwd: Option<String>,
/// Ordered alias-keyed selections forwarded as canonical Workdir attachments.
pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>,
pub profile: String,
pub ticket_id: Option<String>,
pub operation_id: Option<String>,
@@ -459,7 +482,6 @@ pub struct WorkerLifecycleSpawnRequest {
struct WorkspaceWorkerLifecycleService {
client: Arc<dyn WorkspaceClient>,
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<WorkspaceResponse, WorkspaceClientError> {
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<dyn WorkerLifecycleService> =
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::<WorkerTargetInput>(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<WorkspaceResponse, WorkspaceClientError> {
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());
+12 -4
View File
@@ -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::<MemoryBackendHttpResponse>(&response.body)? {
match serde_json::from_str::<server_api::MemoryBackendResponse>(&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::<MemoryConsolidationOutput>(&response.body).map_err(Into::into)
let response = serde_json::from_str::<server_api::MemoryConsolidationResponse>(&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<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
@@ -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<String>,
}
#[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<String>,
/// Every Workdir required by the Ticket target set. Backend authority derives
/// effective read/write capabilities; callers cannot request capabilities.
workdir_attachments: Vec<SpawnTicketCoderWorkdirInput>,
}
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<SpawnTicketCoderWorkdirInput>,
) -> Result<Vec<WorkerLifecycleWorkdirAttachment>, 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<dyn TicketService>,
worker_service: Arc<dyn WorkerLifecycleService>,
@@ -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<dyn Tool> = 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",
] {
@@ -125,17 +125,16 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, 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::<WorkspaceWorkerObservationListResponse>(&body)
@@ -147,20 +146,28 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
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::<WorkspaceWorkerObservationCaptureResponse>(&body)
+2 -2
View File
@@ -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,
};
+4 -14
View File
@@ -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<SkillDiagnostic>,
/// 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}")]
+128 -4
View File
@@ -544,6 +544,112 @@ pub struct WorkspaceWorkerDiscoveryRequest {
pub query: Option<String>,
}
#[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<T: serde::Serialize>(
path: String,
body: &T,
) -> Result<WorkspaceRequest, WorkspaceClientError> {
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<WorkspaceRequest, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError>;
fn execute_server_operation(
&self,
operation: WorkspaceServerOperation,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
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<C: LlmClient + 'static, St: Store> Worker<C, St> {
.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<C: LlmClient + 'static, St: Store> Worker<C, St> {
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()))?;

Some files were not shown because too many files have changed in this diff Show More