feat: merge develop into hare/develop
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "api-macros-impl"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
openapi = []
|
||||
|
||||
[dependencies]
|
||||
proc-macro-crate = "3"
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full", "extra-traits"] }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "api-macros"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
axum = ["dep:axum", "dep:serde"]
|
||||
openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"]
|
||||
reqwest = [
|
||||
"dep:futures",
|
||||
"dep:reqwest",
|
||||
"dep:serde",
|
||||
"dep:serde_json",
|
||||
"dep:serde_urlencoded",
|
||||
"dep:url",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
api-macros-impl = { path = "../api-macros-impl" }
|
||||
axum = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] }
|
||||
schemars = { version = "1.2", optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
serde_urlencoded = { version = "0.7", optional = true }
|
||||
url = { version = "2", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
oas3 = { version = "0.22", default-features = false }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
trybuild = "1"
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Axum support used by routers generated with `#[api(axum)]`.
|
||||
//!
|
||||
//! Generated operation routers contain only transport extraction, typed service invocation, and
|
||||
//! response mapping. Authentication/authorization context, body-limit policy, timeout policy, and
|
||||
//! other application state remain explicit outer Axum layers. Each operation is also exposed as a
|
||||
//! separate router so route-specific layers can be applied before routers are merged.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Parse one contract header into its declared Rust type.
|
||||
pub fn parse_header<T>(headers: &HeaderMap, name: &'static str) -> Result<T, StatusCode>
|
||||
where
|
||||
T: FromStr,
|
||||
{
|
||||
headers
|
||||
.get(name)
|
||||
.ok_or(StatusCode::BAD_REQUEST)?
|
||||
.to_str()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
.parse()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
/// Serialize a typed JSON response with the contract status.
|
||||
pub fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
|
||||
(status, Json(value)).into_response()
|
||||
}
|
||||
|
||||
/// Emit an empty response with the contract status.
|
||||
pub fn empty_response(status: StatusCode) -> Response {
|
||||
status.into_response()
|
||||
}
|
||||
|
||||
/// Emit a transport-level rejection without a response body.
|
||||
pub fn rejection(status: StatusCode) -> Response {
|
||||
status.into_response()
|
||||
}
|
||||
|
||||
/// Convert a status constant from generated metadata.
|
||||
pub fn status(code: u16) -> StatusCode {
|
||||
StatusCode::from_u16(code).expect("#[api] validates HTTP status constants")
|
||||
}
|
||||
|
||||
/// Reexports used by generated router code.
|
||||
pub mod framework {
|
||||
pub use axum::{
|
||||
Json, Router,
|
||||
extract::{Extension, Path, Query, State, rejection::JsonRejection},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
routing::{delete, get, head, options, patch, post, put},
|
||||
serve,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Shared API trait declarations for Yoi services.
|
||||
//!
|
||||
//! `#[api]` turns a public trait into a single, transport-neutral API definition. The
|
||||
//! original trait remains usable by Rust code; generated metadata and zero-sized operation
|
||||
//! markers give adapters and generators a stable view of the same declaration.
|
||||
//!
|
||||
//! # Declaration syntax
|
||||
//!
|
||||
//! ```
|
||||
//! # mod example {
|
||||
//! use api_macros::api;
|
||||
//!
|
||||
//! pub struct CreateWidget;
|
||||
//! pub struct Widget;
|
||||
//! pub struct PublicError;
|
||||
//!
|
||||
//! #[api]
|
||||
//! pub trait WidgetApi {
|
||||
//! #[post(
|
||||
//! "/widgets",
|
||||
//! operation_id = "widgets.create",
|
||||
//! status = 201,
|
||||
//! error_status = 422
|
||||
//! )]
|
||||
//! async fn create(
|
||||
//! &self,
|
||||
//! #[body] request: CreateWidget,
|
||||
//! #[header("x-request-id")] request_id: String,
|
||||
//! ) -> Result<Widget, PublicError>;
|
||||
//!
|
||||
//! #[get("/widgets/{widget_id}", operation_id = "widgets.get")]
|
||||
//! async fn get(&self, widget_id: u64) -> Result<Widget, PublicError>;
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! Supported route attributes are `get`, `post`, `put`, `patch`, `delete`, `head`, and
|
||||
//! `options`. The first argument is a path template. `operation_id` defaults to the Rust
|
||||
//! method name but should be set explicitly for contracts which must remain stable while Rust
|
||||
//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to
|
||||
//! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP
|
||||
//! outcomes share one schema. A public error inferred from `Result<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.
|
||||
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
|
||||
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
|
||||
//!
|
||||
//! # Generated names
|
||||
//!
|
||||
//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`],
|
||||
//! plus a `widget_api_operations` module. That module contains one marker per Rust method
|
||||
//! (`Create`, `Get`, ...), each implementing [`Operation`] and connecting metadata to concrete
|
||||
//! body types at compile time. Unusual method names which have no valid PascalCase form use the
|
||||
//! deterministic `Operation<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.
|
||||
//!
|
||||
//! # OpenAPI 3.1 export
|
||||
//!
|
||||
//! With the `openapi` feature, `#[api(openapi)]` emits a `<trait_name>_openapi` function from the
|
||||
//! same normalized operation model used by the HTTP adapters. The caller supplies title, version,
|
||||
//! and an opaque source digest through [`openapi::OpenApiInfo`], then either serves
|
||||
//! [`openapi::OpenApiDocument::to_json`] or writes exactly those canonical bytes with
|
||||
//! [`openapi::OpenApiDocument::write_json`] from a CLI. Documents contain no server URLs,
|
||||
//! environment names, timestamps, or other deployment topology.
|
||||
//!
|
||||
//! Every exposed wire type must implement [`openapi::OpenApiSchema`] as an explicit assertion that
|
||||
//! its `schemars::JsonSchema` contract matches Serde serialization. The trait's naming hook is the
|
||||
//! public component-name contract. Different schemas claiming one name, unsafe-width integer
|
||||
//! schemas, and ambiguous non-null `anyOf`/untagged representations fail closed. Use bounded
|
||||
//! integers (for example `u32`) for JSON numeric fields, and model optionality separately from
|
||||
//! nullability through object `required` membership and nullable schemas. A normalized
|
||||
//! `#[header("authorization")]` string (or optional string) is projected as an HTTP bearer
|
||||
//! security scheme and operation requirement rather than as a raw header parameter.
|
||||
//!
|
||||
//! # Optional HTTP adapters
|
||||
//!
|
||||
//! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this
|
||||
//! crate's `reqwest` feature is enabled. `#[api(axum)]` generates `TraitNameAxum` plus one public
|
||||
//! router per operation when the `axum` feature is enabled. `#[api(reqwest, axum)]` emits both
|
||||
//! from the same normalized definition. A plain `#[api]` remains framework-independent and pulls
|
||||
//! neither framework into DTO/contract-only builds.
|
||||
//!
|
||||
//! Generated clients use a fallible `try_new` constructor and builder. The builder validates an
|
||||
//! absolute credential-free HTTP(S) base URL and configures request timeout and response byte
|
||||
//! limit. A prevalidated [`reqwest::BaseUrl`] and policy-configured `reqwest::framework::Client`
|
||||
//! may be injected instead. Request authorizers receive the final method, encoded path/query, and
|
||||
//! exact serialized body bytes; credential headers and all body content are redacted from normal
|
||||
//! diagnostics.
|
||||
//!
|
||||
//! Generated Axum adapters keep authentication, authorization, application state, body limits,
|
||||
//! and timeout layers outside the adapter. Callers may layer the complete router or an individual
|
||||
//! generated operation router before merging it into their application.
|
||||
|
||||
extern crate self as api_macros;
|
||||
|
||||
pub use api_macros_impl::api;
|
||||
|
||||
#[cfg(feature = "axum")]
|
||||
pub mod axum;
|
||||
#[cfg(feature = "openapi")]
|
||||
pub mod openapi;
|
||||
#[cfg(feature = "reqwest")]
|
||||
pub mod reqwest;
|
||||
|
||||
/// A transport-neutral HTTP method.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum HttpMethod {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
Head,
|
||||
Options,
|
||||
}
|
||||
|
||||
/// Where an operation argument is encoded on the wire.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum ParameterLocation {
|
||||
Path,
|
||||
Query,
|
||||
Header,
|
||||
Body,
|
||||
}
|
||||
|
||||
/// Body encodings understood or explicitly reserved by the API contract.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[non_exhaustive]
|
||||
pub enum WireKind {
|
||||
Empty,
|
||||
Json,
|
||||
Streaming,
|
||||
Multipart,
|
||||
Binary,
|
||||
}
|
||||
|
||||
/// Stable metadata for one Rust method argument.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ParameterMetadata {
|
||||
pub rust_name: &'static str,
|
||||
pub wire_name: &'static str,
|
||||
pub location: ParameterLocation,
|
||||
}
|
||||
|
||||
/// Stable metadata for a request or response body.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct BodyMetadata {
|
||||
pub wire_kind: WireKind,
|
||||
}
|
||||
|
||||
/// Stable metadata for one HTTP response.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResponseMetadata {
|
||||
pub status: u16,
|
||||
pub body: BodyMetadata,
|
||||
}
|
||||
|
||||
/// Complete transport metadata for one operation.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct OperationMetadata {
|
||||
pub operation_id: &'static str,
|
||||
pub method: HttpMethod,
|
||||
pub path: &'static str,
|
||||
pub parameters: &'static [ParameterMetadata],
|
||||
pub request_body: BodyMetadata,
|
||||
pub response: ResponseMetadata,
|
||||
pub error_response: Option<ResponseMetadata>,
|
||||
}
|
||||
|
||||
/// Deterministic operation inventory emitted for an `#[api]` trait.
|
||||
pub trait ApiContract {
|
||||
const OPERATIONS: &'static [OperationMetadata];
|
||||
}
|
||||
|
||||
/// Compile-time connection between operation metadata and its concrete body types.
|
||||
pub trait Operation {
|
||||
/// Tuple of all non-receiver parameter types in declaration order.
|
||||
type Parameters;
|
||||
/// JSON request body type, or [`NoBody`].
|
||||
type RequestBody;
|
||||
/// JSON success response body type, or [`NoBody`].
|
||||
type ResponseBody;
|
||||
/// JSON public error body type, or [`NoBody`].
|
||||
type ErrorBody;
|
||||
|
||||
const METADATA: OperationMetadata;
|
||||
}
|
||||
|
||||
/// Public JSON error that can select its concrete HTTP status at the server boundary.
|
||||
///
|
||||
/// `error_status` remains the contract's default/documented status. Implementations may return a
|
||||
/// different 4xx/5xx status for one named error shape, allowing a generated client to preserve a
|
||||
/// service's typed 401/403/404/409 responses without introducing duplicate route handlers.
|
||||
pub trait HttpError {
|
||||
fn status_code(&self) -> u16;
|
||||
}
|
||||
|
||||
/// Successful JSON response that selects one of an operation's declared success statuses.
|
||||
///
|
||||
/// Operations opt in with `alternate_status`; ordinary one-status operations remain fully static.
|
||||
pub trait HttpSuccess {
|
||||
fn status_code(&self) -> u16;
|
||||
}
|
||||
|
||||
/// Public error type capable of normalizing Axum request-body rejections.
|
||||
pub trait HttpRequestError: HttpError {
|
||||
fn from_request_rejection(status: u16, message: String) -> Self;
|
||||
}
|
||||
|
||||
/// Marker used when an operation has no request, response, or public error body.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum NoBody {}
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Deterministic OpenAPI 3.1 projection for `#[api(openapi)]` contracts.
|
||||
//!
|
||||
//! The projection deliberately has no deployment topology (`servers`) and no
|
||||
//! generated timestamps. Callers supply an opaque source digest, which is
|
||||
//! retained as provenance in `info.x-yoi-source-digest`.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fmt, fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub use schemars::{self, JsonSchema};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
|
||||
|
||||
/// An explicit assertion that `JsonSchema` describes the type's serialized
|
||||
/// wire representation.
|
||||
///
|
||||
/// OpenAPI-enabled APIs require this trait instead of accepting every
|
||||
/// `JsonSchema` implementation implicitly. This makes unusual Serde behavior
|
||||
/// (for example `flatten`, `untagged`, or a custom serializer) fail closed
|
||||
/// until the type owner supplies and reviews a matching schema implementation.
|
||||
/// The default component name delegates to [`JsonSchema::schema_name`]; types
|
||||
/// may override it to provide a stable public wire name.
|
||||
pub trait OpenApiSchema: JsonSchema {
|
||||
/// Whether a parameter of this type must be present on the wire.
|
||||
///
|
||||
/// This semantic hook intentionally lives on the resolved type, so aliases
|
||||
/// such as `type Header = Option<String>` retain optionality.
|
||||
const PARAMETER_REQUIRED: bool = true;
|
||||
/// Whether an `Authorization` header of this type is a bearer credential.
|
||||
const BEARER_CREDENTIAL: bool = false;
|
||||
|
||||
fn openapi_schema_name() -> Cow<'static, str> {
|
||||
Self::schema_name()
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_openapi_schema {
|
||||
($($ty:ty),* $(,)?) => {
|
||||
$(impl OpenApiSchema for $ty {})*
|
||||
};
|
||||
}
|
||||
|
||||
impl_openapi_schema!((), bool, char, i8, i16, i32, u8, u16, u32, f32, f64);
|
||||
|
||||
impl OpenApiSchema for String {
|
||||
const BEARER_CREDENTIAL: bool = true;
|
||||
}
|
||||
|
||||
impl<T: OpenApiSchema> OpenApiSchema for Option<T> {
|
||||
const PARAMETER_REQUIRED: bool = false;
|
||||
const BEARER_CREDENTIAL: bool = T::BEARER_CREDENTIAL;
|
||||
}
|
||||
impl<T: OpenApiSchema> OpenApiSchema for Vec<T> {}
|
||||
impl<T: OpenApiSchema> OpenApiSchema for Box<T> {}
|
||||
impl<T: OpenApiSchema> OpenApiSchema for std::sync::Arc<T> {}
|
||||
impl<T: OpenApiSchema> OpenApiSchema for BTreeMap<String, T> {}
|
||||
|
||||
/// Stable, deployment-independent document identity supplied by an exporter.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OpenApiInfo<'a> {
|
||||
pub title: &'a str,
|
||||
pub version: &'a str,
|
||||
/// Opaque digest of the source revision used to build the contract.
|
||||
pub source_digest: &'a str,
|
||||
}
|
||||
|
||||
/// An immutable OpenAPI 3.1 document with canonical JSON rendering.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OpenApiDocument(Value);
|
||||
|
||||
impl OpenApiDocument {
|
||||
pub fn as_value(&self) -> &Value {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Render pretty JSON with recursively sorted object keys and a final LF.
|
||||
pub fn to_json(&self) -> Result<String, OpenApiError> {
|
||||
let mut rendered = serde_json::to_string_pretty(&self.0)?;
|
||||
rendered.push('\n');
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
/// Export the same canonical bytes returned by [`Self::to_json`].
|
||||
pub fn write_json(&self, path: impl AsRef<Path>) -> Result<(), OpenApiError> {
|
||||
fs::write(path, self.to_json()?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OpenApiError {
|
||||
InvalidContract(String),
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for OpenApiError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidContract(message) => formatter.write_str(message),
|
||||
Self::Io(error) => write!(formatter, "failed to export OpenAPI document: {error}"),
|
||||
Self::Json(error) => write!(formatter, "failed to serialize OpenAPI document: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OpenApiError {}
|
||||
|
||||
impl From<std::io::Error> for OpenApiError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for OpenApiError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::Json(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder used by code generated for an `#[api(openapi)]` trait.
|
||||
#[doc(hidden)]
|
||||
pub struct OpenApiBuilder {
|
||||
info: Value,
|
||||
paths: BTreeMap<String, BTreeMap<String, Value>>,
|
||||
schemas: BTreeMap<String, Value>,
|
||||
operation_ids: BTreeSet<String>,
|
||||
uses_bearer_auth: bool,
|
||||
uses_browser_auth: bool,
|
||||
}
|
||||
|
||||
impl OpenApiBuilder {
|
||||
pub fn new(info: OpenApiInfo<'_>) -> Result<Self, OpenApiError> {
|
||||
for (field, value) in [
|
||||
("title", info.title),
|
||||
("version", info.version),
|
||||
("source digest", info.source_digest),
|
||||
] {
|
||||
if value.trim().is_empty() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"OpenAPI {field} must not be empty"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
info: json!({
|
||||
"title": info.title,
|
||||
"version": info.version,
|
||||
"x-yoi-source-digest": info.source_digest,
|
||||
}),
|
||||
paths: BTreeMap::new(),
|
||||
schemas: BTreeMap::new(),
|
||||
operation_ids: BTreeSet::new(),
|
||||
uses_bearer_auth: false,
|
||||
uses_browser_auth: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn operation<'a>(
|
||||
&'a mut self,
|
||||
method: &'static str,
|
||||
path: &'static str,
|
||||
operation_id: &'static str,
|
||||
) -> Result<OpenApiOperation<'a>, OpenApiError> {
|
||||
let method = method.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
method.as_str(),
|
||||
"get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "trace"
|
||||
) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"unsupported OpenAPI method `{method}` for `{operation_id}`"
|
||||
)));
|
||||
}
|
||||
if !self.operation_ids.insert(operation_id.to_owned()) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"duplicate operationId `{operation_id}`"
|
||||
)));
|
||||
}
|
||||
if self
|
||||
.paths
|
||||
.get(path)
|
||||
.is_some_and(|operations| operations.contains_key(&method))
|
||||
{
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"duplicate operation for {method} {path}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(OpenApiOperation {
|
||||
parent: self,
|
||||
method,
|
||||
path: path.to_owned(),
|
||||
operation_id,
|
||||
parameters: Vec::new(),
|
||||
request_body: None,
|
||||
responses: BTreeMap::new(),
|
||||
bearer_security_required: None,
|
||||
browser_security_required: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<OpenApiDocument, OpenApiError> {
|
||||
let paths = self
|
||||
.paths
|
||||
.into_iter()
|
||||
.map(|(path, operations)| {
|
||||
let operations = operations.into_iter().collect::<Map<_, _>>();
|
||||
(path, Value::Object(operations))
|
||||
})
|
||||
.collect::<Map<_, _>>();
|
||||
let schemas = self.schemas.into_iter().collect::<Map<_, _>>();
|
||||
let mut components = Map::new();
|
||||
components.insert("schemas".to_owned(), Value::Object(schemas));
|
||||
let mut security_schemes = Map::new();
|
||||
if self.uses_bearer_auth {
|
||||
security_schemes.insert(
|
||||
"bearerAuth".to_owned(),
|
||||
json!({
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}),
|
||||
);
|
||||
}
|
||||
if self.uses_browser_auth {
|
||||
security_schemes.insert(
|
||||
"browserSession".to_owned(),
|
||||
json!({
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "yoi_workspace_session"
|
||||
}),
|
||||
);
|
||||
}
|
||||
if !security_schemes.is_empty() {
|
||||
components.insert(
|
||||
"securitySchemes".to_owned(),
|
||||
Value::Object(security_schemes),
|
||||
);
|
||||
}
|
||||
|
||||
let document = canonicalize(json!({
|
||||
"openapi": "3.1.0",
|
||||
"info": self.info,
|
||||
"paths": paths,
|
||||
"components": components,
|
||||
}));
|
||||
validate_component_references(&document)?;
|
||||
Ok(OpenApiDocument(document))
|
||||
}
|
||||
|
||||
fn schema_ref<T: OpenApiSchema>(&mut self) -> Result<Value, OpenApiError> {
|
||||
let name = T::openapi_schema_name().into_owned();
|
||||
validate_component_name(&name)?;
|
||||
|
||||
let mut schema = serde_json::to_value(schemars::schema_for!(T))?;
|
||||
let definitions = schema
|
||||
.as_object_mut()
|
||||
.and_then(|object| object.remove("$defs"))
|
||||
.unwrap_or_else(|| json!({}));
|
||||
if let Some(object) = schema.as_object_mut() {
|
||||
object.remove("$schema");
|
||||
object.remove("title");
|
||||
}
|
||||
rewrite_definition_refs(&mut schema);
|
||||
validate_schema(&schema, &name)?;
|
||||
self.insert_schema(name.clone(), canonicalize(schema))?;
|
||||
|
||||
let definitions = definitions.as_object().ok_or_else(|| {
|
||||
OpenApiError::InvalidContract(format!(
|
||||
"schema `{name}` emitted non-object JSON Schema definitions"
|
||||
))
|
||||
})?;
|
||||
for (definition_name, definition) in definitions {
|
||||
validate_component_name(definition_name)?;
|
||||
let mut definition = definition.clone();
|
||||
rewrite_definition_refs(&mut definition);
|
||||
validate_schema(&definition, definition_name)?;
|
||||
self.insert_schema(definition_name.clone(), canonicalize(definition))?;
|
||||
}
|
||||
|
||||
Ok(json!({ "$ref": format!("#/components/schemas/{name}") }))
|
||||
}
|
||||
|
||||
fn insert_schema(&mut self, name: String, schema: Value) -> Result<(), OpenApiError> {
|
||||
match self.schemas.get(&name) {
|
||||
Some(existing) if existing != &schema => Err(OpenApiError::InvalidContract(format!(
|
||||
"OpenAPI component name collision for `{name}`"
|
||||
))),
|
||||
Some(_) => Ok(()),
|
||||
None => {
|
||||
self.schemas.insert(name, schema);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One operation under construction. Generated code adds its complete wire
|
||||
/// metadata before atomically inserting it into the parent document.
|
||||
#[doc(hidden)]
|
||||
pub struct OpenApiOperation<'a> {
|
||||
parent: &'a mut OpenApiBuilder,
|
||||
method: String,
|
||||
path: String,
|
||||
operation_id: &'static str,
|
||||
parameters: Vec<Value>,
|
||||
request_body: Option<Value>,
|
||||
responses: BTreeMap<String, Value>,
|
||||
bearer_security_required: Option<bool>,
|
||||
browser_security_required: bool,
|
||||
}
|
||||
|
||||
impl OpenApiOperation<'_> {
|
||||
pub fn parameter<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
location: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
if !matches!(location, "path" | "query" | "header") {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"unsupported parameter location `{location}` for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
let required = location == "path" || T::PARAMETER_REQUIRED;
|
||||
if location == "header" && name.eq_ignore_ascii_case("authorization") {
|
||||
if !T::BEARER_CREDENTIAL {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"Authorization header for `{}` must use a bearer credential schema",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
if self.bearer_security_required.replace(required).is_some() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"multiple Authorization headers for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
self.parent.uses_bearer_auth = true;
|
||||
return Ok(());
|
||||
}
|
||||
let schema = self.parent.schema_ref::<T>()?;
|
||||
self.parameters.push(json!({
|
||||
"name": name,
|
||||
"in": location,
|
||||
"required": required,
|
||||
"schema": schema,
|
||||
"style": if location == "query" { "form" } else { "simple" },
|
||||
"explode": location == "query",
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bearer_authentication(&mut self) {
|
||||
self.parent.uses_bearer_auth = true;
|
||||
self.bearer_security_required = Some(true);
|
||||
}
|
||||
|
||||
pub fn browser_authentication(&mut self) {
|
||||
self.parent.uses_browser_auth = true;
|
||||
self.browser_security_required = true;
|
||||
}
|
||||
|
||||
pub fn request_body<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
content_type: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
if self.request_body.is_some() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"multiple request bodies for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
let schema = self.parent.schema_ref::<T>()?;
|
||||
self.request_body = Some(json!({
|
||||
"required": true,
|
||||
"content": { content_type: { "schema": schema } },
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn response<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
status: u16,
|
||||
content_type: &'static str,
|
||||
description: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
let schema = self.parent.schema_ref::<T>()?;
|
||||
self.insert_response(
|
||||
status,
|
||||
json!({
|
||||
"description": description,
|
||||
"content": { content_type: { "schema": schema } },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn empty_response(
|
||||
&mut self,
|
||||
status: u16,
|
||||
description: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
self.insert_response(status, json!({ "description": description }))
|
||||
}
|
||||
|
||||
fn insert_response(&mut self, status: u16, response: Value) -> Result<(), OpenApiError> {
|
||||
if !(100..=599).contains(&status) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"invalid HTTP response status `{status}` for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
let status = status.to_string();
|
||||
match self.responses.get(&status) {
|
||||
Some(existing) if existing != &response => Err(OpenApiError::InvalidContract(format!(
|
||||
"conflicting response status `{status}` for `{}`",
|
||||
self.operation_id
|
||||
))),
|
||||
Some(_) => Ok(()),
|
||||
None => {
|
||||
self.responses.insert(status, response);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<(), OpenApiError> {
|
||||
if self.responses.is_empty() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"operation `{}` has no responses",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
let responses = self.responses.into_iter().collect::<Map<_, _>>();
|
||||
let mut operation = Map::new();
|
||||
operation.insert("operationId".to_owned(), json!(self.operation_id));
|
||||
operation.insert("responses".to_owned(), Value::Object(responses));
|
||||
if !self.parameters.is_empty() {
|
||||
operation.insert("parameters".to_owned(), Value::Array(self.parameters));
|
||||
}
|
||||
if let Some(request_body) = self.request_body {
|
||||
operation.insert("requestBody".to_owned(), request_body);
|
||||
}
|
||||
let mut security = Vec::new();
|
||||
if self.bearer_security_required == Some(false) {
|
||||
security.push(json!({}));
|
||||
}
|
||||
if self.bearer_security_required.is_some() {
|
||||
security.push(json!({ "bearerAuth": [] }));
|
||||
}
|
||||
if self.browser_security_required {
|
||||
security.push(json!({ "browserSession": [] }));
|
||||
}
|
||||
if !security.is_empty() {
|
||||
operation.insert("security".to_owned(), Value::Array(security));
|
||||
}
|
||||
self.parent
|
||||
.paths
|
||||
.entry(self.path)
|
||||
.or_default()
|
||||
.insert(self.method, Value::Object(operation));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_component_name(name: &str) -> Result<(), OpenApiError> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"OpenAPI component name `{name}` must match [A-Za-z0-9._-]+"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_component_references(document: &Value) -> Result<(), OpenApiError> {
|
||||
let schemas = document["components"]["schemas"]
|
||||
.as_object()
|
||||
.ok_or_else(|| OpenApiError::InvalidContract("missing component schema map".to_owned()))?;
|
||||
let mut references = Vec::new();
|
||||
collect_references(document, &mut references);
|
||||
for reference in references {
|
||||
let Some(name) = reference.strip_prefix("#/components/schemas/") else {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"unsupported non-component schema reference `{reference}`"
|
||||
)));
|
||||
};
|
||||
if !schemas.contains_key(name) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"unresolved OpenAPI component reference `{reference}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_references<'a>(value: &'a Value, references: &mut Vec<&'a str>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
|
||||
references.push(reference);
|
||||
}
|
||||
for value in object.values() {
|
||||
collect_references(value, references);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
collect_references(value, references);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_definition_refs(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(Value::String(reference)) = object.get_mut("$ref") {
|
||||
if let Some(name) = reference.strip_prefix("#/$defs/") {
|
||||
*reference = format!("#/components/schemas/{name}");
|
||||
}
|
||||
}
|
||||
for value in object.values_mut() {
|
||||
rewrite_definition_refs(value);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => values.iter_mut().for_each(rewrite_definition_refs),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_schema(value: &Value, component: &str) -> Result<(), OpenApiError> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if object.get("type").and_then(Value::as_str) == Some("integer") {
|
||||
let safe_format = matches!(
|
||||
object.get("format").and_then(Value::as_str),
|
||||
Some("int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32")
|
||||
);
|
||||
let minimum = object.get("minimum").and_then(Value::as_f64);
|
||||
let maximum = object.get("maximum").and_then(Value::as_f64);
|
||||
if !safe_format
|
||||
&& (minimum.is_none_or(|value| value < -(MAX_SAFE_INTEGER as f64))
|
||||
|| maximum.is_none_or(|value| value > MAX_SAFE_INTEGER as f64))
|
||||
{
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"integer schema in `{component}` exceeds the JSON safe-integer range; expose a bounded wire integer"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// A non-null anyOf is commonly emitted for Serde's untagged enum
|
||||
// representation. Its branch-selection semantics are not lossless
|
||||
// enough for this projection. Nullable schemas remain supported.
|
||||
if let Some(Value::Array(branches)) = object.get("anyOf") {
|
||||
let null_branches = branches
|
||||
.iter()
|
||||
.filter(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
|
||||
.count();
|
||||
if null_branches != 1 || branches.len() != 2 {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"schema `{component}` uses unsupported ambiguous anyOf/untagged semantics"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for value in object.values() {
|
||||
validate_schema(value, component)?;
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
validate_schema(value, component)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonicalize(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let sorted = object
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, canonicalize(value)))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
Value::Object(sorted)
|
||||
}
|
||||
Value::Array(values) => Value::Array(values.into_iter().map(canonicalize).collect()),
|
||||
scalar => scalar,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
//! Reqwest transport support used by clients generated with `#[api(reqwest)]`.
|
||||
//!
|
||||
//! The generated builder validates the base URL before it can be stored. Callers that enforce
|
||||
//! stricter egress policy (custom DNS resolution, proxy policy, TLS roots, or connection pinning)
|
||||
//! can construct their own [`reqwest::Client`] and inject it with the validated [`BaseUrl`].
|
||||
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
use futures::StreamExt as _;
|
||||
use reqwest::{
|
||||
Client, Method, StatusCode, Url,
|
||||
header::{HeaderMap, HeaderName, HeaderValue},
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
/// Default timeout applied to the complete request, including response-body reads.
|
||||
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Default maximum response body size (1 MiB).
|
||||
pub const DEFAULT_RESPONSE_BODY_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// A parsed HTTP(S) base URL without credentials, query, or fragment.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct BaseUrl(Url);
|
||||
|
||||
impl BaseUrl {
|
||||
/// Access the validated URL.
|
||||
pub fn as_url(&self) -> &Url {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for BaseUrl {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.debug_tuple("BaseUrl").field(&self.0).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for BaseUrl {
|
||||
type Error = ClientBuildError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Url::parse(value)
|
||||
.map_err(|_| ClientBuildError::InvalidBaseUrl)
|
||||
.and_then(Self::try_from)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for BaseUrl {
|
||||
type Error = ClientBuildError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::try_from(value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Url> for BaseUrl {
|
||||
type Error = ClientBuildError;
|
||||
|
||||
fn try_from(mut value: Url) -> Result<Self, Self::Error> {
|
||||
if !matches!(value.scheme(), "http" | "https")
|
||||
|| value.cannot_be_a_base()
|
||||
|| value.host_str().is_none()
|
||||
|| !value.username().is_empty()
|
||||
|| value.password().is_some()
|
||||
|| value.query().is_some()
|
||||
|| value.fragment().is_some()
|
||||
{
|
||||
return Err(ClientBuildError::InvalidBaseUrl);
|
||||
}
|
||||
if !value.path().ends_with('/') {
|
||||
let mut path = value.path().to_owned();
|
||||
path.push('/');
|
||||
value.set_path(&path);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure while constructing a generated client.
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ClientBuildError {
|
||||
/// The base URL was not an absolute credential-free HTTP(S) URL without query or fragment.
|
||||
InvalidBaseUrl,
|
||||
/// Reqwest could not construct its default client.
|
||||
ClientConstruction,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientBuildError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientBuildError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::InvalidBaseUrl => "invalid HTTP client base URL",
|
||||
Self::ClientConstruction => "failed to construct HTTP client",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientBuildError {}
|
||||
|
||||
/// Exact canonical request material supplied to a [`RequestAuthorizer`].
|
||||
///
|
||||
/// `body` is the same byte slice subsequently passed to Reqwest. `path_and_query` is taken from
|
||||
/// the final URL after path-segment and query encoding.
|
||||
pub struct AuthorizerRequest<'a> {
|
||||
pub method: &'a Method,
|
||||
pub path_and_query: &'a str,
|
||||
pub body: &'a [u8],
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizerRequest<'_> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AuthorizerRequest")
|
||||
.field("method", self.method)
|
||||
.field("path_and_query", &"<redacted>")
|
||||
.field("body", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque authorizer failure. Secret-bearing authorizer errors are intentionally not retained.
|
||||
#[derive(Clone, Copy, Default, Eq, PartialEq)]
|
||||
pub struct AuthorizationError(());
|
||||
|
||||
impl AuthorizationError {
|
||||
pub const fn new() -> Self {
|
||||
Self(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("AuthorizationError")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AuthorizationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("request authorization failed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuthorizationError {}
|
||||
|
||||
/// Supplies credential headers for an exact encoded request.
|
||||
pub trait RequestAuthorizer: Send + Sync {
|
||||
fn authorize(&self, request: AuthorizerRequest<'_>) -> Result<HeaderMap, AuthorizationError>;
|
||||
}
|
||||
|
||||
/// Authorizer used by default; it adds no headers.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct NoAuthorizer;
|
||||
|
||||
impl RequestAuthorizer for NoAuthorizer {
|
||||
fn authorize(&self, _request: AuthorizerRequest<'_>) -> Result<HeaderMap, AuthorizationError> {
|
||||
Ok(HeaderMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for NoAuthorizer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("NoAuthorizer")
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder shared by generated API-specific client builders.
|
||||
pub struct ClientBuilder<A = NoAuthorizer> {
|
||||
base_url: BaseUrl,
|
||||
client: Option<Client>,
|
||||
authorizer: A,
|
||||
request_timeout: Duration,
|
||||
response_body_limit: usize,
|
||||
}
|
||||
|
||||
impl ClientBuilder<NoAuthorizer> {
|
||||
pub fn try_new(base_url: impl AsRef<str>) -> Result<Self, ClientBuildError> {
|
||||
Ok(Self::from_base_url(BaseUrl::try_from(base_url.as_ref())?))
|
||||
}
|
||||
|
||||
pub fn from_base_url(base_url: BaseUrl) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
client: None,
|
||||
authorizer: NoAuthorizer,
|
||||
request_timeout: DEFAULT_REQUEST_TIMEOUT,
|
||||
response_body_limit: DEFAULT_RESPONSE_BODY_LIMIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ClientBuilder<A> {
|
||||
pub fn client(mut self, client: Client) -> Self {
|
||||
self.client = Some(client);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn authorizer<B>(self, authorizer: B) -> ClientBuilder<B> {
|
||||
ClientBuilder {
|
||||
base_url: self.base_url,
|
||||
client: self.client,
|
||||
authorizer,
|
||||
request_timeout: self.request_timeout,
|
||||
response_body_limit: self.response_body_limit,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.request_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn response_body_limit(mut self, limit: usize) -> Self {
|
||||
self.response_body_limit = limit;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<ClientCore<A>, ClientBuildError> {
|
||||
let client = match self.client {
|
||||
Some(client) => client,
|
||||
None => Client::builder()
|
||||
.build()
|
||||
.map_err(|_| ClientBuildError::ClientConstruction)?,
|
||||
};
|
||||
Ok(ClientCore {
|
||||
base_url: self.base_url,
|
||||
client,
|
||||
authorizer: self.authorizer,
|
||||
request_timeout: self.request_timeout,
|
||||
response_body_limit: self.response_body_limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> fmt::Debug for ClientBuilder<A> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ClientBuilder")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("client", &"<opaque>")
|
||||
.field("authorizer", &"<redacted>")
|
||||
.field("request_timeout", &self.request_timeout)
|
||||
.field("response_body_limit", &self.response_body_limit)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Framework support held by each generated API client.
|
||||
pub struct ClientCore<A = NoAuthorizer> {
|
||||
base_url: BaseUrl,
|
||||
client: Client,
|
||||
authorizer: A,
|
||||
request_timeout: Duration,
|
||||
response_body_limit: usize,
|
||||
}
|
||||
|
||||
impl<A: Clone> Clone for ClientCore<A> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
base_url: self.base_url.clone(),
|
||||
client: self.client.clone(),
|
||||
authorizer: self.authorizer.clone(),
|
||||
request_timeout: self.request_timeout,
|
||||
response_body_limit: self.response_body_limit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> fmt::Debug for ClientCore<A> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ClientCore")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("client", &"<opaque>")
|
||||
.field("authorizer", &"<redacted>")
|
||||
.field("request_timeout", &self.request_timeout)
|
||||
.field("response_body_limit", &self.response_body_limit)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ClientCore<A> {
|
||||
pub fn from_parts(
|
||||
base_url: BaseUrl,
|
||||
client: Client,
|
||||
authorizer: A,
|
||||
request_timeout: Duration,
|
||||
response_body_limit: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
client,
|
||||
authorizer,
|
||||
request_timeout,
|
||||
response_body_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an endpoint by appending already-separated, unencoded path segments.
|
||||
pub fn endpoint(&self, segments: &[String]) -> Result<Url, ClientFailure> {
|
||||
let mut url = self.base_url.0.clone();
|
||||
let mut path = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| ClientFailure::RequestEncoding)?;
|
||||
path.pop_if_empty();
|
||||
for segment in segments {
|
||||
path.push(segment);
|
||||
}
|
||||
drop(path);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn response_body_limit(&self) -> usize {
|
||||
self.response_body_limit
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: RequestAuthorizer> ClientCore<A> {
|
||||
pub async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
url: Url,
|
||||
mut headers: HeaderMap,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Result<ReceivedResponse, ClientFailure> {
|
||||
let body_bytes = body.as_deref().unwrap_or_default();
|
||||
let path_and_query = &url[url::Position::BeforePath..url::Position::AfterQuery];
|
||||
let authorization = self
|
||||
.authorizer
|
||||
.authorize(AuthorizerRequest {
|
||||
method: &method,
|
||||
path_and_query,
|
||||
body: body_bytes,
|
||||
})
|
||||
.map_err(|_| ClientFailure::Authorization)?;
|
||||
headers.extend(authorization);
|
||||
|
||||
let mut request = self
|
||||
.client
|
||||
.request(method, url)
|
||||
.headers(headers)
|
||||
.timeout(self.request_timeout);
|
||||
if let Some(body) = body {
|
||||
request = request
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
ClientFailure::Timeout
|
||||
} else if error.is_connect() {
|
||||
ClientFailure::Connect
|
||||
} else {
|
||||
ClientFailure::Transport
|
||||
}
|
||||
})?;
|
||||
let status = response.status();
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
ClientFailure::Timeout
|
||||
} else {
|
||||
ClientFailure::Transport
|
||||
}
|
||||
})?;
|
||||
let Some(size) = bytes.len().checked_add(chunk.len()) else {
|
||||
return Err(ClientFailure::ResponseTooLarge {
|
||||
limit: self.response_body_limit,
|
||||
});
|
||||
};
|
||||
if size > self.response_body_limit {
|
||||
return Err(ClientFailure::ResponseTooLarge {
|
||||
limit: self.response_body_limit,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(ReceivedResponse { status, bytes })
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a serializable query DTO and append its pairs without changing existing pairs.
|
||||
pub fn append_query<T: Serialize + ?Sized>(url: &mut Url, value: &T) -> Result<(), ClientFailure> {
|
||||
let encoded = serde_urlencoded::to_string(value).map_err(|_| ClientFailure::RequestEncoding)?;
|
||||
if encoded.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let combined = match url.query() {
|
||||
Some(existing) if !existing.is_empty() => format!("{existing}&{encoded}"),
|
||||
_ => encoded,
|
||||
};
|
||||
url.set_query(Some(&combined));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a string-form header value.
|
||||
pub fn insert_header<T: fmt::Display>(
|
||||
headers: &mut HeaderMap,
|
||||
name: &str,
|
||||
value: &T,
|
||||
) -> Result<(), ClientFailure> {
|
||||
let name =
|
||||
HeaderName::from_bytes(name.as_bytes()).map_err(|_| ClientFailure::RequestEncoding)?;
|
||||
let value =
|
||||
HeaderValue::from_str(&value.to_string()).map_err(|_| ClientFailure::RequestEncoding)?;
|
||||
headers.insert(name, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize the request body once. These exact bytes are authorized and transmitted.
|
||||
pub fn encode_json<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, ClientFailure> {
|
||||
serde_json::to_vec(value).map_err(|_| ClientFailure::RequestEncoding)
|
||||
}
|
||||
|
||||
/// Response received under the configured byte limit. Its content is always redacted in Debug.
|
||||
pub struct ReceivedResponse {
|
||||
status: StatusCode,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ReceivedResponse {
|
||||
pub fn status(&self) -> StatusCode {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn decode_json<T: DeserializeOwned>(&self, kind: DecodeKind) -> Result<T, ClientFailure> {
|
||||
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
|
||||
}
|
||||
|
||||
pub fn require_empty(&self) -> Result<(), ClientFailure> {
|
||||
if self.bytes.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ClientFailure::UnexpectedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ReceivedResponse {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ReceivedResponse")
|
||||
.field("status", &self.status)
|
||||
.field("bytes", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DecodeKind {
|
||||
Success,
|
||||
PublicError,
|
||||
}
|
||||
|
||||
/// Non-public-error failure from a generated client.
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ClientFailure {
|
||||
Authorization,
|
||||
RequestEncoding,
|
||||
Connect,
|
||||
Transport,
|
||||
Timeout,
|
||||
ResponseTooLarge { limit: usize },
|
||||
UnexpectedStatus { expected: u16, actual: u16 },
|
||||
ErrorResponseDecode { status: u16 },
|
||||
Decode { kind: DecodeKind },
|
||||
UnexpectedBody,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientFailure {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientFailure {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Authorization => formatter.write_str("request authorization failed"),
|
||||
Self::RequestEncoding => formatter.write_str("request encoding failed"),
|
||||
Self::Connect => formatter.write_str("HTTP connection failed"),
|
||||
Self::Transport => formatter.write_str("HTTP transport failed"),
|
||||
Self::Timeout => formatter.write_str("HTTP request timed out"),
|
||||
Self::ResponseTooLarge { limit } => {
|
||||
write!(formatter, "response body exceeded {limit} bytes")
|
||||
}
|
||||
Self::UnexpectedStatus { expected, actual } => {
|
||||
write!(
|
||||
formatter,
|
||||
"expected HTTP status {expected}, received {actual}"
|
||||
)
|
||||
}
|
||||
Self::ErrorResponseDecode { status } => {
|
||||
write!(formatter, "HTTP {status} error response JSON was malformed")
|
||||
}
|
||||
Self::Decode {
|
||||
kind: DecodeKind::Success,
|
||||
} => formatter.write_str("success response JSON was malformed"),
|
||||
Self::Decode {
|
||||
kind: DecodeKind::PublicError,
|
||||
} => formatter.write_str("public error response JSON was malformed"),
|
||||
Self::UnexpectedBody => formatter.write_str("empty response contained a body"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientFailure {}
|
||||
|
||||
/// Typed error returned by generated client methods.
|
||||
pub enum ClientError<E> {
|
||||
Failure(ClientFailure),
|
||||
Public { status: StatusCode, error: E },
|
||||
}
|
||||
|
||||
impl<E> ClientError<E> {
|
||||
pub fn failure(failure: ClientFailure) -> Self {
|
||||
Self::Failure(failure)
|
||||
}
|
||||
|
||||
pub fn public(status: StatusCode, error: E) -> Self {
|
||||
Self::Public { status, error }
|
||||
}
|
||||
|
||||
pub fn into_public_error(self) -> Option<E> {
|
||||
match self {
|
||||
Self::Public { error, .. } => Some(error),
|
||||
Self::Failure(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<ClientFailure> for ClientError<E> {
|
||||
fn from(value: ClientFailure) -> Self {
|
||||
Self::Failure(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> fmt::Debug for ClientError<E> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Failure(failure) => formatter.debug_tuple("Failure").field(failure).finish(),
|
||||
Self::Public { status, .. } => formatter
|
||||
.debug_struct("Public")
|
||||
.field("status", status)
|
||||
.field("error", &"<redacted>")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> fmt::Display for ClientError<E> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Failure(failure) => fmt::Display::fmt(failure, formatter),
|
||||
Self::Public { status, .. } => write!(formatter, "public HTTP error {status}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> std::error::Error for ClientError<E> {}
|
||||
|
||||
/// Reexports used by generated code and advanced client construction.
|
||||
pub mod framework {
|
||||
pub use reqwest::{Client, Method, StatusCode, Url, header};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#[test]
|
||||
fn invalid_api_declarations_have_stable_diagnostics() {
|
||||
let tests = trybuild::TestCases::new();
|
||||
tests.compile_fail("tests/ui/*.rs");
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#![allow(async_fn_in_trait)]
|
||||
|
||||
use api_macros::{ApiContract, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api};
|
||||
|
||||
pub struct CreateWidget;
|
||||
pub struct Widget;
|
||||
pub struct Lookup;
|
||||
pub struct PublicError;
|
||||
pub struct SearchResult<T>(std::marker::PhantomData<T>);
|
||||
|
||||
#[api]
|
||||
pub trait WidgetApi {
|
||||
#[post(
|
||||
"/widgets",
|
||||
operation_id = "widgets.create",
|
||||
status = 201,
|
||||
error_status = 422
|
||||
)]
|
||||
async fn create(
|
||||
&self,
|
||||
#[body] request: CreateWidget,
|
||||
#[header("x-request-id")] request_id: String,
|
||||
) -> Result<Widget, PublicError>;
|
||||
|
||||
#[get("/widgets/{widget_id}", operation_id = "widgets.get")]
|
||||
async fn lookup(&self, widget_id: u64, #[query] query: Lookup) -> Result<Widget, PublicError>;
|
||||
|
||||
#[delete("/widgets/{widget_id}", operation_id = "widgets.delete")]
|
||||
async fn delete(&self, #[path] widget_id: u64) -> ();
|
||||
}
|
||||
|
||||
#[api]
|
||||
pub trait WrapperApi {
|
||||
#[get("/search", operation_id = "search")]
|
||||
async fn search(&self) -> SearchResult<Widget>;
|
||||
}
|
||||
|
||||
#[api]
|
||||
pub trait RawIdentifierApi {
|
||||
#[get("/type", operation_id = "raw.type")]
|
||||
async fn r#type(&self) -> Widget;
|
||||
}
|
||||
|
||||
#[api]
|
||||
pub trait UnusualIdentifierApi {
|
||||
#[get("/underscores", operation_id = "unusual.underscores")]
|
||||
async fn __(&self) -> Widget;
|
||||
|
||||
#[get("/underscore-digit", operation_id = "unusual.underscore_digit")]
|
||||
async fn _0(&self) -> Widget;
|
||||
}
|
||||
|
||||
fn assert_operation_types<O>()
|
||||
where
|
||||
O: Operation<
|
||||
Parameters = (CreateWidget, String),
|
||||
RequestBody = CreateWidget,
|
||||
ResponseBody = Widget,
|
||||
ErrorBody = PublicError,
|
||||
>,
|
||||
{
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_exposes_deterministic_metadata_and_type_connections() {
|
||||
assert_operation_types::<widget_api_operations::Create>();
|
||||
|
||||
fn assert_named_wrapper<O: Operation<ResponseBody = SearchResult<Widget>>>() {}
|
||||
assert_named_wrapper::<wrapper_api_operations::Search>();
|
||||
let raw = <raw_identifier_api_operations::Type as Operation>::METADATA;
|
||||
assert_eq!(raw.operation_id, "raw.type");
|
||||
let underscores = <unusual_identifier_api_operations::Operation5f5f as Operation>::METADATA;
|
||||
assert_eq!(underscores.operation_id, "unusual.underscores");
|
||||
let underscore_digit =
|
||||
<unusual_identifier_api_operations::Operation5f30 as Operation>::METADATA;
|
||||
assert_eq!(underscore_digit.operation_id, "unusual.underscore_digit");
|
||||
|
||||
let operations = <WidgetApiMetadata as ApiContract>::OPERATIONS;
|
||||
assert_eq!(operations.len(), 3);
|
||||
assert_eq!(operations[0].operation_id, "widgets.create");
|
||||
assert_eq!(operations[1].operation_id, "widgets.delete");
|
||||
assert_eq!(operations[2].operation_id, "widgets.get");
|
||||
|
||||
let create = <widget_api_operations::Create as Operation>::METADATA;
|
||||
assert_eq!(operations[0], create);
|
||||
assert_eq!(create.method, HttpMethod::Post);
|
||||
assert_eq!(create.path, "/widgets");
|
||||
assert_eq!(create.request_body.wire_kind, WireKind::Json);
|
||||
assert_eq!(create.response.status, 201);
|
||||
assert_eq!(create.response.body.wire_kind, WireKind::Json);
|
||||
assert_eq!(create.error_response.unwrap().status, 422);
|
||||
assert_eq!(create.parameters[0].location, ParameterLocation::Body);
|
||||
assert_eq!(create.parameters[1].location, ParameterLocation::Header);
|
||||
assert_eq!(create.parameters[1].wire_name, "x-request-id");
|
||||
|
||||
let lookup = <widget_api_operations::Lookup as Operation>::METADATA;
|
||||
assert_eq!(lookup.parameters[0].location, ParameterLocation::Path);
|
||||
assert_eq!(lookup.parameters[1].location, ParameterLocation::Query);
|
||||
|
||||
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);
|
||||
|
||||
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>();
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
#![cfg(all(feature = "reqwest", feature = "axum"))]
|
||||
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use api_macros::{api, reqwest as api_reqwest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CreateWidget {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct Lookup {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct Widget {
|
||||
id: u64,
|
||||
name: String,
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct PublicError {
|
||||
message: String,
|
||||
#[serde(skip)]
|
||||
status: u16,
|
||||
}
|
||||
|
||||
impl api_macros::HttpError for PublicError {
|
||||
fn status_code(&self) -> u16 {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
|
||||
#[api(reqwest, axum)]
|
||||
pub trait WidgetApi {
|
||||
#[post(
|
||||
"/widgets/{widget_id}",
|
||||
operation_id = "widgets.create",
|
||||
status = 201,
|
||||
error_status = 422
|
||||
)]
|
||||
async fn create(
|
||||
&self,
|
||||
#[path] widget_id: u64,
|
||||
#[query] lookup: Lookup,
|
||||
#[header("authorization")] authorization: String,
|
||||
#[body] request: CreateWidget,
|
||||
) -> Result<Widget, PublicError>;
|
||||
|
||||
#[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)]
|
||||
async fn delete(&self, #[path] widget_id: u64) -> ();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WidgetService;
|
||||
|
||||
impl WidgetApi for WidgetService {
|
||||
async fn create(
|
||||
&self,
|
||||
widget_id: u64,
|
||||
lookup: Lookup,
|
||||
authorization: String,
|
||||
request: CreateWidget,
|
||||
) -> Result<Widget, PublicError> {
|
||||
assert_eq!(authorization, "Bearer top-secret");
|
||||
if request.name == "bad" {
|
||||
return Err(PublicError {
|
||||
message: "sensitive-public-detail".to_owned(),
|
||||
status: 409,
|
||||
});
|
||||
}
|
||||
Ok(Widget {
|
||||
id: widget_id,
|
||||
name: request.name,
|
||||
mode: lookup.mode,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(&self, _widget_id: u64) {}
|
||||
}
|
||||
|
||||
#[api(reqwest)]
|
||||
pub trait FailureApi {
|
||||
#[get("/malformed", operation_id = "failure.malformed", status = 200)]
|
||||
async fn malformed(&self) -> Widget;
|
||||
|
||||
#[get("/oversized", operation_id = "failure.oversized", status = 200)]
|
||||
async fn oversized(&self) -> Widget;
|
||||
|
||||
#[get("/slow", operation_id = "failure.slow", status = 200)]
|
||||
async fn slow(&self) -> Widget;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AuthorizedRequest {
|
||||
method: String,
|
||||
path_and_query: String,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RecordingAuthorizer {
|
||||
request: Arc<Mutex<Option<AuthorizedRequest>>>,
|
||||
credential: String,
|
||||
}
|
||||
|
||||
impl api_reqwest::RequestAuthorizer for RecordingAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
request: api_reqwest::AuthorizerRequest<'_>,
|
||||
) -> Result<api_reqwest::framework::header::HeaderMap, api_reqwest::AuthorizationError> {
|
||||
*self.request.lock().expect("recording lock") = Some(AuthorizedRequest {
|
||||
method: request.method.to_string(),
|
||||
path_and_query: request.path_and_query.to_owned(),
|
||||
body: request.body.to_vec(),
|
||||
});
|
||||
let mut headers = api_reqwest::framework::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
api_reqwest::framework::header::AUTHORIZATION,
|
||||
self.credential.parse().expect("valid fixture credential"),
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
async fn malformed() -> &'static str {
|
||||
"this is not json"
|
||||
}
|
||||
|
||||
async fn oversized() -> String {
|
||||
"response-content-that-must-never-appear-in-debug-output".to_owned()
|
||||
}
|
||||
|
||||
async fn slow() -> &'static str {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
"{}"
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_client_and_router_follow_the_normalized_contract() {
|
||||
let app = WidgetApiAxum::router(WidgetService).merge(
|
||||
api_macros::axum::framework::Router::new()
|
||||
.route("/malformed", api_macros::axum::framework::get(malformed))
|
||||
.route("/oversized", api_macros::axum::framework::get(oversized))
|
||||
.route("/slow", api_macros::axum::framework::get(slow)),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind fixture server");
|
||||
let address = listener.local_addr().expect("fixture address");
|
||||
let server = tokio::spawn(async move {
|
||||
api_macros::axum::framework::serve(listener, app)
|
||||
.await
|
||||
.expect("serve fixture API");
|
||||
});
|
||||
let base_url = format!("http://{address}/");
|
||||
|
||||
let recorded = Arc::new(Mutex::new(None));
|
||||
let client = WidgetApiClient::builder(&base_url)
|
||||
.expect("validated base URL")
|
||||
.authorizer(RecordingAuthorizer {
|
||||
request: recorded.clone(),
|
||||
credential: "Bearer top-secret".to_owned(),
|
||||
})
|
||||
.build()
|
||||
.expect("client build");
|
||||
|
||||
let widget = client
|
||||
.create(
|
||||
7,
|
||||
Lookup {
|
||||
mode: "full".to_owned(),
|
||||
},
|
||||
"placeholder-overwritten-by-authorizer".to_owned(),
|
||||
CreateWidget {
|
||||
name: "alpha".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("typed success response");
|
||||
assert_eq!(
|
||||
widget,
|
||||
Widget {
|
||||
id: 7,
|
||||
name: "alpha".to_owned(),
|
||||
mode: "full".to_owned(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
recorded.lock().expect("recording lock").clone(),
|
||||
Some(AuthorizedRequest {
|
||||
method: "POST".to_owned(),
|
||||
path_and_query: "/widgets/7?mode=full".to_owned(),
|
||||
body: br#"{"name":"alpha"}"#.to_vec(),
|
||||
})
|
||||
);
|
||||
assert!(!format!("{client:?}").contains("top-secret"));
|
||||
|
||||
client.delete(7).await.expect("typed empty response");
|
||||
|
||||
let public_error = client
|
||||
.create(
|
||||
8,
|
||||
Lookup {
|
||||
mode: "full".to_owned(),
|
||||
},
|
||||
"placeholder".to_owned(),
|
||||
CreateWidget {
|
||||
name: "bad".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("typed public error");
|
||||
assert!(!format!("{public_error:?}").contains("sensitive-public-detail"));
|
||||
assert!(matches!(
|
||||
&public_error,
|
||||
api_reqwest::ClientError::Public { status, .. }
|
||||
if *status == api_reqwest::framework::StatusCode::CONFLICT
|
||||
));
|
||||
assert_eq!(
|
||||
public_error.into_public_error(),
|
||||
Some(PublicError {
|
||||
message: "sensitive-public-detail".to_owned(),
|
||||
status: 0,
|
||||
})
|
||||
);
|
||||
|
||||
let malformed_error = FailureApiClient::try_new(&base_url)
|
||||
.expect("failure client")
|
||||
.malformed()
|
||||
.await
|
||||
.expect_err("malformed JSON must fail");
|
||||
assert_eq!(
|
||||
format!("{malformed_error:?}"),
|
||||
"Failure(success response JSON was malformed)"
|
||||
);
|
||||
assert!(!format!("{malformed_error:?}").contains("this is not json"));
|
||||
|
||||
let oversized_error = FailureApiClient::builder(&base_url)
|
||||
.expect("failure client builder")
|
||||
.response_body_limit(8)
|
||||
.build()
|
||||
.expect("failure client")
|
||||
.oversized()
|
||||
.await
|
||||
.expect_err("oversized body must fail");
|
||||
assert!(matches!(
|
||||
oversized_error,
|
||||
api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::ResponseTooLarge {
|
||||
limit: 8
|
||||
})
|
||||
));
|
||||
assert!(!format!("{oversized_error:?}").contains("response-content"));
|
||||
|
||||
let timeout_error = FailureApiClient::builder(&base_url)
|
||||
.expect("failure client builder")
|
||||
.request_timeout(Duration::from_millis(10))
|
||||
.build()
|
||||
.expect("failure client")
|
||||
.slow()
|
||||
.await
|
||||
.expect_err("slow response must time out");
|
||||
assert!(matches!(
|
||||
timeout_error,
|
||||
api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::Timeout)
|
||||
));
|
||||
|
||||
let _route_specific = widget_api_axum::create(Arc::new(WidgetService));
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_urls_are_strict_and_named_adapter_types_are_public() {
|
||||
assert!(api_reqwest::BaseUrl::try_from("ftp://example.test/").is_err());
|
||||
assert!(api_reqwest::BaseUrl::try_from("https://user@example.test/").is_err());
|
||||
assert!(api_reqwest::BaseUrl::try_from("https://example.test/?token=secret").is_err());
|
||||
assert!(api_reqwest::BaseUrl::try_from("https://example.test/#fragment").is_err());
|
||||
|
||||
fn assert_client(_: &WidgetApiClient) {}
|
||||
fn assert_builder(_: &WidgetApiClientBuilder) {}
|
||||
let client = WidgetApiClient::try_new("https://example.test/").expect("client");
|
||||
let builder = WidgetApiClient::builder("https://example.test/").expect("builder");
|
||||
assert_client(&client);
|
||||
assert_builder(&builder);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
#![cfg(feature = "openapi")]
|
||||
#![allow(async_fn_in_trait, dead_code)]
|
||||
|
||||
use api_macros::{
|
||||
api,
|
||||
openapi::{OpenApiInfo, OpenApiSchema},
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
generator.subschema_for::<Option<String>>()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||
pub struct GeoPoint {
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
}
|
||||
impl OpenApiSchema for GeoPoint {}
|
||||
|
||||
/// A custom Serde wire representation admitted only after an explicit,
|
||||
/// matching schema hook and `OpenApiSchema` attestation.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(transparent)]
|
||||
pub struct WireSlug(String);
|
||||
|
||||
impl Serialize for WireSlug {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WireSlug {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
String::deserialize(deserializer).map(Self)
|
||||
}
|
||||
}
|
||||
impl OpenApiSchema for WireSlug {}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||
pub struct CreateWidget {
|
||||
#[schemars(length(min = 1, max = 64))]
|
||||
name: String,
|
||||
#[schemars(range(min = 1, max = 1_000))]
|
||||
count: u32,
|
||||
#[schemars(range(min = -100, max = 100))]
|
||||
bounded_balance: i64,
|
||||
#[schemars(length(min = 1, max = 8))]
|
||||
tags: Vec<String>,
|
||||
slug: WireSlug,
|
||||
/// Missing and explicit null are separate wire states.
|
||||
#[schemars(default, schema_with = "nullable_string_schema")]
|
||||
optional_label: Option<String>,
|
||||
#[schemars(required, schema_with = "nullable_string_schema")]
|
||||
required_nullable_label: Option<String>,
|
||||
location: GeoPoint,
|
||||
}
|
||||
impl OpenApiSchema for CreateWidget {}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||
pub struct Lookup {
|
||||
detailed: bool,
|
||||
}
|
||||
impl OpenApiSchema for Lookup {}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||
pub struct Widget {
|
||||
id: u32,
|
||||
request: CreateWidget,
|
||||
}
|
||||
impl OpenApiSchema for Widget {}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||
#[serde(tag = "code", content = "details")]
|
||||
pub enum PublicError {
|
||||
Invalid { message: String },
|
||||
}
|
||||
impl OpenApiSchema for PublicError {}
|
||||
|
||||
type OptionalRequestId = Option<String>;
|
||||
|
||||
#[api(openapi)]
|
||||
pub trait FixtureApi {
|
||||
#[post(
|
||||
"/widgets/{widget_id}",
|
||||
operation_id = "widgets.create",
|
||||
status = 201,
|
||||
error_status = 422
|
||||
)]
|
||||
async fn create(
|
||||
&self,
|
||||
#[path] widget_id: u32,
|
||||
#[query] lookup: Lookup,
|
||||
#[header("authorization")] authorization: String,
|
||||
#[header("x-request-id")] request_id: OptionalRequestId,
|
||||
#[body] request: CreateWidget,
|
||||
) -> Result<Widget, PublicError>;
|
||||
|
||||
#[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)]
|
||||
async fn delete(&self, #[path] widget_id: u32) -> ();
|
||||
}
|
||||
|
||||
fn document() -> api_macros::openapi::OpenApiDocument {
|
||||
fixture_api_openapi(OpenApiInfo {
|
||||
title: "Fixture API",
|
||||
version: "1.2.3",
|
||||
source_digest: "git:0123456789abcdef",
|
||||
})
|
||||
.expect("valid OpenAPI projection")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_deterministic_and_accepted_by_an_independent_parser() {
|
||||
let first = document().to_json().expect("render document");
|
||||
let second = document().to_json().expect("render document again");
|
||||
assert_eq!(first.as_bytes(), second.as_bytes());
|
||||
|
||||
let parsed = oas3::from_json(&first).expect("oas3 independently parses generated document");
|
||||
assert_eq!(
|
||||
parsed
|
||||
.validate_version()
|
||||
.expect("valid OpenAPI version")
|
||||
.major,
|
||||
3
|
||||
);
|
||||
|
||||
let value: Value = serde_json::from_str(&first).expect("valid JSON");
|
||||
assert_eq!(value["openapi"], "3.1.0");
|
||||
assert_eq!(value["info"]["x-yoi-source-digest"], "git:0123456789abcdef");
|
||||
assert!(
|
||||
value.get("servers").is_none(),
|
||||
"deployment topology is omitted"
|
||||
);
|
||||
assert!(!first.contains("generatedAt"));
|
||||
assert!(!first.contains("#/$defs/"));
|
||||
|
||||
let schemas = value["components"]["schemas"]
|
||||
.as_object()
|
||||
.expect("component map");
|
||||
let mut references = Vec::new();
|
||||
collect_schema_references(&value, &mut references);
|
||||
assert!(!references.is_empty());
|
||||
for reference in references {
|
||||
let name = reference
|
||||
.strip_prefix("#/components/schemas/")
|
||||
.expect("only local component references are emitted");
|
||||
assert!(
|
||||
schemas.contains_key(name),
|
||||
"unresolved reference {reference}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_schema_references<'a>(value: &'a Value, references: &mut Vec<&'a str>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
|
||||
references.push(reference);
|
||||
}
|
||||
for value in object.values() {
|
||||
collect_schema_references(value, references);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
collect_schema_references(value, references);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operations_and_components_preserve_the_wire_contract() {
|
||||
let value = document().as_value().clone();
|
||||
let create = &value["paths"]["/widgets/{widget_id}"]["post"];
|
||||
assert_eq!(create["operationId"], "widgets.create");
|
||||
assert_eq!(create["security"], json!([{ "bearerAuth": [] }]));
|
||||
assert_eq!(
|
||||
create["responses"]["201"]["content"]["application/json"]["schema"],
|
||||
json!({
|
||||
"$ref": "#/components/schemas/Widget"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
create["responses"]["422"]["content"]["application/json"]["schema"],
|
||||
json!({
|
||||
"$ref": "#/components/schemas/PublicError"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
create["requestBody"]["content"]["application/json"]["schema"],
|
||||
json!({ "$ref": "#/components/schemas/CreateWidget" })
|
||||
);
|
||||
|
||||
let parameters = create["parameters"].as_array().expect("parameter list");
|
||||
assert_eq!(parameters.len(), 3);
|
||||
assert_eq!(
|
||||
(parameters[0]["name"].as_str(), parameters[0]["in"].as_str()),
|
||||
(Some("widget_id"), Some("path"))
|
||||
);
|
||||
assert_eq!(parameters[0]["required"], true);
|
||||
assert_eq!(
|
||||
(parameters[1]["name"].as_str(), parameters[1]["in"].as_str()),
|
||||
(Some("lookup"), Some("query"))
|
||||
);
|
||||
assert_eq!(parameters[1]["explode"], true);
|
||||
assert_eq!(
|
||||
(parameters[2]["name"].as_str(), parameters[2]["in"].as_str()),
|
||||
(Some("x-request-id"), Some("header"))
|
||||
);
|
||||
assert_eq!(parameters[2]["required"], false);
|
||||
|
||||
let schemas = value["components"]["schemas"]
|
||||
.as_object()
|
||||
.expect("components");
|
||||
assert_eq!(
|
||||
value["components"]["securitySchemes"]["bearerAuth"],
|
||||
json!({ "type": "http", "scheme": "bearer" })
|
||||
);
|
||||
for name in [
|
||||
"CreateWidget",
|
||||
"GeoPoint",
|
||||
"Lookup",
|
||||
"PublicError",
|
||||
"Widget",
|
||||
"WireSlug",
|
||||
"uint32",
|
||||
"Nullable_string",
|
||||
] {
|
||||
assert!(
|
||||
schemas.contains_key(name),
|
||||
"missing stable schema component {name}; found {:?}",
|
||||
schemas.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
schemas["CreateWidget"]["properties"]["location"]["$ref"],
|
||||
"#/components/schemas/GeoPoint"
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["CreateWidget"]["properties"]["slug"]["$ref"],
|
||||
"#/components/schemas/WireSlug"
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["CreateWidget"]["properties"]["name"]["minLength"],
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["CreateWidget"]["properties"]["name"]["maxLength"],
|
||||
64
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["CreateWidget"]["properties"]["count"]["maximum"],
|
||||
1_000
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["CreateWidget"]["properties"]["bounded_balance"]["minimum"],
|
||||
-100
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["CreateWidget"]["properties"]["bounded_balance"]["maximum"],
|
||||
100
|
||||
);
|
||||
assert_eq!(schemas["CreateWidget"]["properties"]["tags"]["minItems"], 1);
|
||||
assert_eq!(schemas["CreateWidget"]["properties"]["tags"]["maxItems"], 8);
|
||||
assert!(schemas["PublicError"]["oneOf"].is_array());
|
||||
|
||||
let required = schemas["CreateWidget"]["required"]
|
||||
.as_array()
|
||||
.expect("required fields");
|
||||
assert!(!required.contains(&json!("optional_label")));
|
||||
assert!(required.contains(&json!("required_nullable_label")));
|
||||
for field in ["optional_label", "required_nullable_label"] {
|
||||
assert!(
|
||||
schemas["CreateWidget"]["properties"][field]["type"]
|
||||
.as_array()
|
||||
.expect("nullable type union")
|
||||
.contains(&json!("null"))
|
||||
);
|
||||
}
|
||||
|
||||
let delete = &value["paths"]["/widgets/{widget_id}"]["delete"];
|
||||
assert_eq!(delete["operationId"], "widgets.delete");
|
||||
assert!(delete.get("security").is_none());
|
||||
assert_eq!(
|
||||
delete["responses"]["204"],
|
||||
json!({ "description": "Successful response" })
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, JsonSchema)]
|
||||
pub struct FirstCollision {
|
||||
first: String,
|
||||
}
|
||||
impl OpenApiSchema for FirstCollision {
|
||||
fn openapi_schema_name() -> std::borrow::Cow<'static, str> {
|
||||
"SharedWireName".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, JsonSchema)]
|
||||
pub struct SecondCollision {
|
||||
second: bool,
|
||||
}
|
||||
impl OpenApiSchema for SecondCollision {
|
||||
fn openapi_schema_name() -> std::borrow::Cow<'static, str> {
|
||||
"SharedWireName".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum AmbiguousWireEnum {
|
||||
Text(String),
|
||||
Flag(bool),
|
||||
}
|
||||
impl OpenApiSchema for AmbiguousWireEnum {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DanglingReference;
|
||||
|
||||
impl JsonSchema for DanglingReference {
|
||||
fn schema_name() -> std::borrow::Cow<'static, str> {
|
||||
"DanglingReference".into()
|
||||
}
|
||||
|
||||
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
schemars::json_schema!({ "$ref": "#/$defs/Missing" })
|
||||
}
|
||||
}
|
||||
impl OpenApiSchema for DanglingReference {}
|
||||
|
||||
#[test]
|
||||
fn schema_name_collisions_and_ambiguous_wire_shapes_fail_closed() {
|
||||
use api_macros::openapi::{OpenApiBuilder, OpenApiError};
|
||||
|
||||
let info = OpenApiInfo {
|
||||
title: "Rejected fixture",
|
||||
version: "1",
|
||||
source_digest: "git:bad",
|
||||
};
|
||||
let mut collision = OpenApiBuilder::new(info).expect("builder");
|
||||
{
|
||||
let mut operation = collision
|
||||
.operation("GET", "/first", "first")
|
||||
.expect("operation");
|
||||
operation
|
||||
.response::<FirstCollision>(200, "application/json", "ok")
|
||||
.expect("first schema");
|
||||
operation.finish().expect("first operation");
|
||||
}
|
||||
let error = collision
|
||||
.operation("GET", "/second", "second")
|
||||
.expect("operation")
|
||||
.response::<SecondCollision>(200, "application/json", "ok")
|
||||
.expect_err("different schemas must not share a public name");
|
||||
assert!(matches!(error, OpenApiError::InvalidContract(_)));
|
||||
assert!(error.to_string().contains("component name collision"));
|
||||
|
||||
let mut ambiguous = OpenApiBuilder::new(info).expect("builder");
|
||||
let error = ambiguous
|
||||
.operation("GET", "/ambiguous", "ambiguous")
|
||||
.expect("operation")
|
||||
.response::<AmbiguousWireEnum>(200, "application/json", "ok")
|
||||
.expect_err("untagged/ambiguous unions must fail closed");
|
||||
assert!(error.to_string().contains("untagged"));
|
||||
|
||||
let mut dangling = OpenApiBuilder::new(info).expect("builder");
|
||||
{
|
||||
let mut operation = dangling
|
||||
.operation("GET", "/dangling", "dangling")
|
||||
.expect("operation");
|
||||
operation
|
||||
.response::<DanglingReference>(200, "application/json", "ok")
|
||||
.expect("schema registration succeeds before graph validation");
|
||||
operation.finish().expect("operation");
|
||||
}
|
||||
let error = dangling
|
||||
.finish()
|
||||
.expect_err("dangling component references must fail closed");
|
||||
assert!(error.to_string().contains("unresolved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_export_writes_the_canonical_document() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"yoi-openapi-{}-{}.json",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("test")
|
||||
));
|
||||
let document = document();
|
||||
document.write_json(&path).expect("export OpenAPI file");
|
||||
let written = std::fs::read_to_string(&path).expect("read export");
|
||||
std::fs::remove_file(path).expect("remove export");
|
||||
assert_eq!(written, document.to_json().expect("canonical JSON"));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::{fs, path::PathBuf, process::Command};
|
||||
|
||||
#[test]
|
||||
fn macro_expansion_resolves_a_renamed_support_dependency() {
|
||||
let fixture = std::env::temp_dir().join(format!(
|
||||
"api-macros-renamed-dependency-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&fixture);
|
||||
fs::create_dir_all(fixture.join("src")).expect("create fixture source directory");
|
||||
|
||||
let package_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let package_path = package_path
|
||||
.to_str()
|
||||
.expect("package path is UTF-8")
|
||||
.replace('\\', "\\\\");
|
||||
fs::write(
|
||||
fixture.join("Cargo.toml"),
|
||||
format!(
|
||||
r#"[package]
|
||||
name = "renamed-api-consumer"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
yoi-api = {{ package = "api-macros", path = "{package_path}" }}
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.expect("write fixture manifest");
|
||||
fs::write(
|
||||
fixture.join("src/lib.rs"),
|
||||
r#"use yoi_api::{ApiContract, api};
|
||||
|
||||
pub struct Output;
|
||||
|
||||
#[api]
|
||||
pub trait RenamedApi {
|
||||
#[get("/output", operation_id = "renamed.output")]
|
||||
async fn output(&self) -> Output;
|
||||
}
|
||||
|
||||
pub fn operation_count() -> usize {
|
||||
<RenamedApiMetadata as ApiContract>::OPERATIONS.len()
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("write fixture source");
|
||||
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args(["check", "--quiet", "--offline"])
|
||||
.current_dir(&fixture)
|
||||
.env("CARGO_TARGET_DIR", fixture.join("target"))
|
||||
.output()
|
||||
.expect("run cargo check for renamed dependency fixture");
|
||||
let _ = fs::remove_dir_all(&fixture);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"renamed dependency fixture failed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait AnonymousBody {
|
||||
#[post("/widgets")]
|
||||
async fn create(&self, #[body] request: (u64, u64)) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: request bodies must use a named Rust type
|
||||
--> tests/ui/anonymous_body.rs:8:45
|
||||
|
|
||||
8 | async fn create(&self, #[body] request: (u64, u64)) -> Response;
|
||||
| ^^^^^^^^^^
|
||||
@@ -0,0 +1,14 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait DuplicateOperationId {
|
||||
#[get("/one", operation_id = "duplicate")]
|
||||
async fn one(&self) -> Response;
|
||||
|
||||
#[get("/two", operation_id = "duplicate")]
|
||||
async fn two(&self) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: duplicate operation ID `duplicate`
|
||||
--> tests/ui/duplicate_operation_id.rs:11:14
|
||||
|
|
||||
11 | async fn two(&self) -> Response;
|
||||
| ^^^
|
||||
@@ -0,0 +1,14 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait DuplicateRoute {
|
||||
#[get("/widgets", operation_id = "widgets.first")]
|
||||
async fn first(&self) -> Response;
|
||||
|
||||
#[get("/widgets", operation_id = "widgets.second")]
|
||||
async fn second(&self) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: duplicate route for GET `/widgets`
|
||||
--> tests/ui/duplicate_route.rs:11:14
|
||||
|
|
||||
11 | async fn second(&self) -> Response;
|
||||
| ^^^^^^
|
||||
@@ -0,0 +1,12 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct Response;
|
||||
pub struct PublicError;
|
||||
|
||||
#[api]
|
||||
pub trait InvalidErrorStatus {
|
||||
#[get("/widgets", error_status = 200)]
|
||||
async fn list(&self) -> Result<Response, PublicError>;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: error_status must be between 400 and 599
|
||||
--> tests/ui/invalid_error_status.rs:8:38
|
||||
|
|
||||
8 | #[get("/widgets", error_status = 200)]
|
||||
| ^^^
|
||||
@@ -0,0 +1,12 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct Request;
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait InvalidMethodBody {
|
||||
#[get("/widgets")]
|
||||
async fn list(&self, #[body] request: Request) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: GET operations cannot declare a request body
|
||||
--> tests/ui/invalid_method_body.rs:9:14
|
||||
|
|
||||
9 | async fn list(&self, #[body] request: Request) -> Response;
|
||||
| ^^^^
|
||||
@@ -0,0 +1,11 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait InvalidSuccessStatus {
|
||||
#[get("/widgets", status = 404)]
|
||||
async fn list(&self) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: status must be a success status between 200 and 299
|
||||
--> tests/ui/invalid_success_status.rs:7:32
|
||||
|
|
||||
7 | #[get("/widgets", status = 404)]
|
||||
| ^^^
|
||||
@@ -0,0 +1,14 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait MarkerCollision {
|
||||
#[get("/first")]
|
||||
async fn foo_bar(&self) -> Response;
|
||||
|
||||
#[get("/second")]
|
||||
async fn foo__bar(&self) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,11 @@
|
||||
error: generated operation marker `FooBar` collides with method `foo_bar`
|
||||
--> tests/ui/marker_collision.rs:11:14
|
||||
|
|
||||
11 | async fn foo__bar(&self) -> Response;
|
||||
| ^^^^^^^^
|
||||
|
||||
error: first method generating marker `FooBar`
|
||||
--> tests/ui/marker_collision.rs:8:14
|
||||
|
|
||||
8 | async fn foo_bar(&self) -> Response;
|
||||
| ^^^^^^^
|
||||
@@ -0,0 +1,17 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct FirstBody;
|
||||
pub struct SecondBody;
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait MultipleBodies {
|
||||
#[post("/widgets")]
|
||||
async fn create(
|
||||
&self,
|
||||
#[body] first: FirstBody,
|
||||
#[body] second: SecondBody,
|
||||
) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: an API operation may declare only one request body
|
||||
--> tests/ui/multiple_bodies.rs:13:17
|
||||
|
|
||||
13 | #[body] second: SecondBody,
|
||||
| ^^^^^^
|
||||
@@ -0,0 +1,11 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait PathMismatch {
|
||||
#[get("/widgets/{widget_id}")]
|
||||
async fn get(&self, #[path] other_id: u64) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: path placeholder(s) have no matching path parameter: widget_id
|
||||
--> tests/ui/path_mismatch.rs:7:11
|
||||
|
|
||||
7 | #[get("/widgets/{widget_id}")]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -0,0 +1,14 @@
|
||||
#![allow(async_fn_in_trait, private_interfaces)]
|
||||
|
||||
use api_macros::api;
|
||||
|
||||
struct PrivateRequest;
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait PrivateBody {
|
||||
#[post("/widgets")]
|
||||
async fn create(&self, #[body] request: PrivateRequest) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,10 @@
|
||||
error[E0446]: private type `PrivateRequest` in public interface
|
||||
--> tests/ui/private_body.rs:8:1
|
||||
|
|
||||
5 | struct PrivateRequest;
|
||||
| --------------------- `PrivateRequest` declared as private
|
||||
...
|
||||
8 | #[api]
|
||||
| ^^^^^^ can't leak private type
|
||||
|
|
||||
= note: this error originates in the attribute macro `api` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
@@ -0,0 +1,12 @@
|
||||
use api_macros::api;
|
||||
|
||||
pub struct StreamRequest;
|
||||
pub struct Response;
|
||||
|
||||
#[api]
|
||||
pub trait UnsupportedWireKind {
|
||||
#[post("/widgets")]
|
||||
async fn create(&self, #[stream] request: StreamRequest) -> Response;
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: this wire kind is explicitly unsupported by the initial API contract
|
||||
--> tests/ui/unsupported_wire_kind.rs:9:28
|
||||
|
|
||||
9 | async fn create(&self, #[stream] request: StreamRequest) -> Response;
|
||||
| ^^^^^^^^^
|
||||
@@ -8,16 +8,17 @@ license.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
protocol = { workspace = true }
|
||||
runtime-api.workspace = true
|
||||
ticket = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
server-api.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
workspace-api.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -192,6 +192,10 @@ impl BackendApiClient {
|
||||
format!("Bearer {}", self.access_token.0)
|
||||
}
|
||||
|
||||
pub(crate) fn asynchronous_client(&self) -> reqwest::Client {
|
||||
self.asynchronous.clone()
|
||||
}
|
||||
|
||||
pub async fn require_success(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
|
||||
@@ -3,8 +3,8 @@ use serde::Deserialize;
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
|
||||
use workspace_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest};
|
||||
pub use workspace_api::{DeviceLoginPollResponse, DeviceLoginStartResponse};
|
||||
use server_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest};
|
||||
pub use server_api::{DeviceLoginPollResponse, DeviceLoginStartResponse};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendAuthTarget {
|
||||
@@ -153,7 +153,7 @@ async fn parse_json_response<T: for<'de> Deserialize<'de>>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use workspace_api::DeviceAccessTokenType;
|
||||
use server_api::DeviceAccessTokenType;
|
||||
|
||||
fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse {
|
||||
DeviceLoginPollResponse {
|
||||
|
||||
@@ -2,11 +2,7 @@ use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketE
|
||||
use crate::{BackendApiClient, BackendApiClientError, Client};
|
||||
use reqwest::Method as HttpMethod;
|
||||
use serde::Deserialize;
|
||||
use std::fmt;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||
pub use workspace_api::{
|
||||
pub use server_api::{
|
||||
BrowserCreateWorkerResponse as BackendCreateWorkerResponse,
|
||||
CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic,
|
||||
DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse,
|
||||
@@ -18,7 +14,8 @@ pub use workspace_api::{
|
||||
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
|
||||
WorkerOperationState as BackendWorkerOperationState,
|
||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||
WorkerRestoreResult as BackendWorkerRestoreResult,
|
||||
WorkerRestoreState as BackendWorkerRestoreState, WorkerSummary as BackendWorkerSummary,
|
||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
|
||||
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
|
||||
@@ -26,8 +23,12 @@ pub use workspace_api::{
|
||||
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
|
||||
WorkingDirectorySummary as BackendWorkingDirectorySummary,
|
||||
};
|
||||
use std::fmt;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BackendRuntimeTarget {
|
||||
/// Workspace Backend API root URL, for example `http://127.0.0.1:8787`.
|
||||
/// This is intentionally the Backend endpoint, not a Runtime endpoint.
|
||||
@@ -38,6 +39,8 @@ pub struct BackendRuntimeTarget {
|
||||
pub runtime_id: String,
|
||||
/// Backend-owned Worker identity used as path authority.
|
||||
pub worker_id: String,
|
||||
pub initial_snapshot: Option<protocol::SessionSnapshot>,
|
||||
pub initial_notice: Option<String>,
|
||||
}
|
||||
|
||||
impl BackendRuntimeTarget {
|
||||
@@ -52,6 +55,8 @@ impl BackendRuntimeTarget {
|
||||
workspace_id: workspace_id.into(),
|
||||
runtime_id: runtime_id.into(),
|
||||
worker_id: worker_id.into(),
|
||||
initial_snapshot: None,
|
||||
initial_notice: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +278,7 @@ impl BackendRuntimeListTarget {
|
||||
pub enum BackendRuntimeClientError {
|
||||
InvalidTarget(String),
|
||||
Api(BackendApiClientError),
|
||||
SessionApi(server_api::ServerApiClientError),
|
||||
Http(reqwest::Error),
|
||||
Protocol(String),
|
||||
}
|
||||
@@ -282,6 +288,7 @@ impl fmt::Display for BackendRuntimeClientError {
|
||||
match self {
|
||||
Self::InvalidTarget(message) => f.write_str(message),
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::SessionApi(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
Self::Protocol(message) => f.write_str(message),
|
||||
}
|
||||
@@ -290,6 +297,12 @@ impl fmt::Display for BackendRuntimeClientError {
|
||||
|
||||
impl std::error::Error for BackendRuntimeClientError {}
|
||||
|
||||
impl From<server_api::ServerApiClientError> for BackendRuntimeClientError {
|
||||
fn from(value: server_api::ServerApiClientError) -> Self {
|
||||
Self::SessionApi(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BackendApiClientError> for BackendRuntimeClientError {
|
||||
fn from(error: BackendApiClientError) -> Self {
|
||||
Self::Api(error)
|
||||
@@ -302,6 +315,59 @@ impl From<reqwest::Error> for BackendRuntimeClientError {
|
||||
}
|
||||
}
|
||||
|
||||
const WORKER_SESSION_RESPONSE_LIMIT: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StoredBearerAuthorizer {
|
||||
authorization: String,
|
||||
}
|
||||
|
||||
impl server_api::client_support::RequestAuthorizer for StoredBearerAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
_request: server_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> Result<reqwest::header::HeaderMap, server_api::client_support::AuthorizationError> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
self.authorization
|
||||
.parse()
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn observe_backend_worker_session(
|
||||
target: &BackendRuntimeTarget,
|
||||
) -> Result<server_api::WorkspaceWorkerSessionResponse, BackendRuntimeClientError> {
|
||||
validate_target(target)?;
|
||||
let backend = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
observe_backend_worker_session_with_client(target, &backend).await
|
||||
}
|
||||
|
||||
async fn observe_backend_worker_session_with_client(
|
||||
target: &BackendRuntimeTarget,
|
||||
backend: &BackendApiClient,
|
||||
) -> Result<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
|
||||
.worker_session(
|
||||
target.workspace_id.clone(),
|
||||
target.runtime_id.clone(),
|
||||
target.worker_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_backend_worker_launch_options(
|
||||
target: &BackendWorkerLaunchTarget,
|
||||
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
|
||||
@@ -760,6 +826,43 @@ mod tests {
|
||||
(base_url, task)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_session_request_uses_bearer_and_sixteen_mib_response_limit() {
|
||||
let large_message = "x".repeat(2 * 1024 * 1024);
|
||||
let (base_url, server) = serve_json_once(serde_json::json!({
|
||||
"subject": {
|
||||
"kind": "runtime_worker",
|
||||
"runtime_id": "runtime-a",
|
||||
"worker_id": "worker-a"
|
||||
},
|
||||
"availability": "unavailable",
|
||||
"reason": "storage_unavailable",
|
||||
"message": large_message
|
||||
}))
|
||||
.await;
|
||||
let target = BackendRuntimeTarget::new(&base_url, "workspace-a", "runtime-a", "worker-a");
|
||||
let api =
|
||||
BackendApiClient::from_access_token_for_test(&base_url, "session-secret").unwrap();
|
||||
|
||||
let response = observe_backend_worker_session_with_client(&target, &api)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
response.observation,
|
||||
runtime_api::WorkerSessionAvailability::Unavailable { ref message, .. }
|
||||
if message.len() == 2 * 1024 * 1024
|
||||
));
|
||||
let request = server.await.unwrap();
|
||||
assert!(request.starts_with(
|
||||
"GET /api/w/workspace-a/runtimes/runtime-a/workers/worker-a/session HTTP/1.1\r\n"
|
||||
));
|
||||
assert!(
|
||||
request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer session-secret\r\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_options_request_uses_workspace_path_and_bearer_auth() {
|
||||
let (base_url, server) = serve_json_once(serde_json::json!({
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use reqwest::Method;
|
||||
use std::fmt;
|
||||
use workspace_api::{
|
||||
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
||||
WorkspaceSummary,
|
||||
use server_api::{
|
||||
InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse,
|
||||
WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary,
|
||||
};
|
||||
use std::fmt;
|
||||
|
||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||
|
||||
@@ -32,6 +31,7 @@ pub enum BackendWorkspaceClientError {
|
||||
InvalidTarget(String),
|
||||
Api(BackendApiClientError),
|
||||
Http(reqwest::Error),
|
||||
RepositoryApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
||||
}
|
||||
|
||||
impl fmt::Display for BackendWorkspaceClientError {
|
||||
@@ -40,6 +40,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
||||
Self::InvalidTarget(message) => f.write_str(message),
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
Self::RepositoryApi(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +59,62 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<server_api::client_support::ClientError<server_api::RepositoryApiError>>
|
||||
for BackendWorkspaceClientError
|
||||
{
|
||||
fn from(
|
||||
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
|
||||
) -> Self {
|
||||
Self::RepositoryApi(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn repository_client_error(
|
||||
backend: &BackendApiClient,
|
||||
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
|
||||
) -> BackendWorkspaceClientError {
|
||||
let status = match &error {
|
||||
server_api::client_support::ClientError::Public { status, .. } => Some(*status),
|
||||
server_api::client_support::ClientError::Failure(_) => None,
|
||||
};
|
||||
match status {
|
||||
Some(reqwest::StatusCode::UNAUTHORIZED) => {
|
||||
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized {
|
||||
origin: backend.origin().clone(),
|
||||
})
|
||||
}
|
||||
Some(reqwest::StatusCode::FORBIDDEN) => {
|
||||
BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden {
|
||||
origin: backend.origin().clone(),
|
||||
})
|
||||
}
|
||||
_ => BackendWorkspaceClientError::RepositoryApi(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RepositoryBearerAuthorizer {
|
||||
authorization: String,
|
||||
}
|
||||
|
||||
impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
_request: server_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> Result<reqwest::header::HeaderMap, server_api::client_support::AuthorizationError> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
self.authorization
|
||||
.parse()
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
const REPOSITORY_RESPONSE_LIMIT: usize = 16 * 1024 * 1024;
|
||||
|
||||
pub fn list_backend_workspaces_blocking(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
@@ -75,6 +132,21 @@ pub fn list_backend_workspaces_blocking(
|
||||
pub fn list_backend_workspace_repositories_blocking(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
|
||||
runtime.block_on(list_backend_workspace_repositories_with_client(
|
||||
&client,
|
||||
workspace_id,
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_backend_workspace_repositories_with_client(
|
||||
backend: &BackendApiClient,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
|
||||
if workspace_id.is_empty()
|
||||
|| workspace_id.len() > 200
|
||||
@@ -86,12 +158,19 @@ pub fn list_backend_workspace_repositories_blocking(
|
||||
"Workspace id returned by Backend is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let client = server_api::ServerApiClient::builder(backend.origin().as_str())
|
||||
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?
|
||||
.client(backend.asynchronous_client())
|
||||
.authorizer(RepositoryBearerAuthorizer {
|
||||
authorization: backend.authorization_header_value(),
|
||||
})
|
||||
.response_body_limit(REPOSITORY_RESPONSE_LIMIT)
|
||||
.build()
|
||||
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
|
||||
let response = client
|
||||
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
|
||||
.send()?;
|
||||
client.check_status(response.status())?;
|
||||
let response = response.json::<RepositoryListResponse>()?;
|
||||
.repository_list(workspace_id.to_owned())
|
||||
.await
|
||||
.map_err(|error| repository_client_error(backend, error))?;
|
||||
if response.workspace_id != workspace_id {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Repository catalog response does not match the requested Workspace".to_string(),
|
||||
@@ -170,6 +249,124 @@ mod tests {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_catalog_uses_generated_path_and_bearer_authorizer() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /api/w/workspace-test/repositories "));
|
||||
assert!(request.contains("authorization: bearer repository-secret\r\n"));
|
||||
let body = r#"{"workspace_id":"workspace-test","items":[],"source":"workspace","diagnostics":[]}"#;
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap();
|
||||
|
||||
assert!(
|
||||
list_backend_workspace_repositories_with_client(&client, "workspace-test")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_catalog_enforces_generated_response_limit() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let _ = stream.read(&mut request).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
REPOSITORY_RESPONSE_LIMIT + 1,
|
||||
)
|
||||
.unwrap();
|
||||
stream.flush().unwrap();
|
||||
let chunk = [b' '; 8192];
|
||||
let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1;
|
||||
while remaining > 0 {
|
||||
let length = remaining.min(chunk.len());
|
||||
if stream.write_all(&chunk[..length]).is_err() {
|
||||
break;
|
||||
}
|
||||
remaining -= length;
|
||||
}
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap();
|
||||
|
||||
let error = list_backend_workspace_repositories_with_client(&client, "workspace-test")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
&error,
|
||||
BackendWorkspaceClientError::RepositoryApi(
|
||||
server_api::client_support::ClientError::Failure(
|
||||
server_api::client_support::ClientFailure::ResponseTooLarge {
|
||||
limit: REPOSITORY_RESPONSE_LIMIT
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
"unexpected error: {error:?}"
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_client_preserves_authentication_error_taxonomy() {
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test("https://backend.example.test", "token")
|
||||
.unwrap();
|
||||
let unauthorized = repository_client_error(
|
||||
&client,
|
||||
server_api::client_support::ClientError::public(
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
server_api::RepositoryApiError::new(
|
||||
401,
|
||||
"Unauthorized",
|
||||
"authentication required",
|
||||
Vec::new(),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert!(matches!(
|
||||
unauthorized,
|
||||
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. })
|
||||
));
|
||||
let forbidden = repository_client_error(
|
||||
&client,
|
||||
server_api::client_support::ClientError::public(
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
server_api::RepositoryApiError::new(
|
||||
403,
|
||||
"Forbidden",
|
||||
"repository access denied",
|
||||
Vec::new(),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert!(matches!(
|
||||
forbidden,
|
||||
BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||
let request = CreateBackendWorkspaceRequest {
|
||||
|
||||
@@ -27,10 +27,10 @@ pub use backend_runtime::{
|
||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||
BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
|
||||
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
|
||||
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
|
||||
restore_backend_worker,
|
||||
BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary,
|
||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
|
||||
create_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers,
|
||||
list_backend_workers, observe_backend_worker_session, restore_backend_worker,
|
||||
};
|
||||
pub use backend_workspace::{
|
||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||
@@ -40,15 +40,19 @@ pub use backend_workspace::{
|
||||
list_backend_workspaces_blocking,
|
||||
};
|
||||
pub use client::{Client, ClientError};
|
||||
pub use target::{
|
||||
BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
|
||||
StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
|
||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||
pub use runtime_api::{
|
||||
RetainedSessionIdentity, WorkerSessionAvailability, WorkerSessionUnavailableReason,
|
||||
};
|
||||
pub use workspace_api::{
|
||||
pub use server_api::{
|
||||
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
|
||||
CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse,
|
||||
CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole,
|
||||
CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary,
|
||||
};
|
||||
pub use server_api::{ServerApi, ServerApiClient, WorkspaceWorkerSessionResponse};
|
||||
pub use target::{
|
||||
BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
|
||||
StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
|
||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||
};
|
||||
pub use workspace_product::BackendWorkspaceProductClient;
|
||||
|
||||
@@ -134,7 +134,7 @@ pub struct WorkerList {
|
||||
pub include_stopped: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WorkerConnection {
|
||||
pub target: BackendRuntimeTarget,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use ticket::{
|
||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketItemEdit,
|
||||
TicketListQuery, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
|
||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
||||
};
|
||||
use workspace_api::{
|
||||
use server_api::{
|
||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
@@ -17,6 +10,13 @@ use workspace_api::{
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
};
|
||||
use ticket::{
|
||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketItemEdit,
|
||||
TicketListQuery, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
|
||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
||||
};
|
||||
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
|
||||
@@ -758,7 +758,7 @@ mod tests {
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.kind,
|
||||
workspace_api::MemoryEvidenceOriginKind::WorkerInput
|
||||
server_api::MemoryEvidenceOriginKind::WorkerInput
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
|
||||
@@ -16,14 +16,6 @@ pub fn new_submission_request_id() -> String {
|
||||
uuid::Uuid::now_v7().to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_true(value: &bool) -> bool {
|
||||
*value
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
@@ -231,25 +223,20 @@ pub enum Method {
|
||||
#[serde(skip_deserializing, default)]
|
||||
source: AuthenticatedInputSource,
|
||||
},
|
||||
/// Human-readable text injected into the target Worker's LLM context
|
||||
/// as a non-blocking system message. `auto_run` controls whether an
|
||||
/// idle target is kicked into `RunForNotification`; weak notifications
|
||||
/// (`auto_run: false`) are only queued for the next turn/resume/run.
|
||||
/// No side effects beyond LLM context; use `WorkerEvent` for typed
|
||||
/// lifecycle reports.
|
||||
/// Human-readable text injected into the target Worker's LLM context as a
|
||||
/// non-blocking system message. Accepted notifications are delivered at the
|
||||
/// next between-turn boundary, or start a coalesced notification run while
|
||||
/// the Worker is idle. No side effects beyond LLM context; use
|
||||
/// `WorkerEvent` for typed lifecycle reports.
|
||||
Notify {
|
||||
notification_request_id: String,
|
||||
message: String,
|
||||
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
||||
auto_run: bool,
|
||||
},
|
||||
/// Authenticated transport form of Notify.
|
||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
||||
NotifyTracked {
|
||||
notification_request_id: String,
|
||||
message: String,
|
||||
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
||||
auto_run: bool,
|
||||
#[serde(skip_deserializing, default)]
|
||||
source: AuthenticatedInputSource,
|
||||
},
|
||||
@@ -2040,26 +2027,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_notify_json_roundtrip_defaults_to_auto_run() {
|
||||
fn method_notify_json_roundtrip_has_no_delivery_mode() {
|
||||
let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"turn done"}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
method,
|
||||
Method::Notify { ref message, auto_run: true, .. } if message == "turn done"
|
||||
Method::Notify { ref message, .. } if message == "turn done"
|
||||
));
|
||||
let serialized = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(serialized, json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_notify_weak_json_roundtrip_serializes_auto_run_false() {
|
||||
let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"progress","auto_run":false}}"#;
|
||||
let method: Method = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
method,
|
||||
Method::Notify { ref message, auto_run: false, .. } if message == "progress"
|
||||
));
|
||||
assert_eq!(serde_json::to_string(&method).unwrap(), json);
|
||||
assert!(!serialized.contains("auto_run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -559,6 +559,19 @@ pub enum SubscriptionWorkerState {
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// Whether the Workspace Server has a current Runtime observation for a Worker.
|
||||
///
|
||||
/// `Unavailable` deliberately does not imply `Stopped`: it means the last observed
|
||||
/// lifecycle state is retained while the Runtime observation source is unavailable.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubscriptionWorkerAvailability {
|
||||
#[default]
|
||||
Observed,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct SubscriptionWorker {
|
||||
@@ -571,6 +584,10 @@ pub struct SubscriptionWorker {
|
||||
/// Workspace-facing projections must populate it before publishing the Worker.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resource_key: Option<String>,
|
||||
/// Freshness of the Runtime-backed observation carried by this projection.
|
||||
/// `Unavailable` preserves catalog membership without claiming that execution stopped.
|
||||
#[serde(default)]
|
||||
pub availability: SubscriptionWorkerAvailability,
|
||||
/// Producer-owned monotonic revision for this Worker subject.
|
||||
pub subject_revision: u64,
|
||||
/// Latest revisioned foreground state observed from the Worker. This remains
|
||||
@@ -878,6 +895,7 @@ mod tests {
|
||||
worker_id: worker_id(value),
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
availability: SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: 0,
|
||||
worker_state: None,
|
||||
state: SubscriptionWorkerState::Idle,
|
||||
|
||||
@@ -21,8 +21,8 @@ use crate::{
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot,
|
||||
SubscriptionTerminationCode, SubscriptionWorkdirId, SubscriptionWorker,
|
||||
SubscriptionWorkerId, SubscriptionWorkerIds, SubscriptionWorkerProtocolMethod,
|
||||
SubscriptionWorkerState, WorkspaceSubscriptionWorkdir,
|
||||
SubscriptionWorkerAvailability, SubscriptionWorkerId, SubscriptionWorkerIds,
|
||||
SubscriptionWorkerProtocolMethod, SubscriptionWorkerState, WorkspaceSubscriptionWorkdir,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -112,6 +112,7 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<SubscriptionWorkdirId>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkerIds>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkerState>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkerAvailability>(&cfg, &mut output);
|
||||
push_decl::<EventSubscriptionSelector>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorker>(&cfg, &mut output);
|
||||
push_decl::<WorkspaceSubscriptionWorkdir>(&cfg, &mut output);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "runtime-api"
|
||||
description = "Typed JSON REST contract for Yoi Runtime Worker management"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["reqwest", "axum"] }
|
||||
manifest.workspace = true
|
||||
protocol.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "workspace-api"
|
||||
name = "server-api"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -10,17 +10,23 @@ default = []
|
||||
typescript = ["dep:ts-rs", "protocol/typescript"]
|
||||
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
|
||||
protocol.workspace = true
|
||||
runtime-api.workspace = true
|
||||
schemars.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
|
||||
[[example]]
|
||||
name = "generate_typescript"
|
||||
required-features = ["typescript"]
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[[example]]
|
||||
name = "generate_legacy_typescript"
|
||||
required-features = ["typescript"]
|
||||
|
||||
[[example]]
|
||||
name = "generate_workdir_api_types"
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::{env, fs, io, path::PathBuf};
|
||||
|
||||
const DEFAULT_OUTPUT: &str = "openapi/server-api.json";
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut arguments = env::args_os().skip(1);
|
||||
let first = arguments.next();
|
||||
let (mode, output) = match first.as_deref().and_then(|value| value.to_str()) {
|
||||
Some("--check") => ("check", arguments.next().map(PathBuf::from)),
|
||||
Some("--stdout") => ("stdout", None),
|
||||
Some(_) => ("write", first.map(PathBuf::from)),
|
||||
None => ("write", None),
|
||||
};
|
||||
if arguments.next().is_some() {
|
||||
return Err("usage: export_openapi [--check [PATH] | --stdout | PATH]".into());
|
||||
}
|
||||
|
||||
let output = output.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT));
|
||||
let json = server_api::canonical_openapi_document()?.to_json()?;
|
||||
match mode {
|
||||
"stdout" => print!("{json}"),
|
||||
"check" => {
|
||||
let checked_in = fs::read_to_string(&output)?;
|
||||
if checked_in != json {
|
||||
return Err(format!(
|
||||
"{} is stale; regenerate with `cargo run -p server-api --example export_openapi -- {}`",
|
||||
output.display(),
|
||||
output.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
println!("{} is current", output.display());
|
||||
}
|
||||
"write" => {
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut temporary = output.clone();
|
||||
let extension = output
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map_or_else(|| "tmp".to_owned(), |value| format!("{value}.tmp"));
|
||||
temporary.set_extension(extension);
|
||||
fs::write(&temporary, json)?;
|
||||
fs::rename(&temporary, &output).or_else(|error| {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
Err::<(), io::Error>(error)
|
||||
})?;
|
||||
println!("wrote {}", output.display());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::auth_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::companion_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::legacy_catalog_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::memory_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::repository_access_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use server_api::repository_openapi_typescript::{
|
||||
INPUT_PATH, OUTPUT_PATH, generate_repository_typescript,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let mut check = false;
|
||||
for argument in std::env::args().skip(1) {
|
||||
match argument.as_str() {
|
||||
"--check" => check = true,
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Generate the bounded Repository API TypeScript artifact.\n\n\
|
||||
Usage: cargo run -q -p server-api --example generate_repository_openapi_types -- [--check]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown argument `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let input_path = root.join(INPUT_PATH);
|
||||
let output_path = root.join(OUTPUT_PATH);
|
||||
let input = fs::read_to_string(&input_path)
|
||||
.map_err(|error| format!("failed to read {}: {error}", input_path.display()))?;
|
||||
let generated = generate_repository_typescript(&input)
|
||||
.map_err(|error| format!("failed to generate Repository API types: {error}"))?;
|
||||
|
||||
if check {
|
||||
let checked_in = fs::read_to_string(&output_path)
|
||||
.map_err(|error| format!("failed to read {}: {error}", output_path.display()))?;
|
||||
if checked_in != generated {
|
||||
return Err(format!(
|
||||
"{} is stale; regenerate it without --check",
|
||||
output_path.display()
|
||||
));
|
||||
}
|
||||
println!("{} is current", output_path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let temporary_path = output_path.with_extension("ts.tmp");
|
||||
fs::write(&temporary_path, generated)
|
||||
.map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?;
|
||||
fs::rename(&temporary_path, &output_path)
|
||||
.map_err(|error| format!("failed to replace {}: {error}", output_path.display()))?;
|
||||
println!("generated {}", output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::skill_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::workdir_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::worker_launch_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,208 @@
|
||||
//!
|
||||
//! This crate owns transport DTOs exposed by the Workspace Server and consumed
|
||||
//! by Rust clients. Runtime-internal projections remain in their owning crates;
|
||||
//! callers must explicitly construct these Workspace-authoritative resources.
|
||||
//! callers must explicitly construct these Server-authoritative resources.
|
||||
|
||||
use api_macros::api;
|
||||
pub use api_macros::axum as server_support;
|
||||
pub use api_macros::reqwest as client_support;
|
||||
pub use api_macros::{ApiContract, HttpMethod};
|
||||
pub mod repository_openapi_typescript;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use webauthn_rs_proto::{
|
||||
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
|
||||
RequestChallengeResponse,
|
||||
};
|
||||
|
||||
pub type ServerApiError = runtime_api::RuntimeApiError;
|
||||
pub type ServerApiClientError = client_support::ClientError<ServerApiError>;
|
||||
|
||||
/// Error body shared by the existing repository routes and their generated adapters.
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
|
||||
pub struct RepositoryApiError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
#[serde(skip, default = "default_repository_error_status")]
|
||||
status: u16,
|
||||
}
|
||||
|
||||
const fn default_repository_error_status() -> u16 {
|
||||
400
|
||||
}
|
||||
|
||||
impl RepositoryApiError {
|
||||
pub fn new(
|
||||
status: u16,
|
||||
error: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
) -> Self {
|
||||
Self {
|
||||
error: error.into(),
|
||||
message: message.into(),
|
||||
diagnostics,
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl api_macros::HttpError for RepositoryApiError {
|
||||
fn status_code(&self) -> u16 {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
|
||||
impl api_macros::HttpRequestError for RepositoryApiError {
|
||||
fn from_request_rejection(status: u16, message: String) -> Self {
|
||||
let reason = match status {
|
||||
413 => "Payload Too Large",
|
||||
415 => "Unsupported Media Type",
|
||||
422 => "Unprocessable Entity",
|
||||
_ => "Bad Request",
|
||||
};
|
||||
Self::new(status, reason, message, Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_openapi_schema {
|
||||
($($ty:ty),+ $(,)?) => {
|
||||
$(impl api_macros::openapi::OpenApiSchema for $ty {})+
|
||||
};
|
||||
}
|
||||
|
||||
impl_openapi_schema!(
|
||||
CreateWorkspaceRepositoryRequest,
|
||||
CreateWorkspaceRepositoryResponse,
|
||||
RepositoryApiError,
|
||||
RepositoryListResponse,
|
||||
RepositoryDetailResponse,
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceWorkerSessionResponse {
|
||||
pub subject: WorkspaceWorkerSubject,
|
||||
#[serde(flatten)]
|
||||
pub observation: runtime_api::WorkerSessionAvailability,
|
||||
}
|
||||
|
||||
#[api(reqwest, axum, openapi)]
|
||||
pub trait ServerApi {
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
openapi = false
|
||||
)]
|
||||
async fn worker_session(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/repositories",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn repository_list(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<RepositoryListResponse, RepositoryApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/repositories",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn repository_list_alias(&self) -> Result<RepositoryListResponse, RepositoryApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}",
|
||||
status = 200,
|
||||
error_status = 404,
|
||||
additional_error_statuses = [400, 401, 403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn repository_detail(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] repository_key: String,
|
||||
) -> Result<RepositoryDetailResponse, RepositoryApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/repositories/{repository_key}",
|
||||
status = 200,
|
||||
error_status = 404,
|
||||
additional_error_statuses = [400, 401, 403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn repository_detail_alias(
|
||||
&self,
|
||||
#[path] repository_key: String,
|
||||
) -> Result<RepositoryDetailResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/w/{workspace_id}/repositories",
|
||||
status = 201,
|
||||
alternate_status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 409, 413, 415, 422, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true,
|
||||
normalize_body_errors = true
|
||||
)]
|
||||
async fn repository_create(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: CreateWorkspaceRepositoryRequest,
|
||||
) -> Result<CreateWorkspaceRepositoryResponse, RepositoryApiError>;
|
||||
}
|
||||
|
||||
/// Digest of the fully rendered canonical contract with its digest slot normalized.
|
||||
pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::OpenApiError> {
|
||||
const NORMALIZED_DIGEST: &str =
|
||||
"sha256:0000000000000000000000000000000000000000000000000000000000000000";
|
||||
let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo {
|
||||
title: "Yoi Server API",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
source_digest: NORMALIZED_DIGEST,
|
||||
})?
|
||||
.to_json()?;
|
||||
let digest = Sha256::digest(normalized.as_bytes());
|
||||
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
|
||||
encoded.push_str("sha256:");
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
||||
}
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
/// Build the deployment-independent canonical ServerApi OpenAPI document.
|
||||
pub fn canonical_openapi_document()
|
||||
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
|
||||
let source_digest = canonical_openapi_source_digest()?;
|
||||
server_api_openapi(api_macros::openapi::OpenApiInfo {
|
||||
title: "Yoi Server API",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
source_digest: &source_digest,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public browser-authentication configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -365,7 +559,7 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
|
||||
///
|
||||
/// Local paths remain distinct from network Git transports so callers cannot
|
||||
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositorySourceKind {
|
||||
@@ -422,7 +616,7 @@ impl<'de> Deserialize<'de> for RepositorySourceKind {
|
||||
}
|
||||
|
||||
/// Stable Repository source identity stored by Workspace authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct RepositorySource {
|
||||
pub kind: RepositorySourceKind,
|
||||
@@ -435,7 +629,7 @@ pub struct RepositorySource {
|
||||
///
|
||||
/// The Server parses and canonicalizes `source`; callers cannot assert a
|
||||
/// transport classification or supply credential material through this DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateWorkspaceRepositoryRequest {
|
||||
pub repository_key: String,
|
||||
@@ -444,14 +638,20 @@ pub struct CreateWorkspaceRepositoryRequest {
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
pub struct CreateWorkspaceRepositoryResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_key: String,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
impl api_macros::HttpSuccess for CreateWorkspaceRepositoryResponse {
|
||||
fn status_code(&self) -> u16 {
|
||||
if self.replayed { 200 } else { 201 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositoryObservedStatus {
|
||||
@@ -1096,7 +1296,7 @@ pub enum WorkspaceProfileSourceProvenance {
|
||||
ProjectProfileSourceTree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryDiagnostic {
|
||||
@@ -1105,7 +1305,7 @@ pub struct RepositoryDiagnostic {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GitRemoteSummary {
|
||||
@@ -1113,7 +1313,7 @@ pub struct GitRemoteSummary {
|
||||
pub fetch_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GitRepositorySummary {
|
||||
@@ -1124,7 +1324,7 @@ pub struct GitRepositorySummary {
|
||||
pub remotes: Vec<GitRemoteSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySummary {
|
||||
@@ -1133,6 +1333,7 @@ pub struct RepositorySummary {
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
@@ -1165,7 +1366,7 @@ pub struct GitCommitSummary {
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryListResponse {
|
||||
@@ -1175,7 +1376,7 @@ pub struct RepositoryListResponse {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryDetailResponse {
|
||||
@@ -1253,7 +1454,7 @@ pub struct RepositoryLogResponse {
|
||||
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
|
||||
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiagnosticSeverity {
|
||||
@@ -1262,7 +1463,7 @@ pub enum DiagnosticSeverity {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Diagnostic {
|
||||
@@ -1451,6 +1652,106 @@ impl WorkingDirectorySummary {
|
||||
self.cleanup_target.is_some()
|
||||
}
|
||||
}
|
||||
impl From<workdir::workspace::WorkingDirectoryMaterializerKind>
|
||||
for WorkingDirectoryMaterializerKind
|
||||
{
|
||||
fn from(value: workdir::workspace::WorkingDirectoryMaterializerKind) -> Self {
|
||||
match value {
|
||||
workdir::workspace::WorkingDirectoryMaterializerKind::RuntimeGitClone => {
|
||||
Self::RuntimeGitClone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<workdir::workspace::WorkingDirectoryStatusKind> for WorkingDirectoryStatusKind {
|
||||
fn from(value: workdir::workspace::WorkingDirectoryStatusKind) -> Self {
|
||||
match value {
|
||||
workdir::workspace::WorkingDirectoryStatusKind::Active => Self::Active,
|
||||
workdir::workspace::WorkingDirectoryStatusKind::CleanupPending => Self::CleanupPending,
|
||||
workdir::workspace::WorkingDirectoryStatusKind::Corrupted => Self::Corrupted,
|
||||
workdir::workspace::WorkingDirectoryStatusKind::NotFound => Self::NotFound,
|
||||
workdir::workspace::WorkingDirectoryStatusKind::Unknown => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<workdir::workspace::WorkingDirectoryCleanupTarget> for WorkingDirectoryCleanupTarget {
|
||||
fn from(value: workdir::workspace::WorkingDirectoryCleanupTarget) -> Self {
|
||||
Self {
|
||||
kind: value.kind,
|
||||
working_directory_id: value.working_directory_id,
|
||||
repository_key: value.repository_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<workdir::workspace::WorkingDirectoryOccupancy> for WorkingDirectoryOccupancy {
|
||||
fn from(value: workdir::workspace::WorkingDirectoryOccupancy) -> Self {
|
||||
Self {
|
||||
runtime_id: value.runtime_id,
|
||||
worker_id: value.worker_id,
|
||||
display_name: value.display_name,
|
||||
linked_at: value.linked_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<workdir::workspace::RuntimeWorkingDirectoryCleanupTarget>
|
||||
for RuntimeWorkingDirectoryCleanupTarget
|
||||
{
|
||||
fn from(value: workdir::workspace::RuntimeWorkingDirectoryCleanupTarget) -> Self {
|
||||
Self {
|
||||
kind: value.kind,
|
||||
working_directory_id: value.working_directory_id,
|
||||
repository_id: value.repository_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<workdir::workspace::RuntimeWorkingDirectorySummary> for RuntimeWorkingDirectorySummary {
|
||||
fn from(value: workdir::workspace::RuntimeWorkingDirectorySummary) -> Self {
|
||||
Self {
|
||||
working_directory_id: value.working_directory_id,
|
||||
repository_id: value.repository_id,
|
||||
creation_selector: value.creation_selector,
|
||||
creation_ref: value.creation_ref,
|
||||
creation_tree: value.creation_tree,
|
||||
current_selector: value.current_selector,
|
||||
current_ref: value.current_ref,
|
||||
current_tree: value.current_tree,
|
||||
observed_at_epoch_seconds: value.observed_at_epoch_seconds,
|
||||
materializer_kind: value.materializer_kind.into(),
|
||||
cleanup_target: value.cleanup_target.map(Into::into),
|
||||
status: value.status.into(),
|
||||
cleanliness: value.cleanliness,
|
||||
primary_worker_id: value.primary_worker_id,
|
||||
occupied_by: value.occupied_by.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySummary {
|
||||
fn from(value: workdir::workspace::WorkingDirectorySummary) -> Self {
|
||||
Self {
|
||||
working_directory_id: value.working_directory_id,
|
||||
repository_key: value.repository_key,
|
||||
creation_selector: value.creation_selector,
|
||||
creation_ref: value.creation_ref,
|
||||
creation_tree: value.creation_tree,
|
||||
current_selector: value.current_selector,
|
||||
current_ref: value.current_ref,
|
||||
current_tree: value.current_tree,
|
||||
observed_at_epoch_seconds: value.observed_at_epoch_seconds,
|
||||
materializer_kind: value.materializer_kind.into(),
|
||||
cleanup_target: value.cleanup_target.map(Into::into),
|
||||
status: value.status.into(),
|
||||
cleanliness: value.cleanliness,
|
||||
primary_worker_id: value.primary_worker_id,
|
||||
occupied_by: value.occupied_by.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser/Rust-client Workdir materialization request.
|
||||
///
|
||||
@@ -2128,7 +2429,7 @@ pub fn companion_api_typescript() -> String {
|
||||
];
|
||||
|
||||
format!(
|
||||
"// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.\n// Do not edit manually.\n\n{}\n",
|
||||
"// Generated by `cargo run -p server-api --features typescript --example generate_companion_api_types`.\n// Do not edit manually.\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
@@ -2327,15 +2628,36 @@ pub enum WorkerOperationState {
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// Cross-layer outcome of an explicit Worker restore operation.
|
||||
///
|
||||
/// `Rejected` is reserved for read-only preflight failures. Once live restore
|
||||
/// work starts, failure is either a confirmed `RolledBack` operation or a
|
||||
/// `ReconciliationRequired` result whose commit state must be reread/retried.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerRestoreState {
|
||||
Accepted,
|
||||
Rejected,
|
||||
RolledBack,
|
||||
ReconciliationRequired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerRestoreResult {
|
||||
pub state: WorkerOperationState,
|
||||
pub state: WorkerRestoreState,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub worker: Option<WorkerSummary>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerRestoreResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
@@ -2525,7 +2847,7 @@ pub fn memory_api_typescript() -> String {
|
||||
];
|
||||
|
||||
format!(
|
||||
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
@@ -3103,7 +3425,18 @@ fn validate_diagnostics(diagnostics: &[SkillDiagnostic]) -> Result<(), SkillApiV
|
||||
}
|
||||
|
||||
#[cfg(feature = "typescript")]
|
||||
pub fn catalog_typescript() -> String {
|
||||
pub fn normalized_typescript(generated: String) -> String {
|
||||
let mut normalized = generated
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
normalized.push('\n');
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(feature = "typescript")]
|
||||
pub fn legacy_catalog_typescript() -> String {
|
||||
use ts_rs::TS;
|
||||
|
||||
let config = ts_rs::Config::default();
|
||||
@@ -3137,16 +3470,7 @@ pub fn catalog_typescript() -> String {
|
||||
WorkspaceProfileSummary::decl(&config),
|
||||
WorkspaceProfileSourceSummary::decl(&config),
|
||||
WorkspaceProfileSourceProvenance::decl(&config),
|
||||
RepositorySourceKind::decl(&config),
|
||||
RepositorySource::decl(&config),
|
||||
RepositoryObservedStatus::decl(&config),
|
||||
RepositoryDiagnostic::decl(&config),
|
||||
GitRemoteSummary::decl(&config),
|
||||
GitRepositorySummary::decl(&config),
|
||||
RepositorySummary::decl(&config),
|
||||
GitCommitSummary::decl(&config),
|
||||
RepositoryListResponse::decl(&config),
|
||||
RepositoryDetailResponse::decl(&config),
|
||||
RepositorySshConnectionProbeRequest::decl(&config),
|
||||
RepositorySshHostKeyCandidate::decl(&config),
|
||||
RepositorySshConnectionTrustState::decl(&config),
|
||||
@@ -3187,7 +3511,7 @@ pub fn catalog_typescript() -> String {
|
||||
.map(|declaration| format!("export {declaration}"));
|
||||
|
||||
format!(
|
||||
"// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`.\n// Do not edit this file directly.\n\n{}\n",
|
||||
"// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.\n// Do not edit this file directly.\n\nimport type {{ RepositoryObservedStatus, RepositorySource }} from \"./repository-api\";\n\n{}\n",
|
||||
declarations.join("\n\n")
|
||||
)
|
||||
}
|
||||
@@ -3212,7 +3536,7 @@ pub fn repository_access_api_typescript() -> String {
|
||||
RepositoryAccessProjection::decl(&config),
|
||||
];
|
||||
format!(
|
||||
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts\n\n{}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
@@ -3243,7 +3567,7 @@ pub fn skill_api_typescript() -> String {
|
||||
"export const SKILL_API_AUTHORITY = \"{SKILL_CATALOG_AUTHORITY}\" as const;\n\nexport const SKILL_API_LIMITS = {{\n maxSafeInteger: {SKILL_API_MAX_SAFE_INTEGER},\n maxCatalogEntries: {SKILL_API_MAX_CATALOG_ENTRIES},\n maxOverrides: {SKILL_API_MAX_OVERRIDES},\n maxDiagnostics: {SKILL_API_MAX_DIAGNOSTICS},\n maxResources: {SKILL_API_MAX_RESOURCES},\n maxAllowedTools: {SKILL_API_MAX_ALLOWED_TOOLS},\n maxNameBytes: {SKILL_API_MAX_NAME_BYTES},\n maxLabelBytes: {SKILL_API_MAX_LABEL_BYTES},\n maxBodyBytes: {SKILL_API_MAX_BODY_BYTES},\n maxPathBytes: {SKILL_API_MAX_PATH_BYTES},\n maxDigestBytes: {SKILL_API_MAX_DIGEST_BYTES},\n maxResponseBytes: {SKILL_API_MAX_RESPONSE_BYTES},\n}} as const;"
|
||||
);
|
||||
format!(
|
||||
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts\n\n{limits}\n\n{}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts\n\n{limits}\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
@@ -3284,7 +3608,7 @@ pub fn auth_api_typescript() -> String {
|
||||
LogoutResponse::decl(&config),
|
||||
];
|
||||
format!(
|
||||
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts\n\n{}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
@@ -3312,7 +3636,7 @@ pub fn workdir_api_typescript() -> String {
|
||||
WorkingDirectoryCreateResponse::decl(&config),
|
||||
];
|
||||
format!(
|
||||
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts\n\n{}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
@@ -3351,7 +3675,7 @@ pub fn worker_launch_api_typescript() -> String {
|
||||
BrowserWorkspaceOrchestratorResponse::decl(&config),
|
||||
];
|
||||
format!(
|
||||
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts\n\nimport type {{ Segment }} from \"./protocol\";\n\n{}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts\n\nimport type {{ Segment }} from \"./protocol\";\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
@@ -3372,7 +3696,7 @@ mod worker_launch_typescript_tests {
|
||||
assert_eq!(
|
||||
normalize(&actual),
|
||||
normalize(&expected),
|
||||
"regenerate Worker launch API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts` and format the generated file",
|
||||
"regenerate Worker launch API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts` and format the generated file",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3401,7 +3725,7 @@ mod memory_typescript_tests {
|
||||
assert_eq!(
|
||||
normalize(&actual),
|
||||
normalize(&expected),
|
||||
"regenerate Memory API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts` and format the generated file",
|
||||
"regenerate Memory API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts` and format the generated file",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3431,7 +3755,7 @@ mod skill_typescript_tests {
|
||||
assert_eq!(
|
||||
normalize(&actual),
|
||||
normalize(&expected),
|
||||
"regenerate Skill API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts` and format the generated file",
|
||||
"regenerate Skill API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts` and format the generated file",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3461,7 +3785,7 @@ mod workdir_typescript_tests {
|
||||
assert_eq!(
|
||||
normalize(&actual),
|
||||
normalize(&expected),
|
||||
"regenerate Workdir API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts` and format the generated file",
|
||||
"regenerate Workdir API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts` and format the generated file",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3481,6 +3805,67 @@ mod workdir_typescript_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn worker_session_contract_and_flattened_availability_are_stable() {
|
||||
let operations = ServerApiMetadata::OPERATIONS;
|
||||
assert_eq!(operations.len(), 6);
|
||||
let operation = operations
|
||||
.iter()
|
||||
.find(|operation| operation.operation_id == "worker_session")
|
||||
.expect("worker-session operation must remain in ServerApi metadata");
|
||||
assert_eq!(operation.method, HttpMethod::Get);
|
||||
assert_eq!(
|
||||
operation.path,
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session"
|
||||
);
|
||||
|
||||
let response = WorkspaceWorkerSessionResponse {
|
||||
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
worker_id: "worker-a".to_string(),
|
||||
},
|
||||
observation: runtime_api::WorkerSessionAvailability::LiveProtocol,
|
||||
};
|
||||
let value = serde_json::to_value(response).unwrap();
|
||||
assert_eq!(value["availability"], "live_protocol");
|
||||
assert_eq!(value["subject"]["kind"], "runtime_worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_restore_state_round_trips_and_rejects_unknown_variants() {
|
||||
for (state, wire) in [
|
||||
(WorkerRestoreState::Accepted, "accepted"),
|
||||
(WorkerRestoreState::Rejected, "rejected"),
|
||||
(WorkerRestoreState::RolledBack, "rolled_back"),
|
||||
(
|
||||
WorkerRestoreState::ReconciliationRequired,
|
||||
"reconciliation_required",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_value(state).unwrap(),
|
||||
serde_json::json!(wire)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkerRestoreState>(serde_json::json!(wire)).unwrap(),
|
||||
state
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<WorkerRestoreState>(serde_json::json!("unknown")).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_restore_result_rejects_unknown_fields() {
|
||||
let result = serde_json::from_value::<WorkerRestoreResult>(serde_json::json!({
|
||||
"state": "rejected",
|
||||
"worker": null,
|
||||
"diagnostics": [],
|
||||
"unexpected": true
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_http_repository_source_kind_decodes_as_invalid_evidence() {
|
||||
@@ -4122,15 +4507,20 @@ mod tests {
|
||||
#[cfg(feature = "typescript")]
|
||||
#[test]
|
||||
fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() {
|
||||
let output = catalog_typescript();
|
||||
let output = legacy_catalog_typescript();
|
||||
assert!(
|
||||
output.contains("export type WorkspaceCatalogListResponse = Array<WorkspaceSummary>")
|
||||
);
|
||||
assert!(output.contains("export type WorkspaceResponse ="));
|
||||
assert!(output.contains("permissions: WorkspacePermissionSummary"));
|
||||
assert!(output.contains("export type RepositoryListResponse ="));
|
||||
assert!(output.contains("items: Array<RepositorySummary>"));
|
||||
assert!(output.contains("observed_at?: string | null"));
|
||||
assert!(output.contains(
|
||||
"import type { RepositoryObservedStatus, RepositorySource } from \"./repository-api\";"
|
||||
));
|
||||
assert!(!output.contains("export type RepositoryListResponse ="));
|
||||
assert!(!output.contains("export type RepositoryDetailResponse ="));
|
||||
assert!(!output.contains("export type RepositorySummary ="));
|
||||
assert!(!output.contains("export type RepositorySource ="));
|
||||
assert!(!output.contains("export type RepositoryObservedStatus ="));
|
||||
assert!(output.contains("export type WorkspaceMetadataSettingsResponse ="));
|
||||
assert!(output.contains("export type WorkspaceMetadataMutationResponse ="));
|
||||
assert!(output.contains("export type ProfileSettingsResponse ="));
|
||||
@@ -4572,7 +4962,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
normalize_typescript(&actual),
|
||||
normalize_typescript(&expected),
|
||||
"regenerate auth API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts` and format the generated file",
|
||||
"regenerate auth API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts` and format the generated file",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4655,7 +5045,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
normalize_typescript(&actual),
|
||||
normalize_typescript(&expected),
|
||||
"regenerate Companion API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts` and format the generated file",
|
||||
"regenerate Companion API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts` and format the generated file",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4767,6 +5157,107 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod openapi_artifact_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_openapi_artifact_is_current() {
|
||||
let generated = canonical_openapi_document()
|
||||
.expect("canonical OpenAPI contract must be valid")
|
||||
.to_json()
|
||||
.expect("canonical OpenAPI contract must serialize");
|
||||
let checked_in = include_str!("../../../openapi/server-api.json");
|
||||
assert_eq!(
|
||||
generated, checked_in,
|
||||
"regenerate with `cargo run -p server-api --example export_openapi -- openapi/server-api.json`",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_operations_are_in_the_canonical_contract() {
|
||||
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&document.to_json().expect("document must serialize"))
|
||||
.expect("document must be JSON");
|
||||
|
||||
assert_eq!(value["openapi"], "3.1.0");
|
||||
assert!(
|
||||
value["paths"]
|
||||
["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session"]
|
||||
.is_null()
|
||||
);
|
||||
assert!(
|
||||
value["info"]["x-yoi-source-digest"]
|
||||
.as_str()
|
||||
.is_some_and(|digest| digest.starts_with("sha256:"))
|
||||
);
|
||||
assert_eq!(
|
||||
value["paths"]["/api/w/{workspace_id}/repositories"]["get"]["responses"]["200"]["content"]
|
||||
["application/json"]["schema"]["$ref"],
|
||||
"#/components/schemas/RepositoryListResponse"
|
||||
);
|
||||
assert!(value["paths"]["/api/repositories"]["get"].is_object());
|
||||
assert!(value["paths"]["/api/repositories/{repository_key}"]["get"].is_object());
|
||||
for (path, method) in [
|
||||
("/api/w/{workspace_id}/repositories", "get"),
|
||||
("/api/w/{workspace_id}/repositories", "post"),
|
||||
("/api/w/{workspace_id}/repositories/{repository_key}", "get"),
|
||||
("/api/repositories", "get"),
|
||||
("/api/repositories/{repository_key}", "get"),
|
||||
] {
|
||||
let operation = &value["paths"][path][method];
|
||||
assert_eq!(
|
||||
operation["security"][0]["bearerAuth"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
assert_eq!(
|
||||
operation["security"][1]["browserSession"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
for status in ["401", "403", "500"] {
|
||||
assert!(
|
||||
operation["responses"][status].is_object(),
|
||||
"missing {method} {path} response {status}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let repository_collection = &value["paths"]["/api/w/{workspace_id}/repositories"];
|
||||
let repository_create = &repository_collection["post"];
|
||||
assert_eq!(
|
||||
repository_create["security"][0]["bearerAuth"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
assert_eq!(
|
||||
repository_create["security"][1]["browserSession"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
for status in [
|
||||
"200", "201", "400", "401", "403", "404", "409", "413", "415", "422", "500",
|
||||
] {
|
||||
assert!(
|
||||
repository_create["responses"][status].is_object(),
|
||||
"missing repository-create response {status}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
repository_create["responses"]["422"]["content"]["application/json"]["schema"]["$ref"],
|
||||
"#/components/schemas/RepositoryApiError"
|
||||
);
|
||||
assert_eq!(
|
||||
value["components"]["securitySchemes"]["bearerAuth"]["scheme"],
|
||||
"bearer"
|
||||
);
|
||||
assert_eq!(
|
||||
value["components"]["securitySchemes"]["browserSession"]["in"],
|
||||
"cookie"
|
||||
);
|
||||
assert!(value["paths"]["/api/w/{workspace_id}/repositories/{repository_key}"]
|
||||
["get"]["responses"]["404"]
|
||||
.is_object());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "typescript"))]
|
||||
mod typescript_tests {
|
||||
#[test]
|
||||
@@ -4779,7 +5270,7 @@ mod typescript_tests {
|
||||
assert_eq!(
|
||||
normalize(&actual),
|
||||
normalize(&expected),
|
||||
"regenerate Repository Access TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts` and format the generated file",
|
||||
"regenerate Repository Access TypeScript types with `cargo run -q -p server-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts` and format the generated file",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub const GENERATOR_NAME: &str = "yoi-repository-openapi-typescript";
|
||||
pub const GENERATOR_VERSION: &str = "1";
|
||||
pub const GENERATOR_OPTIONS: &str = "repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output";
|
||||
pub const INPUT_PATH: &str = "openapi/server-api.json";
|
||||
pub const OUTPUT_PATH: &str = "web/workspace/src/lib/generated/repository-api.ts";
|
||||
|
||||
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
|
||||
const MIN_SAFE_INTEGER: f64 = -9_007_199_254_740_991.0;
|
||||
const SCHEMA_REF_PREFIX: &str = "#/components/schemas/";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GenerationError(String);
|
||||
|
||||
impl GenerationError {
|
||||
fn invalid(message: impl Into<String>) -> Self {
|
||||
Self(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GenerationError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for GenerationError {}
|
||||
|
||||
pub fn generate_repository_typescript(input: &str) -> Result<String, GenerationError> {
|
||||
let document: Value = serde_json::from_str(input)
|
||||
.map_err(|error| GenerationError::invalid(format!("invalid OpenAPI JSON: {error}")))?;
|
||||
let root = object(&document, "OpenAPI document")?;
|
||||
if root.get("openapi").and_then(Value::as_str) != Some("3.1.0") {
|
||||
return Err(GenerationError::invalid(
|
||||
"Repository TypeScript generation requires OpenAPI 3.1.0",
|
||||
));
|
||||
}
|
||||
|
||||
let info = object_field(root, "info", "OpenAPI document")?;
|
||||
let contract_version = required_string(info, "version", "OpenAPI info")?;
|
||||
let contract_digest = required_string(info, "x-yoi-source-digest", "OpenAPI info")?;
|
||||
validate_contract_digest(contract_digest)?;
|
||||
|
||||
let schemas = object_field(
|
||||
object_field(root, "components", "OpenAPI document")?,
|
||||
"schemas",
|
||||
"OpenAPI components",
|
||||
)?;
|
||||
let paths = object_field(root, "paths", "OpenAPI document")?;
|
||||
let roots = validate_repository_operations(paths)?;
|
||||
let closure = schema_closure(schemas, &roots)?;
|
||||
|
||||
let mut output = format!(
|
||||
"// @generated by {GENERATOR_NAME}@{GENERATOR_VERSION}. Do not edit.\n\
|
||||
// Input: {INPUT_PATH}\n\
|
||||
// Input contract: {contract_version} ({contract_digest})\n\
|
||||
// Options: {GENERATOR_OPTIONS}\n\
|
||||
// Output: {OUTPUT_PATH}\n\
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check\n\n"
|
||||
);
|
||||
for name in closure {
|
||||
let schema = schemas
|
||||
.get(&name)
|
||||
.ok_or_else(|| GenerationError::invalid(format!("missing schema `{name}`")))?;
|
||||
let declaration = render_schema(schema, &format!("schema `{name}`"))?;
|
||||
output.push_str(&format!("export type {name} = {declaration};\n\n"));
|
||||
}
|
||||
output.pop();
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn validate_contract_digest(digest: &str) -> Result<(), GenerationError> {
|
||||
let Some(hex) = digest.strip_prefix("sha256:") else {
|
||||
return Err(GenerationError::invalid(
|
||||
"OpenAPI source digest must use sha256",
|
||||
));
|
||||
};
|
||||
if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(GenerationError::invalid(
|
||||
"OpenAPI source digest must contain 64 hexadecimal digits",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_repository_operations(
|
||||
paths: &Map<String, Value>,
|
||||
) -> Result<BTreeSet<String>, GenerationError> {
|
||||
let mut roots = BTreeSet::new();
|
||||
for operation in [
|
||||
OperationContract {
|
||||
path: "/api/repositories",
|
||||
method: "get",
|
||||
operation_id: "repository_list_alias",
|
||||
request: None,
|
||||
successes: &[("200", "RepositoryListResponse")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/repositories/{repository_key}",
|
||||
method: "get",
|
||||
operation_id: "repository_detail_alias",
|
||||
request: None,
|
||||
successes: &[("200", "RepositoryDetailResponse")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/repositories",
|
||||
method: "get",
|
||||
operation_id: "repository_list",
|
||||
request: None,
|
||||
successes: &[("200", "RepositoryListResponse")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/repositories",
|
||||
method: "post",
|
||||
operation_id: "repository_create",
|
||||
request: Some("CreateWorkspaceRepositoryRequest"),
|
||||
successes: &[
|
||||
("200", "CreateWorkspaceRepositoryResponse"),
|
||||
("201", "CreateWorkspaceRepositoryResponse"),
|
||||
],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/repositories/{repository_key}",
|
||||
method: "get",
|
||||
operation_id: "repository_detail",
|
||||
request: None,
|
||||
successes: &[("200", "RepositoryDetailResponse")],
|
||||
},
|
||||
] {
|
||||
let path = object_field(paths, operation.path, "OpenAPI paths")?;
|
||||
let value = object_field(path, operation.method, operation.path)?;
|
||||
if value.get("operationId").and_then(Value::as_str) != Some(operation.operation_id) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{} {} must retain operationId `{}`",
|
||||
operation.method, operation.path, operation.operation_id
|
||||
)));
|
||||
}
|
||||
|
||||
match operation.request {
|
||||
Some(expected) => {
|
||||
let request = object_field(value, "requestBody", operation.operation_id)?;
|
||||
let schema = json_content_schema(request, operation.operation_id)?;
|
||||
require_schema_ref(schema, expected, operation.operation_id)?;
|
||||
roots.insert(expected.to_owned());
|
||||
}
|
||||
None if value.contains_key("requestBody") => {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{} unexpectedly gained a request body",
|
||||
operation.operation_id
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
let responses = object_field(value, "responses", operation.operation_id)?;
|
||||
let successes = operation
|
||||
.successes
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for (status, response) in responses {
|
||||
let response = object(
|
||||
response,
|
||||
&format!("{} response {status}", operation.operation_id),
|
||||
)?;
|
||||
let schema = json_content_schema(response, operation.operation_id)?;
|
||||
let expected = successes
|
||||
.get(status.as_str())
|
||||
.copied()
|
||||
.unwrap_or("RepositoryApiError");
|
||||
require_schema_ref(schema, expected, operation.operation_id)?;
|
||||
roots.insert(expected.to_owned());
|
||||
}
|
||||
for (status, expected) in successes {
|
||||
if !responses.contains_key(status) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{} is missing success response {status} ({expected})",
|
||||
operation.operation_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
struct OperationContract {
|
||||
path: &'static str,
|
||||
method: &'static str,
|
||||
operation_id: &'static str,
|
||||
request: Option<&'static str>,
|
||||
successes: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
fn json_content_schema<'a>(
|
||||
value: &'a Map<String, Value>,
|
||||
context: &str,
|
||||
) -> Result<&'a Value, GenerationError> {
|
||||
object_field(
|
||||
object_field(value, "content", context)?,
|
||||
"application/json",
|
||||
context,
|
||||
)?
|
||||
.get("schema")
|
||||
.ok_or_else(|| GenerationError::invalid(format!("{context} is missing its JSON schema")))
|
||||
}
|
||||
|
||||
fn schema_reference<'a>(value: &'a Value, context: &str) -> Result<&'a str, GenerationError> {
|
||||
const ALLOWED_REFERENCE_SIBLINGS: &[&str] =
|
||||
&["$ref", "$comment", "deprecated", "description", "title"];
|
||||
let schema = object(value, context)?;
|
||||
if let Some(keyword) = schema
|
||||
.keys()
|
||||
.find(|keyword| !ALLOWED_REFERENCE_SIBLINGS.contains(&keyword.as_str()))
|
||||
{
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains unsupported `$ref` sibling `{keyword}`"
|
||||
)));
|
||||
}
|
||||
schema.get("$ref").and_then(Value::as_str).ok_or_else(|| {
|
||||
GenerationError::invalid(format!("{context} must use a string schema reference"))
|
||||
})
|
||||
}
|
||||
|
||||
fn require_schema_ref(value: &Value, expected: &str, context: &str) -> Result<(), GenerationError> {
|
||||
let reference = schema_reference(value, context)?;
|
||||
if reference != format!("{SCHEMA_REF_PREFIX}{expected}") {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} must reference `{expected}`, found `{reference}`"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn schema_closure(
|
||||
schemas: &Map<String, Value>,
|
||||
roots: &BTreeSet<String>,
|
||||
) -> Result<BTreeSet<String>, GenerationError> {
|
||||
let mut closure = BTreeSet::new();
|
||||
let mut pending = roots.iter().cloned().collect::<Vec<_>>();
|
||||
while let Some(name) = pending.pop() {
|
||||
if !closure.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let schema = schemas.get(&name).ok_or_else(|| {
|
||||
GenerationError::invalid(format!("OpenAPI schema `{name}` is missing"))
|
||||
})?;
|
||||
let mut references = BTreeSet::new();
|
||||
collect_schema_references(schema, &name, &mut references)?;
|
||||
pending.extend(references);
|
||||
}
|
||||
Ok(closure)
|
||||
}
|
||||
|
||||
fn collect_schema_references(
|
||||
value: &Value,
|
||||
context: &str,
|
||||
references: &mut BTreeSet<String>,
|
||||
) -> Result<(), GenerationError> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if object.contains_key("$ref") {
|
||||
let reference = schema_reference(value, context)?;
|
||||
let Some(name) = reference.strip_prefix(SCHEMA_REF_PREFIX) else {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains unsupported reference `{reference}`"
|
||||
)));
|
||||
};
|
||||
references.insert(name.to_owned());
|
||||
}
|
||||
for value in object.values() {
|
||||
collect_schema_references(value, context, references)?;
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
collect_schema_references(value, context, references)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_schema_keywords(
|
||||
schema: &Map<String, Value>,
|
||||
allowed: &[&str],
|
||||
context: &str,
|
||||
) -> Result<(), GenerationError> {
|
||||
const ALLOWED_ANNOTATIONS: &[&str] = &[
|
||||
"$comment",
|
||||
"default",
|
||||
"deprecated",
|
||||
"description",
|
||||
"example",
|
||||
"examples",
|
||||
"title",
|
||||
];
|
||||
if let Some(keyword) = schema.keys().find(|keyword| {
|
||||
!allowed.contains(&keyword.as_str()) && !ALLOWED_ANNOTATIONS.contains(&keyword.as_str())
|
||||
}) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains unsupported schema keyword `{keyword}`"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn literal_matches_type(kind: &str, value: &Value) -> bool {
|
||||
matches!(
|
||||
(kind, value),
|
||||
("string", Value::String(_)) | ("boolean", Value::Bool(_)) | ("null", Value::Null)
|
||||
)
|
||||
}
|
||||
|
||||
fn literal_schema_matches_type(schema: &Map<String, Value>, kind: &str) -> bool {
|
||||
if let Some(constant) = schema.get("const") {
|
||||
return literal_matches_type(kind, constant);
|
||||
}
|
||||
schema
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|values| {
|
||||
!values.is_empty() && values.iter().all(|value| literal_matches_type(kind, value))
|
||||
})
|
||||
}
|
||||
|
||||
fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError> {
|
||||
let schema = object(value, context)?;
|
||||
if schema.contains_key("$ref") {
|
||||
let reference = schema_reference(value, context)?;
|
||||
return reference
|
||||
.strip_prefix(SCHEMA_REF_PREFIX)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| {
|
||||
GenerationError::invalid(format!(
|
||||
"{context} contains unsupported reference `{reference}`"
|
||||
))
|
||||
});
|
||||
}
|
||||
if let Some(constant) = schema.get("const") {
|
||||
ensure_schema_keywords(schema, &["const", "type"], context)?;
|
||||
if let Some(type_value) = schema.get("type") {
|
||||
let kind = type_value.as_str().ok_or_else(|| {
|
||||
GenerationError::invalid(format!("{context} const type must be a string"))
|
||||
})?;
|
||||
if !literal_matches_type(kind, constant) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} const literal does not match type `{kind}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
return render_literal(constant, context);
|
||||
}
|
||||
if let Some(enumeration) = schema.get("enum") {
|
||||
ensure_schema_keywords(schema, &["enum", "type"], context)?;
|
||||
let values = enumeration.as_array().ok_or_else(|| {
|
||||
GenerationError::invalid(format!("{context} contains a non-array enum"))
|
||||
})?;
|
||||
if values.is_empty() {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains an empty enum"
|
||||
)));
|
||||
}
|
||||
if let Some(type_value) = schema.get("type") {
|
||||
let kind = type_value.as_str().ok_or_else(|| {
|
||||
GenerationError::invalid(format!("{context} enum type must be a string"))
|
||||
})?;
|
||||
if !values.iter().all(|value| literal_matches_type(kind, value)) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} enum literals do not match type `{kind}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
return values
|
||||
.iter()
|
||||
.map(|value| render_literal(value, context))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(|values| values.join(" | "));
|
||||
}
|
||||
if let Some(branches) = schema.get("anyOf") {
|
||||
ensure_schema_keywords(schema, &["anyOf"], context)?;
|
||||
return render_nullable_union(branches, context);
|
||||
}
|
||||
if let Some(branches) = schema.get("oneOf") {
|
||||
ensure_schema_keywords(schema, &["oneOf", "type"], context)?;
|
||||
let outer_type = match schema.get("type") {
|
||||
Some(Value::String(kind)) if kind == "string" => Some(kind.as_str()),
|
||||
Some(_) => {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains an unsupported oneOf type"
|
||||
)));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let branches = branches.as_array().ok_or_else(|| {
|
||||
GenerationError::invalid(format!("{context} contains a non-array oneOf"))
|
||||
})?;
|
||||
if branches.is_empty() {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains an empty oneOf"
|
||||
)));
|
||||
}
|
||||
let mut rendered = Vec::with_capacity(branches.len());
|
||||
let mut seen_literals = BTreeSet::new();
|
||||
for branch in branches {
|
||||
let branch_object = object(branch, context)?;
|
||||
if !branch_object.contains_key("enum") && !branch_object.contains_key("const") {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains unsupported ambiguous oneOf semantics"
|
||||
)));
|
||||
}
|
||||
let literals = if let Some(constant) = branch_object.get("const") {
|
||||
std::slice::from_ref(constant)
|
||||
} else {
|
||||
branch_object
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
GenerationError::invalid(format!(
|
||||
"{context} contains a non-array oneOf enum"
|
||||
))
|
||||
})?
|
||||
.as_slice()
|
||||
};
|
||||
for literal in literals {
|
||||
render_literal(literal, context)?;
|
||||
let normalized = serde_json::to_string(literal).map_err(|error| {
|
||||
GenerationError::invalid(format!(
|
||||
"failed to normalize {context} oneOf literal: {error}"
|
||||
))
|
||||
})?;
|
||||
if !seen_literals.insert(normalized) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains overlapping oneOf literal branches"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(kind) = outer_type {
|
||||
if !literal_schema_matches_type(branch_object, kind) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} oneOf branch does not match outer type `{kind}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
rendered.push(render_schema(branch, context)?);
|
||||
}
|
||||
return Ok(rendered.join(" | "));
|
||||
}
|
||||
|
||||
match schema.get("type") {
|
||||
Some(Value::String(kind)) => render_typed_schema(kind, schema, context),
|
||||
Some(Value::Array(kinds)) => {
|
||||
if kinds.len() != 2 || !kinds.iter().any(|kind| kind.as_str() == Some("null")) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains an unsupported type union"
|
||||
)));
|
||||
}
|
||||
let kind = kinds
|
||||
.iter()
|
||||
.find_map(|kind| {
|
||||
let kind = kind.as_str()?;
|
||||
(kind != "null").then_some(kind)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
GenerationError::invalid(format!("{context} has no non-null type"))
|
||||
})?;
|
||||
Ok(format!(
|
||||
"{} | null",
|
||||
render_typed_schema(kind, schema, context)?
|
||||
))
|
||||
}
|
||||
_ => Err(GenerationError::invalid(format!(
|
||||
"{context} has no supported type"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_nullable_union(value: &Value, context: &str) -> Result<String, GenerationError> {
|
||||
let branches = value
|
||||
.as_array()
|
||||
.ok_or_else(|| GenerationError::invalid(format!("{context} contains a non-array anyOf")))?;
|
||||
if branches.len() != 2
|
||||
|| branches
|
||||
.iter()
|
||||
.filter(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
|
||||
.count()
|
||||
!= 1
|
||||
{
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains unsupported ambiguous anyOf semantics"
|
||||
)));
|
||||
}
|
||||
let null_branch = branches
|
||||
.iter()
|
||||
.find(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
|
||||
.expect("validated one null branch");
|
||||
if render_schema(null_branch, context)? != "null" {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains an invalid nullable null branch"
|
||||
)));
|
||||
}
|
||||
let branch = branches
|
||||
.iter()
|
||||
.find(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
|
||||
.expect("validated one non-null branch");
|
||||
Ok(format!("{} | null", render_schema(branch, context)?))
|
||||
}
|
||||
|
||||
fn render_typed_schema(
|
||||
kind: &str,
|
||||
schema: &Map<String, Value>,
|
||||
context: &str,
|
||||
) -> Result<String, GenerationError> {
|
||||
match kind {
|
||||
"string" => {
|
||||
ensure_schema_keywords(schema, &["type"], context)?;
|
||||
Ok("string".to_owned())
|
||||
}
|
||||
"boolean" => {
|
||||
ensure_schema_keywords(schema, &["type"], context)?;
|
||||
Ok("boolean".to_owned())
|
||||
}
|
||||
"null" => {
|
||||
ensure_schema_keywords(schema, &["type"], context)?;
|
||||
Ok("null".to_owned())
|
||||
}
|
||||
"integer" => {
|
||||
ensure_schema_keywords(schema, &["format", "maximum", "minimum", "type"], context)?;
|
||||
let format = match schema.get("format") {
|
||||
Some(Value::String(format)) if format == "uint64" => Some(format.as_str()),
|
||||
Some(Value::String(format)) => {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} uses unsupported integer format `{format}`"
|
||||
)));
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} integer format must be a string"
|
||||
)));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let minimum = schema.get("minimum").and_then(Value::as_f64);
|
||||
let maximum = schema.get("maximum").and_then(Value::as_f64);
|
||||
let expected_minimum = if format == Some("uint64") {
|
||||
0.0
|
||||
} else {
|
||||
MIN_SAFE_INTEGER
|
||||
};
|
||||
if minimum != Some(expected_minimum) || maximum != Some(MAX_SAFE_INTEGER) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} integer range does not match its JavaScript safe-number mapping"
|
||||
)));
|
||||
}
|
||||
Ok("number".to_owned())
|
||||
}
|
||||
"array" => {
|
||||
ensure_schema_keywords(schema, &["items", "type"], context)?;
|
||||
let items = schema.get("items").ok_or_else(|| {
|
||||
GenerationError::invalid(format!("{context} array has no item schema"))
|
||||
})?;
|
||||
Ok(format!("Array<{}>", render_schema(items, context)?))
|
||||
}
|
||||
"object" => {
|
||||
ensure_schema_keywords(
|
||||
schema,
|
||||
&["additionalProperties", "properties", "required", "type"],
|
||||
context,
|
||||
)?;
|
||||
render_object(schema, context)
|
||||
}
|
||||
other => Err(GenerationError::invalid(format!(
|
||||
"{context} uses unsupported type `{other}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_object(schema: &Map<String, Value>, context: &str) -> Result<String, GenerationError> {
|
||||
let properties = schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| GenerationError::invalid(format!("{context} object has no properties")))?;
|
||||
let required = match schema.get("required") {
|
||||
Some(Value::Array(required)) => required
|
||||
.iter()
|
||||
.map(|name| {
|
||||
name.as_str().map(str::to_owned).ok_or_else(|| {
|
||||
GenerationError::invalid(format!(
|
||||
"{context} contains a non-string required property"
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?,
|
||||
None => BTreeSet::new(),
|
||||
Some(_) => {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains a non-array required declaration"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if let Some(missing) = required.iter().find(|name| !properties.contains_key(*name)) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} requires missing property `{missing}`"
|
||||
)));
|
||||
}
|
||||
if schema
|
||||
.get("additionalProperties")
|
||||
.is_some_and(|value| value != &Value::Bool(false))
|
||||
{
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains unsupported additionalProperties"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut fields = Vec::with_capacity(properties.len());
|
||||
for (name, property) in properties {
|
||||
let optional = if required.contains(name) { "" } else { "?" };
|
||||
let name = if is_typescript_identifier(name) {
|
||||
name.clone()
|
||||
} else {
|
||||
serde_json::to_string(name).expect("serializing a property name cannot fail")
|
||||
};
|
||||
fields.push(format!(
|
||||
"{name}{optional}: {}",
|
||||
render_schema(property, context)?
|
||||
));
|
||||
}
|
||||
Ok(format!("{{ {}; }}", fields.join("; ")))
|
||||
}
|
||||
|
||||
fn render_literal(value: &Value, context: &str) -> Result<String, GenerationError> {
|
||||
match value {
|
||||
Value::String(_) | Value::Bool(_) | Value::Null => {
|
||||
serde_json::to_string(value).map_err(|error| {
|
||||
GenerationError::invalid(format!("failed to render {context} literal: {error}"))
|
||||
})
|
||||
}
|
||||
_ => Err(GenerationError::invalid(format!(
|
||||
"{context} contains an unsupported non-string enum/const literal"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_typescript_identifier(value: &str) -> bool {
|
||||
let mut bytes = value.bytes();
|
||||
let Some(first) = bytes.next() else {
|
||||
return false;
|
||||
};
|
||||
(first.is_ascii_alphabetic() || matches!(first, b'_' | b'$'))
|
||||
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$'))
|
||||
}
|
||||
|
||||
fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>, GenerationError> {
|
||||
value
|
||||
.as_object()
|
||||
.ok_or_else(|| GenerationError::invalid(format!("{context} must be an object")))
|
||||
}
|
||||
|
||||
fn object_field<'a>(
|
||||
value: &'a Map<String, Value>,
|
||||
field: &str,
|
||||
context: &str,
|
||||
) -> Result<&'a Map<String, Value>, GenerationError> {
|
||||
value
|
||||
.get(field)
|
||||
.ok_or_else(|| GenerationError::invalid(format!("{context} is missing `{field}`")))
|
||||
.and_then(|value| object(value, &format!("{context}.{field}")))
|
||||
}
|
||||
|
||||
fn required_string<'a>(
|
||||
value: &'a Map<String, Value>,
|
||||
field: &str,
|
||||
context: &str,
|
||||
) -> Result<&'a str, GenerationError> {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| GenerationError::invalid(format!("{context} requires string `{field}`")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const OPENAPI: &str = include_str!("../../../openapi/server-api.json");
|
||||
const TYPESCRIPT: &str =
|
||||
include_str!("../../../web/workspace/src/lib/generated/repository-api.ts");
|
||||
|
||||
#[test]
|
||||
fn checked_in_repository_typescript_is_current() {
|
||||
assert_eq!(
|
||||
generate_repository_typescript(OPENAPI).unwrap(),
|
||||
TYPESCRIPT,
|
||||
"regenerate with `cargo run -q -p server-api --example generate_repository_openapi_types`",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_records_authority_and_preserves_openapi_nullability() {
|
||||
let output = generate_repository_typescript(OPENAPI).unwrap();
|
||||
assert!(output.contains(&format!(
|
||||
"@generated by {GENERATOR_NAME}@{GENERATOR_VERSION}"
|
||||
)));
|
||||
assert!(output.contains("Input contract: 0.1.0 (sha256:"));
|
||||
assert!(output.contains("export type RepositorySourceKind = \"local_path\" | \"file\" | \"ssh\" | \"https\" | \"invalid\""));
|
||||
assert!(output.contains("git?: GitRepositorySummary | null"));
|
||||
assert!(output.contains("default_ref?: string | null"));
|
||||
assert!(!output.contains("export type string ="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_rejects_lossy_safe_integer_and_union_changes() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
|
||||
serde_json::json!(9_007_199_254_740_992_u64);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("safe-number"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] =
|
||||
serde_json::json!(-1);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("safe-number"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
|
||||
serde_json::json!(9_007_199_254_740_990_u64);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("safe-number"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
|
||||
serde_json::json!("int64");
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("unsupported integer format"));
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(serde_json::json!({"type": "string"}));
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("ambiguous anyOf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_rejects_lossy_reference_siblings() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source"]["maxLength"] =
|
||||
serde_json::json!(1);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("unsupported `$ref` sibling"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_rejects_lossy_operation_reference_siblings() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["paths"]["/api/repositories"]["get"]["responses"]["200"]["content"]["application/json"]
|
||||
["schema"]["maxItems"] = serde_json::json!(1);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("unsupported `$ref` sibling"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_rejects_unimplemented_ordinary_schema_keywords() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["maxLength"] =
|
||||
serde_json::json!(1);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported schema keyword `maxLength`")
|
||||
);
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositoryListResponse"]["properties"]["items"]["maxItems"] =
|
||||
serde_json::json!(1);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported schema keyword `maxItems`")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_rejects_discarded_combined_schema_constraints() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["DiagnosticSeverity"]["const"] =
|
||||
serde_json::json!("info");
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported schema keyword `enum`")
|
||||
);
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
let null_branch =
|
||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
|
||||
.unwrap();
|
||||
null_branch["maxLength"] = serde_json::json!(1);
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported schema keyword `maxLength`")
|
||||
);
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySourceKind"]["type"] =
|
||||
serde_json::json!("string");
|
||||
document["components"]["schemas"]["RepositorySourceKind"]["oneOf"][0] =
|
||||
serde_json::json!({"const": true, "type": "boolean"});
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("oneOf branch does not match outer type")
|
||||
);
|
||||
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["RepositorySourceKind"]["oneOf"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(serde_json::json!({"const": "invalid", "type": "string"}));
|
||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("overlapping oneOf literal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_is_bounded_to_the_repository_operation_contract() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
document["components"]["schemas"]["UnmigratedDomain"] = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"pretend": {"type": "string"}},
|
||||
"required": ["pretend"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
let output = generate_repository_typescript(&document.to_string()).unwrap();
|
||||
assert!(!output.contains("UnmigratedDomain"));
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,12 @@ unicode-normalization = "0.1.25"
|
||||
unicode-properties = { version = "0.1.4", features = ["general-category"] }
|
||||
unicode-security = "0.1.2"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tempfile = { workspace = true }
|
||||
filetime = "0.2"
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -31,6 +31,36 @@
|
||||
//! })?;
|
||||
//! ```
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// Read an existing retained file without updating its access timestamp.
|
||||
/// Observation fails closed when the platform cannot provide that guarantee.
|
||||
pub(crate) fn read_without_atime(path: &Path) -> io::Result<Vec<u8>> {
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOATIME)
|
||||
.open(path)?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
let _ = path;
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"read-only retained observation requires no-atime file reads",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub mod event_trace;
|
||||
pub mod fs_store;
|
||||
pub mod history;
|
||||
@@ -57,6 +87,10 @@ pub use history::{
|
||||
};
|
||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||
pub use paste_artifact::PasteArtifactLimits;
|
||||
pub use public_snapshot::{
|
||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, RetainedSessionIdentity, RetainedSessionSnapshot,
|
||||
RetainedSnapshotReadError, project_session_snapshot, read_retained_session_snapshot,
|
||||
};
|
||||
pub use segment::{
|
||||
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
||||
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
|
||||
|
||||
@@ -7,11 +7,120 @@ use protocol::{
|
||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
};
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
|
||||
LoggedSessionHistoryOrigin, SessionId, SystemItem,
|
||||
LoggedSessionHistoryOrigin, SessionId, StoreError, SystemItem, WorkerAggregateStore,
|
||||
WorkerSessionStore, WorkerStoreError,
|
||||
};
|
||||
|
||||
/// Projection limit leaves one MiB for the typed API envelope so a public
|
||||
/// retained-session response remains below the 16 MiB response ceiling.
|
||||
pub const DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES: u64 = 15 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetainedSessionIdentity {
|
||||
pub session_id: String,
|
||||
pub segment_id: String,
|
||||
pub entry_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RetainedSessionSnapshot {
|
||||
pub identity: RetainedSessionIdentity,
|
||||
pub snapshot: SessionSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RetainedSnapshotReadError {
|
||||
#[error("retained worker state is unavailable")]
|
||||
RetentionMissing,
|
||||
#[error("retained worker state has no active session pointer")]
|
||||
ActivePointerMissing,
|
||||
#[error("retained session requires migration")]
|
||||
MigrationRequired,
|
||||
#[error("retained session log is corrupt")]
|
||||
CorruptLog,
|
||||
#[error("retained session storage is unavailable")]
|
||||
StorageUnavailable,
|
||||
#[error("retained session snapshot exceeds the observation limit")]
|
||||
SnapshotTooLarge,
|
||||
}
|
||||
|
||||
/// Read the active retained Session without creating, migrating, restoring, or
|
||||
/// rewriting any persisted state.
|
||||
pub fn read_retained_session_snapshot(
|
||||
aggregate_root: &Path,
|
||||
worker_name: &str,
|
||||
max_bytes: u64,
|
||||
) -> Result<RetainedSessionSnapshot, RetainedSnapshotReadError> {
|
||||
let aggregate = WorkerAggregateStore::open_read_only(aggregate_root, worker_name)
|
||||
.map_err(map_worker_store_error)?;
|
||||
let metadata = aggregate
|
||||
.read_read_only()
|
||||
.map_err(map_worker_store_error)?
|
||||
.ok_or(RetainedSnapshotReadError::RetentionMissing)?;
|
||||
let active = metadata
|
||||
.active
|
||||
.ok_or(RetainedSnapshotReadError::ActivePointerMissing)?;
|
||||
let segment_id = active
|
||||
.segment_id
|
||||
.ok_or(RetainedSnapshotReadError::ActivePointerMissing)?;
|
||||
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session"))
|
||||
.map_err(map_store_error)?;
|
||||
if store.segment_log_len(segment_id).map_err(map_store_error)? > max_bytes {
|
||||
return Err(RetainedSnapshotReadError::SnapshotTooLarge);
|
||||
}
|
||||
let entries = store
|
||||
.read_all_read_only(active.session_id, segment_id)
|
||||
.map_err(map_store_error)?;
|
||||
let snapshot = project_session_snapshot(active.session_id, &entries);
|
||||
if serde_json::to_vec(&snapshot)
|
||||
.map_err(|_| RetainedSnapshotReadError::CorruptLog)?
|
||||
.len() as u64
|
||||
> max_bytes
|
||||
{
|
||||
return Err(RetainedSnapshotReadError::SnapshotTooLarge);
|
||||
}
|
||||
Ok(RetainedSessionSnapshot {
|
||||
identity: RetainedSessionIdentity {
|
||||
session_id: active.session_id.to_string(),
|
||||
segment_id: segment_id.to_string(),
|
||||
entry_count: entries.len() as u64,
|
||||
},
|
||||
snapshot,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_worker_store_error(error: WorkerStoreError) -> RetainedSnapshotReadError {
|
||||
match error {
|
||||
WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
RetainedSnapshotReadError::RetentionMissing
|
||||
}
|
||||
WorkerStoreError::Serde(_) | WorkerStoreError::InvalidWorkerName(_) => {
|
||||
RetainedSnapshotReadError::CorruptLog
|
||||
}
|
||||
WorkerStoreError::Io(_) => RetainedSnapshotReadError::StorageUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_store_error(error: StoreError) -> RetainedSnapshotReadError {
|
||||
match error {
|
||||
StoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
RetainedSnapshotReadError::RetentionMissing
|
||||
}
|
||||
StoreError::Corrupt { message, .. } if message.contains("requires migration") => {
|
||||
RetainedSnapshotReadError::MigrationRequired
|
||||
}
|
||||
StoreError::Io(_) => RetainedSnapshotReadError::StorageUnavailable,
|
||||
StoreError::Serde(_) | StoreError::Corrupt { .. } => RetainedSnapshotReadError::CorruptLog,
|
||||
_ => RetainedSnapshotReadError::CorruptLog,
|
||||
}
|
||||
}
|
||||
|
||||
/// Project a complete current-segment log. A valid segment starts with one
|
||||
/// canonical annotated SegmentStart record; malformed partial input uses the
|
||||
/// nil session only to keep the public failure projection deterministic.
|
||||
@@ -318,7 +427,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
LoggedHistoryDerivation, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
|
||||
LoggedWorkerSubject,
|
||||
LoggedWorkerSubject, Store, WorkerMetadataStore,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -559,4 +668,89 @@ mod tests {
|
||||
SessionEntryProvenance::ModelOutput
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_snapshot_read_projects_active_segment_without_rewriting_files() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let aggregate_root = root.path().join("worker-a");
|
||||
let aggregate = WorkerAggregateStore::new(&aggregate_root, "worker-a").unwrap();
|
||||
let session_id = crate::new_session_id();
|
||||
let segment_id = crate::new_segment_id();
|
||||
aggregate
|
||||
.write(&crate::WorkerMetadata::new(
|
||||
"worker-a",
|
||||
Some(crate::WorkerActiveSegmentRef::active_segment(
|
||||
session_id, segment_id,
|
||||
)),
|
||||
))
|
||||
.unwrap();
|
||||
let session = WorkerSessionStore::new(aggregate_root.join("session")).unwrap();
|
||||
session.create_segment(session_id, segment_id, &[]).unwrap();
|
||||
let metadata_path = aggregate_root.join("metadata.json");
|
||||
let manifest_path = aggregate_root.join("session/session.json");
|
||||
let segment_path = aggregate_root
|
||||
.join("session/segments")
|
||||
.join(format!("{segment_id}.jsonl"));
|
||||
let metadata_before = std::fs::read(&metadata_path).unwrap();
|
||||
let manifest_before = std::fs::read(&manifest_path).unwrap();
|
||||
let old_atime = filetime::FileTime::from_unix_time(946_684_800, 0);
|
||||
for path in [&metadata_path, &manifest_path, &segment_path] {
|
||||
filetime::set_file_atime(path, old_atime).unwrap();
|
||||
}
|
||||
let mtimes_before = [&metadata_path, &manifest_path, &segment_path].map(|path| {
|
||||
filetime::FileTime::from_last_modification_time(&std::fs::metadata(path).unwrap())
|
||||
});
|
||||
|
||||
let retained = read_retained_session_snapshot(
|
||||
&aggregate_root,
|
||||
"worker-a",
|
||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(retained.identity.session_id, session_id.to_string());
|
||||
assert_eq!(retained.identity.segment_id, segment_id.to_string());
|
||||
assert_eq!(retained.identity.entry_count, 0);
|
||||
assert!(retained.snapshot.entries.is_empty());
|
||||
for (index, path) in [&metadata_path, &manifest_path, &segment_path]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let metadata = std::fs::metadata(path).unwrap();
|
||||
assert_eq!(
|
||||
filetime::FileTime::from_last_access_time(&metadata),
|
||||
old_atime,
|
||||
"retained observation changed access time for {}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
filetime::FileTime::from_last_modification_time(&metadata),
|
||||
mtimes_before[index],
|
||||
"retained observation changed modification time for {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
assert_eq!(std::fs::read(&metadata_path).unwrap(), metadata_before);
|
||||
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest_before);
|
||||
assert!(matches!(
|
||||
read_retained_session_snapshot(&aggregate_root, "worker-a", 0),
|
||||
Err(RetainedSnapshotReadError::SnapshotTooLarge)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_snapshot_read_does_not_create_missing_aggregate() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let aggregate = root.path().join("missing-worker");
|
||||
|
||||
let error = read_retained_session_snapshot(
|
||||
&aggregate,
|
||||
"missing-worker",
|
||||
DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, RetainedSnapshotReadError::RetentionMissing));
|
||||
assert!(!aggregate.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +383,43 @@ impl WorkerAggregateStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Open an existing aggregate without creating or rewriting retained files.
|
||||
pub fn open_read_only(
|
||||
root: impl Into<PathBuf>,
|
||||
worker_name: impl Into<String>,
|
||||
) -> Result<Self, WorkerStoreError> {
|
||||
let root = root.into();
|
||||
let worker_name = worker_name.into();
|
||||
validate_worker_name(&worker_name)?;
|
||||
if !root.is_dir() {
|
||||
return Err(WorkerStoreError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"worker aggregate is not retained",
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
update_lock: metadata_lock(&root),
|
||||
root,
|
||||
worker_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_read_only(&self) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||
let content = match crate::read_without_atime(&self.metadata_path()) {
|
||||
Ok(content) => content,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let metadata: WorkerMetadata = serde_json::from_slice(&content)?;
|
||||
if metadata.worker_name != self.worker_name {
|
||||
return Err(WorkerStoreError::InvalidWorkerName(format!(
|
||||
"aggregate identity mismatch: expected `{}`, found `{}`",
|
||||
self.worker_name, metadata.worker_name
|
||||
)));
|
||||
}
|
||||
Ok(Some(metadata))
|
||||
}
|
||||
|
||||
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||
validate_worker_name(worker_name)?;
|
||||
if worker_name == self.worker_name {
|
||||
|
||||
@@ -84,6 +84,59 @@ impl WorkerSessionStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Open an already-retained Session without creating directories or migrating
|
||||
/// persisted data. Observation paths must never mutate retained state.
|
||||
pub fn open_read_only(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
|
||||
let root = root.into();
|
||||
let bytes = crate::read_without_atime(&root.join(SESSION_FILE))?;
|
||||
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||
if manifest.schema_version != SESSION_SCHEMA_VERSION {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!(
|
||||
"Worker Session schema version {} requires migration; expected {}",
|
||||
manifest.schema_version, SESSION_SCHEMA_VERSION
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
root,
|
||||
session_id: Arc::new(Mutex::new(Some(manifest.session_id))),
|
||||
append_lock: Arc::new(Mutex::new(())),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_all_read_only(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> Result<Vec<LogEntry>, StoreError> {
|
||||
let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: "Worker Session identity lock poisoned".to_string(),
|
||||
})?;
|
||||
if *retained_session_id != Some(session_id) {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: "active Worker Session identity does not match retained manifest"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let path = self.log_path(segment_id);
|
||||
let bytes = crate::read_without_atime(&path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
StoreError::NotFound(segment_id)
|
||||
} else {
|
||||
StoreError::Io(error)
|
||||
}
|
||||
})?;
|
||||
parse_jsonl(&bytes)
|
||||
}
|
||||
|
||||
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
|
||||
Ok(fs::metadata(self.log_path(segment_id))?.len())
|
||||
}
|
||||
|
||||
pub fn root_dir(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
@@ -349,7 +349,6 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
|
||||
.send(&Method::Notify {
|
||||
notification_request_id: protocol::new_submission_request_id(),
|
||||
message: "persisted notification".to_string(),
|
||||
auto_run: true,
|
||||
})
|
||||
.await?;
|
||||
wait_for_run_end(&mut protocol_client).await?;
|
||||
|
||||
@@ -27,6 +27,7 @@ session-store = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||
async-trait.workspace = true
|
||||
agen.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -782,7 +782,6 @@ impl App {
|
||||
Some(Method::Notify {
|
||||
notification_request_id: protocol::new_submission_request_id(),
|
||||
message,
|
||||
auto_run: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{
|
||||
BackendRuntimeListTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||
BackendWorkerSummary, list_backend_stopped_workers, list_backend_workers,
|
||||
restore_backend_worker,
|
||||
BackendRuntimeListTarget, BackendWorkerSummary, WorkerSessionAvailability,
|
||||
list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
|
||||
};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::Frame;
|
||||
@@ -81,61 +80,22 @@ pub(crate) async fn run(
|
||||
}
|
||||
WorkerPickerResult::Selected(selected) => selected,
|
||||
};
|
||||
let worker = if selected.state == "stopped" {
|
||||
let restore_target = target
|
||||
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let restore = restore_backend_worker(&restore_target)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to restore Backend worker {}/{}: {error}",
|
||||
selected.runtime_id, selected.worker_id
|
||||
))
|
||||
})?;
|
||||
restored_worker(restore).map_err(|error| {
|
||||
let mut attach_target = target
|
||||
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let response = observe_backend_worker_session(&attach_target)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
"failed to restore Backend worker {}/{}: {error}",
|
||||
"failed to observe Worker Session {}/{}: {error}",
|
||||
selected.runtime_id, selected.worker_id
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
selected
|
||||
};
|
||||
let attach_target = target
|
||||
.runtime_target(worker.runtime_id, worker.worker_id)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
})?;
|
||||
apply_worker_session_observation(&mut attach_target, response.observation);
|
||||
return console::run_backend_runtime(attach_target).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn restored_worker(response: BackendWorkerRestoreResponse) -> Result<BackendWorkerSummary, String> {
|
||||
if response.result.state != BackendWorkerOperationState::Accepted {
|
||||
let diagnostics = response
|
||||
.result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
let state = match response.result.state {
|
||||
BackendWorkerOperationState::Accepted => unreachable!(),
|
||||
BackendWorkerOperationState::Rejected => "rejected",
|
||||
BackendWorkerOperationState::Unsupported => "unsupported",
|
||||
};
|
||||
return Err(if diagnostics.is_empty() {
|
||||
format!("restore was {state} without a diagnostic")
|
||||
} else {
|
||||
format!("restore was {state}: {diagnostics}")
|
||||
});
|
||||
}
|
||||
|
||||
response
|
||||
.result
|
||||
.worker
|
||||
.ok_or_else(|| "restore was accepted without a Worker snapshot".to_string())
|
||||
}
|
||||
|
||||
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
|
||||
@@ -146,6 +106,28 @@ enum WorkerPickerResult {
|
||||
SwitchWorkspace,
|
||||
}
|
||||
|
||||
fn apply_worker_session_observation(
|
||||
target: &mut client::BackendRuntimeTarget,
|
||||
observation: WorkerSessionAvailability,
|
||||
) {
|
||||
match observation {
|
||||
WorkerSessionAvailability::LiveProtocol => {}
|
||||
WorkerSessionAvailability::RetainedSnapshot { snapshot, .. } => {
|
||||
target.initial_snapshot = Some(snapshot);
|
||||
target.initial_notice = Some("read-only retained Session snapshot".to_string());
|
||||
}
|
||||
WorkerSessionAvailability::Unavailable { message, .. } => {
|
||||
let bounded_message: String = message.chars().take(512).collect();
|
||||
target.initial_snapshot = Some(protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
});
|
||||
target.initial_notice =
|
||||
Some(format!("retained Session unavailable: {bounded_message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_worker(
|
||||
target: BackendRuntimeListTarget,
|
||||
mut workers: Vec<BackendWorkerSummary>,
|
||||
@@ -436,8 +418,7 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use client::{
|
||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary,
|
||||
BackendWorkerImplementationSummary, BackendWorkerRestoreResult,
|
||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||
BackendWorkerWorkspaceSummary,
|
||||
};
|
||||
|
||||
@@ -493,65 +474,51 @@ mod tests {
|
||||
text_width(&text[..byte_offset])
|
||||
}
|
||||
|
||||
fn restore_response(
|
||||
state: BackendWorkerOperationState,
|
||||
worker: Option<BackendWorkerSummary>,
|
||||
diagnostics: Vec<BackendDiagnostic>,
|
||||
) -> BackendWorkerRestoreResponse {
|
||||
BackendWorkerRestoreResponse {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
worker_id: "worker-a".to_string(),
|
||||
result: BackendWorkerRestoreResult {
|
||||
state,
|
||||
worker,
|
||||
diagnostics,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_restore_surfaces_diagnostic_instead_of_attaching_selected_worker() {
|
||||
let error = restored_worker(restore_response(
|
||||
BackendWorkerOperationState::Rejected,
|
||||
None,
|
||||
vec![BackendDiagnostic {
|
||||
code: "working_directory_not_found".to_string(),
|
||||
severity: BackendDiagnosticSeverity::Error,
|
||||
message: "working directory was not found".to_string(),
|
||||
}],
|
||||
))
|
||||
.expect_err("rejected restore must not produce a Worker to attach");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
"restore was rejected: working_directory_not_found: working directory was not found"
|
||||
fn authoritative_observation_selects_live_retained_and_unavailable_detail_modes() {
|
||||
let mut live = client::BackendRuntimeTarget::new(
|
||||
"http://127.0.0.1:3000",
|
||||
"workspace-a",
|
||||
"runtime-a",
|
||||
"worker-a",
|
||||
);
|
||||
}
|
||||
apply_worker_session_observation(&mut live, WorkerSessionAvailability::LiveProtocol);
|
||||
assert!(live.initial_snapshot.is_none());
|
||||
|
||||
#[test]
|
||||
fn accepted_restore_requires_returned_worker_snapshot() {
|
||||
let error = restored_worker(restore_response(
|
||||
BackendWorkerOperationState::Accepted,
|
||||
None,
|
||||
Vec::new(),
|
||||
))
|
||||
.expect_err("accepted restore without a Worker must not attach the stale selection");
|
||||
let snapshot = protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
};
|
||||
let mut retained = live.clone();
|
||||
apply_worker_session_observation(
|
||||
&mut retained,
|
||||
WorkerSessionAvailability::RetainedSnapshot {
|
||||
identity: client::RetainedSessionIdentity {
|
||||
session_id: "session-a".to_string(),
|
||||
segment_id: "segment-a".to_string(),
|
||||
entry_count: 0,
|
||||
},
|
||||
snapshot: snapshot.clone(),
|
||||
},
|
||||
);
|
||||
assert_eq!(retained.initial_snapshot, Some(snapshot));
|
||||
assert!(retained.initial_notice.unwrap().contains("read-only"));
|
||||
|
||||
assert_eq!(error, "restore was accepted without a Worker snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_restore_returns_authoritative_worker_snapshot() {
|
||||
let worker = worker("runtime-a", "worker-a", Some("builtin:companion"));
|
||||
let restored = restored_worker(restore_response(
|
||||
BackendWorkerOperationState::Accepted,
|
||||
Some(worker.clone()),
|
||||
Vec::new(),
|
||||
))
|
||||
.expect("accepted restore should return its Worker snapshot");
|
||||
|
||||
assert_eq!(restored, worker);
|
||||
let mut unavailable = live;
|
||||
apply_worker_session_observation(
|
||||
&mut unavailable,
|
||||
WorkerSessionAvailability::Unavailable {
|
||||
reason: client::WorkerSessionUnavailableReason::RetentionMissing,
|
||||
message: "retention expired".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(unavailable.initial_snapshot.is_some());
|
||||
assert!(
|
||||
unavailable
|
||||
.initial_notice
|
||||
.unwrap()
|
||||
.contains("retention expired")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -458,19 +458,83 @@ async fn run_standalone_host(
|
||||
pub(crate) async fn run_backend_runtime(
|
||||
target: BackendRuntimeTarget,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let worker_label = target.display_label();
|
||||
let attachment_target = target.clone();
|
||||
let client = connect_backend_runtime(target).await?;
|
||||
if let Some(snapshot) = target.initial_snapshot.clone() {
|
||||
let client = Client::new(RetainedSnapshotSocket::new(snapshot)?);
|
||||
return run_backend_console(target, client, false).await;
|
||||
}
|
||||
let client = connect_backend_runtime(target.clone()).await?;
|
||||
run_backend_console(target, client, true).await
|
||||
}
|
||||
|
||||
async fn run_backend_console<T: Socket>(
|
||||
target: BackendRuntimeTarget,
|
||||
client: Client<T>,
|
||||
connected: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let worker_label = match target.initial_notice.as_deref() {
|
||||
Some(notice) => format!("{} — {notice}", target.display_label()),
|
||||
None => target.display_label(),
|
||||
};
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
|
||||
app.connected = true;
|
||||
let mut connection = ConsoleConnection::with_backend_target(client, attachment_target);
|
||||
app.connected = connected;
|
||||
let mut connection = ConsoleConnection::with_backend_target(client, target);
|
||||
let result = run_loop(&mut terminal, &mut app, &mut connection).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
result
|
||||
}
|
||||
|
||||
struct RetainedSnapshotSocket {
|
||||
event: Option<String>,
|
||||
}
|
||||
|
||||
impl RetainedSnapshotSocket {
|
||||
fn new(snapshot: protocol::SessionSnapshot) -> Result<Self, serde_json::Error> {
|
||||
Ok(Self {
|
||||
event: Some(serde_json::to_string(&Event::Snapshot {
|
||||
session: snapshot,
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "retained worker".to_string(),
|
||||
cwd: String::new(),
|
||||
provider: "retained session".to_string(),
|
||||
model: String::new(),
|
||||
scope_summary: "read-only retained session".to_string(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
state: protocol::WorkerStateSnapshot {
|
||||
last_command_id: 0,
|
||||
state: protocol::WorkerState::Idle,
|
||||
},
|
||||
in_flight: protocol::InFlightSnapshot::default(),
|
||||
internal_workers: Vec::new(),
|
||||
})?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Socket for RetainedSnapshotSocket {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
async fn send(&mut self, _message: String) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
if self.event.is_some() {
|
||||
return Ok(self.event.take());
|
||||
}
|
||||
std::future::pending::<Result<Option<String>, Self::Error>>().await
|
||||
}
|
||||
|
||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
Ok(self.event.take())
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_fullscreen() -> Result<ConsoleTerminal, Box<dyn std::error::Error>> {
|
||||
let mut stdout = io::stdout();
|
||||
// Enable button-event tracking so the transcript can own drag selection;
|
||||
@@ -2009,7 +2073,6 @@ mod tests {
|
||||
method,
|
||||
Some(Method::Notify {
|
||||
ref message,
|
||||
auto_run: true,
|
||||
..
|
||||
}) if message == "progress"
|
||||
));
|
||||
|
||||
@@ -19,7 +19,6 @@ sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
||||
workspace-api = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
+155
-19
@@ -1,17 +1,139 @@
|
||||
//! Shared Workspace-facing Workdir inventory transport contracts.
|
||||
//! Shared Workdir inventory domain contracts.
|
||||
//!
|
||||
//! These projections describe Workspace inventory and durable occupancy. They
|
||||
//! intentionally do not expose provider/session handles, host paths, Runtime
|
||||
//! URLs, or credentials. Runtime-local Workdir operation contracts live in
|
||||
//! [`crate::http`].
|
||||
//! These types are a neutral lower-level authority shared by Server and Runtime
|
||||
//! transport projections. They intentionally do not expose provider/session
|
||||
//! handles, host paths, Runtime URLs, or credentials.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use workspace_api::{
|
||||
RuntimeWorkingDirectoryCleanupTarget, RuntimeWorkingDirectorySummary,
|
||||
WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind as MaterializerKind,
|
||||
WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary,
|
||||
};
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryMaterializerKind {
|
||||
#[default]
|
||||
RuntimeGitClone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryStatusKind {
|
||||
Active,
|
||||
CleanupPending,
|
||||
Corrupted,
|
||||
NotFound,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl WorkingDirectoryStatusKind {
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::CleanupPending => "cleanup_pending",
|
||||
Self::Corrupted => "corrupted",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorkingDirectoryStatusKind {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryCleanupTarget {
|
||||
pub kind: String,
|
||||
pub working_directory_id: String,
|
||||
pub repository_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryOccupancy {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub display_name: String,
|
||||
pub linked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeWorkingDirectoryCleanupTarget {
|
||||
pub kind: String,
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeWorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub observed_at_epoch_seconds: Option<u64>,
|
||||
pub materializer_kind: WorkingDirectoryMaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<RuntimeWorkingDirectoryCleanupTarget>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub observed_at_epoch_seconds: Option<u64>,
|
||||
pub materializer_kind: WorkingDirectoryMaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanliness: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_worker_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
impl WorkingDirectorySummary {
|
||||
pub fn is_workspace_managed(&self) -> bool {
|
||||
self.cleanup_target.is_some()
|
||||
}
|
||||
}
|
||||
pub use WorkingDirectoryMaterializerKind as MaterializerKind;
|
||||
|
||||
/// Stable Workspace identity for a Worker hosted by a Runtime.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -89,15 +211,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_workdir_projection_reexports_workspace_api_authority() {
|
||||
assert_eq!(
|
||||
std::any::TypeId::of::<WorkingDirectorySummary>(),
|
||||
std::any::TypeId::of::<workspace_api::WorkingDirectorySummary>()
|
||||
);
|
||||
assert_eq!(
|
||||
std::any::TypeId::of::<WorkingDirectoryOccupancy>(),
|
||||
std::any::TypeId::of::<workspace_api::WorkingDirectoryOccupancy>()
|
||||
);
|
||||
fn working_directory_summary_preserves_server_projection_wire_shape() {
|
||||
let summary = WorkingDirectorySummary {
|
||||
working_directory_id: "wd-1".to_string(),
|
||||
repository_key: "repo-1".to_string(),
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
creation_tree: None,
|
||||
current_selector: None,
|
||||
current_ref: None,
|
||||
current_tree: None,
|
||||
observed_at_epoch_seconds: None,
|
||||
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitClone,
|
||||
cleanup_target: None,
|
||||
status: WorkingDirectoryStatusKind::Active,
|
||||
cleanliness: None,
|
||||
primary_worker_id: None,
|
||||
occupied_by: None,
|
||||
};
|
||||
let value = serde_json::to_value(summary).unwrap();
|
||||
assert_eq!(value["working_directory_id"], "wd-1");
|
||||
assert_eq!(value["materializer_kind"], "runtime_git_clone");
|
||||
assert_eq!(value["status"], "active");
|
||||
assert!(value.get("cleanup_target").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ flow = { path = "../flow" }
|
||||
fs4 = { workspace = true, optional = true }
|
||||
manifest.workspace = true
|
||||
protocol.workspace = true
|
||||
runtime-api.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
session-store.workspace = true
|
||||
sha2.workspace = true
|
||||
@@ -49,7 +50,7 @@ uuid = { workspace = true, features = ["v7"] }
|
||||
zeroize.workspace = true
|
||||
tower = { workspace = true, features = ["util"], optional = true }
|
||||
worker.workspace = true
|
||||
workspace-api = { path = "../workspace-api" }
|
||||
server-api = { path = "../server-api" }
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -74,7 +74,7 @@ impl std::ops::Deref for RepositorySelector {
|
||||
pub struct WorkingDirectoryRepository {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub source: workspace_api::RepositorySource,
|
||||
pub source: server_api::RepositorySource,
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -131,7 +131,7 @@ pub struct RepositorySshMaterializationAccess {
|
||||
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||
pub host_trust_id: String,
|
||||
pub host_trust_revision: u64,
|
||||
pub access: workspace_api::RepositoryAccessMode,
|
||||
pub access: server_api::RepositoryAccessMode,
|
||||
pub expires_at_epoch_seconds: u64,
|
||||
pub repository_id: String,
|
||||
pub repository_source_fingerprint: String,
|
||||
|
||||
@@ -286,7 +286,21 @@ pub enum WorkerExecutionSpawnResult {
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
working_directory: Option<WorkingDirectoryStatus>,
|
||||
},
|
||||
/// A read-only preflight rejected the operation before live work started.
|
||||
Rejected(WorkerExecutionResult),
|
||||
/// Live work started, failed, and all operation-owned resources were joined/released.
|
||||
RolledBack(WorkerExecutionResult),
|
||||
/// The operation crossed the live side-effect boundary and could not prove
|
||||
/// either commit or complete cleanup. Optional handle/state fields preserve
|
||||
/// concrete execution evidence for retry reconciliation.
|
||||
ReconciliationRequired {
|
||||
result: WorkerExecutionResult,
|
||||
handle: Option<WorkerExecutionHandle>,
|
||||
worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||
working_directory: Option<WorkingDirectoryStatus>,
|
||||
},
|
||||
/// Legacy spawn failure. Restore implementations must use `RolledBack` or
|
||||
/// `ReconciliationRequired` after crossing their live side-effect boundary.
|
||||
Errored(WorkerExecutionResult),
|
||||
}
|
||||
|
||||
@@ -304,6 +318,11 @@ impl WorkerExecutionSpawnResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WorkerSessionObservationRequest {
|
||||
pub worker_ref: WorkerRef,
|
||||
}
|
||||
|
||||
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
fn backend_id(&self) -> &str;
|
||||
|
||||
@@ -314,8 +333,27 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
Err("execution backend does not support Workspace Config fetching".to_string())
|
||||
}
|
||||
|
||||
fn worker_session(
|
||||
&self,
|
||||
_request: WorkerSessionObservationRequest,
|
||||
) -> runtime_api::WorkerSessionAvailability {
|
||||
runtime_api::WorkerSessionAvailability::Unavailable {
|
||||
reason: runtime_api::WorkerSessionUnavailableReason::StorageUnavailable,
|
||||
message: "retained session storage is unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
|
||||
|
||||
/// Read-only restore validation performed before any live controller,
|
||||
/// bridge, task, or external registration is created.
|
||||
fn preflight_restore(
|
||||
&self,
|
||||
_request: &WorkerExecutionRestoreRequest,
|
||||
) -> Result<(), WorkerExecutionResult> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_worker(
|
||||
&self,
|
||||
_request: WorkerExecutionRestoreRequest,
|
||||
@@ -502,6 +540,13 @@ impl WorkerExecutionBackendRef {
|
||||
self.backend.fetch_workspace_config(request)
|
||||
}
|
||||
|
||||
pub(crate) fn worker_session(
|
||||
&self,
|
||||
request: WorkerSessionObservationRequest,
|
||||
) -> runtime_api::WorkerSessionAvailability {
|
||||
self.backend.worker_session(request)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_worker(
|
||||
&self,
|
||||
request: WorkerExecutionSpawnRequest,
|
||||
@@ -509,6 +554,13 @@ impl WorkerExecutionBackendRef {
|
||||
self.backend.spawn_worker(request)
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_restore(
|
||||
&self,
|
||||
request: &WorkerExecutionRestoreRequest,
|
||||
) -> Result<(), WorkerExecutionResult> {
|
||||
self.backend.preflight_restore(request)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_worker(
|
||||
&self,
|
||||
request: WorkerExecutionRestoreRequest,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! Runtime process directly; a backend is expected to own any browser-facing
|
||||
//! credentials, registration, and policy.
|
||||
|
||||
mod runtime_management_api;
|
||||
|
||||
use crate::auth::{RuntimeAuthContext, new_token_id, unix_now_seconds};
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
|
||||
@@ -25,13 +27,11 @@ use crate::retention::{
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::runtime::RuntimeSubscriptionRecvError;
|
||||
use crate::ssh_host_key_probe::{
|
||||
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SSH_KEYSCAN_TIMEOUT,
|
||||
SshHostKeyProbeError, SshHostKeyProbeRequest, SshHostKeyProbeResponse,
|
||||
probe_ssh_host_keys_with_program,
|
||||
SSH_HOST_KEY_PROBE_OPERATION, SSH_KEYSCAN_TIMEOUT, SshHostKeyProbeError,
|
||||
SshHostKeyProbeRequest, SshHostKeyProbeResponse, probe_ssh_host_keys_with_program,
|
||||
};
|
||||
use crate::workspace_issuer::{
|
||||
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_ACK_PATH,
|
||||
WORKSPACE_VERIFICATION_CHALLENGE_PATH, WORKSPACE_VERIFICATION_OPERATION,
|
||||
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_OPERATION,
|
||||
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier,
|
||||
WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationAuthority,
|
||||
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
||||
@@ -164,6 +164,18 @@ pub async fn serve_runtime_http_with_workspace_auth(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remaining_route(path: &'static str, methods: &[&str]) -> &'static str {
|
||||
for method in methods {
|
||||
assert!(
|
||||
runtime_api::REMAINING_RUNTIME_ROUTES
|
||||
.iter()
|
||||
.any(|route| route.method == *method && route.path == path),
|
||||
"handwritten Runtime route {method} {path} is missing from runtime-api inventory",
|
||||
);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Build the REST router for an existing Runtime.
|
||||
///
|
||||
/// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local.
|
||||
@@ -209,101 +221,102 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
||||
};
|
||||
|
||||
let router = Router::new()
|
||||
.route("/v1/ping", get(get_runtime_ping))
|
||||
.route(
|
||||
WORKSPACE_VERIFICATION_CHALLENGE_PATH,
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE, &["POST"]),
|
||||
post(post_workspace_verification_challenge),
|
||||
)
|
||||
.route(
|
||||
WORKSPACE_VERIFICATION_ACK_PATH,
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK, &["POST"]),
|
||||
post(post_workspace_verification_acknowledgement),
|
||||
)
|
||||
.route("/v1/runtime", get(get_runtime))
|
||||
.route(
|
||||
"/v1/config-bundles",
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES, &["GET", "POST"]),
|
||||
get(list_config_bundles).post(store_config_bundle),
|
||||
)
|
||||
.route(
|
||||
"/v1/config-bundles/{bundle_id}/availability",
|
||||
remaining_route(
|
||||
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY,
|
||||
&["GET"],
|
||||
),
|
||||
get(check_config_bundle),
|
||||
)
|
||||
.route(
|
||||
"/v1/workspace-prompt-projections",
|
||||
remaining_route(
|
||||
runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS,
|
||||
&["POST"],
|
||||
),
|
||||
post(observe_workspace_prompt_projection),
|
||||
)
|
||||
.route(
|
||||
"/v1/working-directories",
|
||||
remaining_route(
|
||||
runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORIES,
|
||||
&["GET", "POST"],
|
||||
),
|
||||
get(list_working_directories).post(create_working_directory),
|
||||
)
|
||||
.route(
|
||||
"/v1/working-directories/repository-access",
|
||||
remaining_route(
|
||||
runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS,
|
||||
&["POST"],
|
||||
),
|
||||
post(authorize_working_directory_repository_access),
|
||||
)
|
||||
.route(
|
||||
SSH_HOST_KEY_PROBE_PATH,
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_SSH_PROBE, &["POST"]),
|
||||
post(probe_repository_ssh_host_keys),
|
||||
)
|
||||
.route("/v1/repository-refs/observe", post(observe_repository_ref))
|
||||
.route(
|
||||
"/v1/working-directories/{working_directory_id}/sessions",
|
||||
remaining_route(
|
||||
runtime_api::RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE,
|
||||
&["POST"],
|
||||
),
|
||||
post(observe_repository_ref),
|
||||
)
|
||||
.route(
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_WORKDIR_SESSIONS, &["POST"]),
|
||||
post(open_workdir_session),
|
||||
)
|
||||
.route(
|
||||
"/v1/workdir-sessions/{session_id}/operations",
|
||||
remaining_route(
|
||||
runtime_api::RUNTIME_ROUTE_WORKDIR_SESSION_OPERATIONS,
|
||||
&["POST"],
|
||||
),
|
||||
post(run_workdir_session_operation),
|
||||
)
|
||||
.route(
|
||||
"/v1/workdir-sessions/{session_id}",
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_WORKDIR_SESSION, &["DELETE"]),
|
||||
delete(close_workdir_session),
|
||||
)
|
||||
.route(
|
||||
"/v1/working-directories/{working_directory_id}",
|
||||
remaining_route(
|
||||
runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY,
|
||||
&["GET", "DELETE"],
|
||||
),
|
||||
get(get_working_directory).delete(cleanup_working_directory),
|
||||
)
|
||||
.route("/v1/workers", get(list_workers).post(create_worker))
|
||||
.route(
|
||||
"/v1/workers/{worker_id}",
|
||||
get(get_worker).delete(delete_worker),
|
||||
)
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/retention/inventory",
|
||||
get(worker_retention_inventory),
|
||||
)
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/retention/execute",
|
||||
post(execute_worker_retention),
|
||||
)
|
||||
.route("/v1/workers/{worker_id}/input", post(send_worker_input))
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/attachments",
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENTS, &["POST"]),
|
||||
post(upload_worker_file).layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)),
|
||||
)
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/attachments/{artifact_id}",
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENT, &["DELETE"]),
|
||||
delete(delete_worker_uploaded_file),
|
||||
)
|
||||
.route("/v1/workers/{worker_id}/restore", post(restore_worker))
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/workspace-api",
|
||||
post(replace_worker_workspace_api),
|
||||
)
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/completions",
|
||||
post(worker_completions),
|
||||
)
|
||||
.route("/v1/workers/{worker_id}/stop", post(stop_worker))
|
||||
.route("/v1/workers/{worker_id}/cancel", post(cancel_worker));
|
||||
);
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
let router = router
|
||||
.route("/v1/protocol/ws", get(runtime_protocol_ws))
|
||||
.route(
|
||||
"/v1/workers/{worker_id}/protocol/ws",
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_PROTOCOL_WS, &["GET"]),
|
||||
get(runtime_protocol_ws),
|
||||
)
|
||||
.route(
|
||||
remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_PROTOCOL_WS, &["GET"]),
|
||||
get(worker_protocol_ws),
|
||||
);
|
||||
|
||||
router
|
||||
.with_state(state.clone())
|
||||
.merge(runtime_management_api::router(state.clone()))
|
||||
.layer(middleware::from_fn_with_state(state, require_runtime_auth))
|
||||
}
|
||||
|
||||
@@ -372,11 +385,13 @@ struct RuntimeHttpConfigBundleAvailabilityQuery {
|
||||
digest: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
struct RuntimeHttpWorkersQuery {
|
||||
status: Option<RuntimeHttpWorkerStatusFilter>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum RuntimeHttpWorkerStatusFilter {
|
||||
@@ -670,6 +685,7 @@ async fn post_workspace_verification_acknowledgement(
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn get_runtime_ping(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(auth): Extension<RuntimeAuthContext>,
|
||||
@@ -712,6 +728,7 @@ async fn get_runtime_ping(
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn get_runtime(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
) -> RestResult<RuntimeHttpSummaryResponse> {
|
||||
@@ -791,6 +808,7 @@ async fn check_config_bundle(
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn list_workers(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1131,6 +1149,7 @@ async fn cleanup_working_directory(
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn get_worker(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1145,6 +1164,7 @@ async fn get_worker(
|
||||
Ok(Json(RuntimeHttpWorkerResponse { worker }))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn delete_worker(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1159,6 +1179,7 @@ async fn delete_worker(
|
||||
Ok(Json(RuntimeHttpWorkerDeleteResponse { worker }))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn create_worker(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1173,6 +1194,7 @@ async fn create_worker(
|
||||
Ok(Json(RuntimeHttpWorkerResponse { worker }))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn replace_worker_workspace_api(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1195,20 +1217,6 @@ async fn replace_worker_workspace_api(
|
||||
Ok(Json(RuntimeHttpWorkerResponse { worker }))
|
||||
}
|
||||
|
||||
async fn restore_worker(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
Path(worker_id): Path<String>,
|
||||
) -> RestResult<RuntimeHttpWorkerResponse> {
|
||||
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
|
||||
let worker = match auth_workspace_scope(&state, auth.as_ref())? {
|
||||
Some(scope) => state.runtime.restore_worker_scoped(&scope, &worker_ref),
|
||||
None => state.runtime.restore_worker(&worker_ref),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)?;
|
||||
Ok(Json(RuntimeHttpWorkerResponse { worker }))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
const RUNTIME_PROTOCOL_OUTBOUND_CAPACITY: usize = 256;
|
||||
|
||||
@@ -1557,7 +1565,6 @@ fn authorize_runtime_protocol_method(
|
||||
protocol::Method::NotifyTracked {
|
||||
notification_request_id,
|
||||
message,
|
||||
auto_run,
|
||||
..
|
||||
} => protocol::Method::NotifyTracked {
|
||||
source: transport_source.cloned().unwrap_or_else(|| {
|
||||
@@ -1567,7 +1574,6 @@ fn authorize_runtime_protocol_method(
|
||||
}),
|
||||
notification_request_id,
|
||||
message,
|
||||
auto_run,
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
@@ -1758,6 +1764,7 @@ fn protocol_error_event(message: impl Into<String>) -> protocol::Event {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn worker_retention_inventory(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1778,6 +1785,7 @@ async fn worker_retention_inventory(
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn execute_worker_retention(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1813,6 +1821,7 @@ async fn execute_worker_retention(
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn send_worker_input(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1829,6 +1838,7 @@ async fn send_worker_input(
|
||||
Ok(Json(RuntimeHttpWorkerInputResponse { ack }))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn worker_completions(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1965,6 +1975,7 @@ async fn delete_worker_uploaded_file(
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn stop_worker(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -1983,6 +1994,7 @@ async fn stop_worker(
|
||||
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn cancel_worker(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
auth: Option<Extension<RuntimeAuthContext>>,
|
||||
@@ -2080,8 +2092,9 @@ async fn require_runtime_auth(
|
||||
};
|
||||
match workspace_auth.verifier.verify(token, &expected) {
|
||||
Ok(verified) => {
|
||||
let is_verification = path_and_query == WORKSPACE_VERIFICATION_CHALLENGE_PATH
|
||||
|| path_and_query == WORKSPACE_VERIFICATION_ACK_PATH;
|
||||
let is_verification = path_and_query
|
||||
== runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|
||||
|| path_and_query == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK;
|
||||
if !is_verification {
|
||||
let record = match workspace_auth
|
||||
.verifications
|
||||
@@ -2130,7 +2143,8 @@ async fn require_runtime_auth(
|
||||
token_id: verified.token_id,
|
||||
expires_at: u64::try_from(verified.expires_at).unwrap_or(0),
|
||||
});
|
||||
return next.run(request).await;
|
||||
let context = request.extensions().get::<RuntimeAuthContext>().cloned();
|
||||
return runtime_management_api::scope_auth(context, next.run(request)).await;
|
||||
}
|
||||
Err(error) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
@@ -2146,7 +2160,8 @@ async fn require_runtime_auth(
|
||||
let workspace_bootstrap_request = request.method() == Method::POST
|
||||
&& matches!(
|
||||
request.uri().path(),
|
||||
WORKSPACE_VERIFICATION_CHALLENGE_PATH | WORKSPACE_VERIFICATION_ACK_PATH
|
||||
runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|
||||
| runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK
|
||||
);
|
||||
if state.workspace_auth.is_some() && !workspace_bootstrap_request {
|
||||
let local_token_matches = state
|
||||
@@ -2180,7 +2195,8 @@ async fn require_runtime_auth(
|
||||
expires_at: 0,
|
||||
});
|
||||
}
|
||||
next.run(request).await
|
||||
let context = request.extensions().get::<RuntimeAuthContext>().cloned();
|
||||
runtime_management_api::scope_auth(context, next.run(request)).await
|
||||
}
|
||||
|
||||
fn auth_workspace_scope(
|
||||
@@ -2217,7 +2233,8 @@ fn auth_workspace_scope(
|
||||
}
|
||||
|
||||
fn workspace_runtime_operation(method: &Method, path: &str) -> &'static str {
|
||||
if (path == WORKSPACE_VERIFICATION_CHALLENGE_PATH || path == WORKSPACE_VERIFICATION_ACK_PATH)
|
||||
if (path == runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|
||||
|| path == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK)
|
||||
&& *method == Method::POST
|
||||
{
|
||||
return WORKSPACE_VERIFICATION_OPERATION;
|
||||
@@ -2244,9 +2261,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
||||
if path == "/v1/workers" && *method == Method::POST {
|
||||
return Some("workers:create");
|
||||
}
|
||||
if (path == "/v1/working-directories/repository-access"
|
||||
|| path == "/v1/repository-refs/observe"
|
||||
|| path == SSH_HOST_KEY_PROBE_PATH)
|
||||
if (path == runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS
|
||||
|| path == runtime_api::RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE
|
||||
|| path == runtime_api::RUNTIME_ROUTE_SSH_PROBE)
|
||||
&& *method == Method::POST
|
||||
{
|
||||
return Some(SSH_HOST_KEY_PROBE_OPERATION);
|
||||
@@ -2256,9 +2273,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
||||
{
|
||||
return Some("workdirs:operate");
|
||||
}
|
||||
if path.starts_with("/v1/config-bundles")
|
||||
|| path.starts_with("/v1/workspace-prompt-projections")
|
||||
|| path.starts_with("/v1/working-directories")
|
||||
if path.starts_with(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES)
|
||||
|| path.starts_with(runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS)
|
||||
|| path.starts_with(runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORIES)
|
||||
{
|
||||
return Some("workers:create");
|
||||
}
|
||||
@@ -2271,7 +2288,7 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
||||
if path.ends_with("/stop") || path.ends_with("/cancel") {
|
||||
return Some("workers:stop");
|
||||
}
|
||||
if path == "/v1/protocol/ws" {
|
||||
if path == runtime_api::RUNTIME_ROUTE_PROTOCOL_WS {
|
||||
return Some("workers:list");
|
||||
}
|
||||
if path.ends_with("/protocol") || path.ends_with("/protocol/ws") {
|
||||
@@ -2466,6 +2483,11 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
RuntimeError::InvalidRequest(message)
|
||||
if message.contains("already created with a different fingerprint") =>
|
||||
{
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
RuntimeError::RuntimeStopped
|
||||
| RuntimeError::RuntimeStoreAlreadyOpen { .. }
|
||||
| RuntimeError::WorkerExecutionUnavailable { .. }
|
||||
@@ -2504,6 +2526,11 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
|
||||
RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected".to_string(),
|
||||
RuntimeError::WorkspaceOwnerMismatch { .. } => "workspace_owner_mismatch".to_string(),
|
||||
RuntimeError::LimitTooLarge { .. } => "limit_too_large".to_string(),
|
||||
RuntimeError::InvalidRequest(message)
|
||||
if message.contains("already created with a different fingerprint") =>
|
||||
{
|
||||
"worker_create_conflict".to_string()
|
||||
}
|
||||
RuntimeError::InvalidRequest(_) => "invalid_request".to_string(),
|
||||
RuntimeError::WorkingDirectory(diagnostic) => diagnostic.code.clone(),
|
||||
RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind".to_string(),
|
||||
@@ -2537,7 +2564,7 @@ pub enum RuntimeHttpServerError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::RuntimeIdentityMaterial;
|
||||
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus, WorkspaceApiRef};
|
||||
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkspaceApiRef};
|
||||
use crate::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
||||
};
|
||||
@@ -2547,6 +2574,7 @@ mod tests {
|
||||
WorkerExecutionSpawnResult,
|
||||
};
|
||||
use crate::management::RuntimeOptions;
|
||||
use crate::retention::{DiagnosticsDisposition, SessionDisposition};
|
||||
use crate::workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
|
||||
WorkspaceCapabilityClaims, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
|
||||
@@ -2556,16 +2584,111 @@ mod tests {
|
||||
use axum::body::to_bytes;
|
||||
use axum::http::Method;
|
||||
use manifest::{Scope, SharedScope};
|
||||
use runtime_api::WorkerRestoreState;
|
||||
use sha2::Digest as _;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestRuntimeAuthorizer(String);
|
||||
|
||||
impl runtime_api::client_support::RequestAuthorizer for TestRuntimeAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
_request: runtime_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> Result<
|
||||
runtime_api::client_support::framework::header::HeaderMap,
|
||||
runtime_api::client_support::AuthorizationError,
|
||||
> {
|
||||
let mut headers = runtime_api::client_support::framework::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
runtime_api::client_support::framework::header::AUTHORIZATION,
|
||||
format!("Bearer {}", self.0)
|
||||
.parse()
|
||||
.map_err(|_| runtime_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
use workdir::{
|
||||
GrepOutputMode, GrepRequest, LocalWorkdirSession, StatRequest, Workdir, WorkdirPath,
|
||||
WorkdirSessionCapabilities,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn generated_runtime_routes_are_classified_by_permission_authority() {
|
||||
for operation in <runtime_api::RuntimeApiMetadata as runtime_api::ApiContract>::OPERATIONS {
|
||||
let method = match operation.method {
|
||||
runtime_api::HttpMethod::Get => Method::GET,
|
||||
runtime_api::HttpMethod::Post => Method::POST,
|
||||
runtime_api::HttpMethod::Put => Method::PUT,
|
||||
runtime_api::HttpMethod::Patch => Method::PATCH,
|
||||
runtime_api::HttpMethod::Delete => Method::DELETE,
|
||||
runtime_api::HttpMethod::Head | runtime_api::HttpMethod::Options => {
|
||||
panic!("unexpected management contract method")
|
||||
}
|
||||
};
|
||||
let path = operation.path.replace("{worker_id}", "worker-1");
|
||||
let permission = required_runtime_permission(&method, &path);
|
||||
if operation.operation_id == "runtime_summary" {
|
||||
assert_eq!(
|
||||
permission, None,
|
||||
"{:?} {}",
|
||||
operation.method, operation.path
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
permission.is_some(),
|
||||
"unclassified generated route: {:?} {}",
|
||||
operation.method,
|
||||
operation.path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_route_inventory_covers_every_handwritten_router_path() {
|
||||
let inventoried = runtime_api::REMAINING_RUNTIME_ROUTES
|
||||
.iter()
|
||||
.map(|route| route.path)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for path in [
|
||||
runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE,
|
||||
runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK,
|
||||
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES,
|
||||
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY,
|
||||
runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS,
|
||||
runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORIES,
|
||||
runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS,
|
||||
runtime_api::RUNTIME_ROUTE_SSH_PROBE,
|
||||
runtime_api::RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE,
|
||||
runtime_api::RUNTIME_ROUTE_WORKDIR_SESSIONS,
|
||||
runtime_api::RUNTIME_ROUTE_WORKDIR_SESSION_OPERATIONS,
|
||||
runtime_api::RUNTIME_ROUTE_WORKDIR_SESSION,
|
||||
runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY,
|
||||
runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENTS,
|
||||
runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENT,
|
||||
runtime_api::RUNTIME_ROUTE_PROTOCOL_WS,
|
||||
runtime_api::RUNTIME_ROUTE_WORKER_PROTOCOL_WS,
|
||||
] {
|
||||
assert!(
|
||||
inventoried.contains(path),
|
||||
"missing handwritten route inventory for {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
|
||||
let runtime = Runtime::new_memory();
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
runtime
|
||||
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
|
||||
"builtin:coder".to_string(),
|
||||
)))
|
||||
.unwrap();
|
||||
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let workspace_public_key =
|
||||
@@ -2598,7 +2721,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let app = runtime_http_router_with_workspace_auth(
|
||||
runtime,
|
||||
runtime.clone(),
|
||||
None,
|
||||
WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
@@ -2631,7 +2754,7 @@ mod tests {
|
||||
worker_id: None,
|
||||
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
|
||||
method: "POST".to_string(),
|
||||
path_and_query: WORKSPACE_VERIFICATION_CHALLENGE_PATH.to_string(),
|
||||
path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE.to_string(),
|
||||
body_digest: workspace_request_body_digest(&body),
|
||||
iat: unix_now_i64(),
|
||||
exp: challenge.expires_at,
|
||||
@@ -2645,7 +2768,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(WORKSPACE_VERIFICATION_CHALLENGE_PATH)
|
||||
.uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
@@ -2691,7 +2814,7 @@ mod tests {
|
||||
};
|
||||
let acknowledgement_body = serde_json::to_vec(&acknowledgement).unwrap();
|
||||
let acknowledgement_claims = WorkspaceCapabilityClaims {
|
||||
path_and_query: WORKSPACE_VERIFICATION_ACK_PATH.to_string(),
|
||||
path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK.to_string(),
|
||||
body_digest: workspace_request_body_digest(&acknowledgement_body),
|
||||
jti: "ack-token".to_string(),
|
||||
..claims
|
||||
@@ -2706,7 +2829,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(WORKSPACE_VERIFICATION_ACK_PATH)
|
||||
.uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK)
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {acknowledgement_token}"),
|
||||
@@ -2724,7 +2847,7 @@ mod tests {
|
||||
for authorization in [None, Some("Bearer malformed")] {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/config-bundles");
|
||||
.uri(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES);
|
||||
if let Some(authorization) = authorization {
|
||||
request = request.header(header::AUTHORIZATION, authorization);
|
||||
}
|
||||
@@ -2750,6 +2873,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
@@ -2762,6 +2886,64 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let mut retention_create = task_request("retention-scope");
|
||||
retention_create.workspace_api = Some(WorkspaceApiRef {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
base_url: "https://backend.test".to_string(),
|
||||
});
|
||||
let retention_worker_id = retention_create.worker_id.clone();
|
||||
runtime.create_worker(retention_create).unwrap();
|
||||
let retention_path = format!("/v1/workers/{retention_worker_id}/retention/execute");
|
||||
let retention_request = WorkerRetentionExecutionRequest {
|
||||
operation_id: "retention-op".to_string(),
|
||||
input_fingerprint: "fingerprint".to_string(),
|
||||
archive_id: None,
|
||||
workspace_id: "workspace-b".to_string(),
|
||||
source_runtime_id: "runtime-a".to_string(),
|
||||
worker_id: retention_worker_id,
|
||||
expected_worker_revision: "revision".to_string(),
|
||||
source_created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||
removed_at: "2025-01-02T00:00:00Z".to_string(),
|
||||
effective_profile: None,
|
||||
retention_class: None,
|
||||
policy_id: "policy".to_string(),
|
||||
policy_revision: 1,
|
||||
session_disposition: SessionDisposition::Purge,
|
||||
diagnostics_disposition: DiagnosticsDisposition::Retain,
|
||||
};
|
||||
let retention_body = serde_json::to_vec(&retention_request).unwrap();
|
||||
let retention_claims = WorkspaceCapabilityClaims {
|
||||
operation: required_runtime_permission(&Method::POST, &retention_path)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
method: "POST".to_string(),
|
||||
path_and_query: retention_path.clone(),
|
||||
worker_id: Some(retention_worker_id.to_string()),
|
||||
body_digest: workspace_request_body_digest(&retention_body),
|
||||
jti: "retention-scope-token".to_string(),
|
||||
..ping_claims
|
||||
};
|
||||
let retention_token = issue_workspace_capability_token(
|
||||
&workspace_identity.signing_key().unwrap(),
|
||||
&retention_claims,
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(retention_path)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {retention_token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(retention_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
||||
assert_eq!(error.error.code, "worker_not_found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2804,7 +2986,6 @@ mod tests {
|
||||
let wire = serde_json::to_string(&protocol::Method::NotifyTracked {
|
||||
notification_request_id: "notification-1".into(),
|
||||
message: "hello".into(),
|
||||
auto_run: true,
|
||||
source: protocol::AuthenticatedInputSource::Account {
|
||||
account_id: "forged".into(),
|
||||
},
|
||||
@@ -2919,15 +3100,21 @@ mod tests {
|
||||
#[test]
|
||||
fn workdir_routes_require_dedicated_operation_permission() {
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
|
||||
required_runtime_permission(
|
||||
&Method::POST,
|
||||
runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS,
|
||||
),
|
||||
Some("workdirs:operate")
|
||||
);
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, "/v1/repository-refs/observe"),
|
||||
required_runtime_permission(
|
||||
&Method::POST,
|
||||
runtime_api::RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE
|
||||
),
|
||||
Some("workdirs:operate")
|
||||
);
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH),
|
||||
required_runtime_permission(&Method::POST, runtime_api::RUNTIME_ROUTE_SSH_PROBE),
|
||||
Some(SSH_HOST_KEY_PROBE_OPERATION)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -3242,6 +3429,132 @@ mod tests {
|
||||
let token = "local-token";
|
||||
let app = runtime_http_router(runtime.clone(), token.to_string());
|
||||
|
||||
let malformed_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/workers")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::from("{"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(malformed_response.status(), StatusCode::BAD_REQUEST);
|
||||
let malformed: RuntimeHttpErrorResponse = read_json(malformed_response).await;
|
||||
assert_eq!(malformed.error.code, "invalid_json");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server_app = app.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, server_app).await.unwrap();
|
||||
});
|
||||
let client = runtime_api::RuntimeApiClient::builder(&format!("http://{address}"))
|
||||
.unwrap()
|
||||
.authorizer(TestRuntimeAuthorizer(token.to_string()))
|
||||
.build()
|
||||
.unwrap();
|
||||
let generated_request: runtime_api::CreateWorkerRequest = serde_json::from_value(
|
||||
serde_json::to_value(task_request("generated-roundtrip")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let generated_worker_id = generated_request.worker_id.to_string();
|
||||
let mut conflicting_request = generated_request.clone();
|
||||
conflicting_request.create_fingerprint = "different-fingerprint".to_string();
|
||||
let generated = client.create_worker(generated_request).await.unwrap();
|
||||
assert_eq!(generated.worker.worker_id.to_string(), generated_worker_id);
|
||||
let generated_input: runtime_api::WorkerInput = serde_json::from_value(
|
||||
serde_json::to_value(WorkerInput::user("generated client input")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.send_worker_input(generated_worker_id.clone(), generated_input)
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.stop_worker(
|
||||
generated_worker_id.clone(),
|
||||
runtime_api::WorkerLifecycleRequest::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.restore_worker(
|
||||
generated_worker_id.clone(),
|
||||
runtime_api::EmptyObjectRequest::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let conflict = client.create_worker(conflicting_request).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
&conflict,
|
||||
runtime_api::client_support::ClientError::Public { status, .. }
|
||||
if *status == StatusCode::CONFLICT
|
||||
),
|
||||
"{conflict:?}"
|
||||
);
|
||||
client
|
||||
.stop_worker(
|
||||
generated_worker_id.clone(),
|
||||
runtime_api::WorkerLifecycleRequest::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.delete_worker(generated_worker_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let missing = client
|
||||
.get_worker(generated_worker_id.clone())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
missing,
|
||||
runtime_api::client_support::ClientError::Public { status, .. }
|
||||
if status == StatusCode::NOT_FOUND
|
||||
));
|
||||
let unauthorized = runtime_api::RuntimeApiClient::builder(&format!("http://{address}"))
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap()
|
||||
.list_workers(runtime_api::WorkerListQuery::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
unauthorized,
|
||||
runtime_api::client_support::ClientError::Public { status, .. }
|
||||
if status == StatusCode::UNAUTHORIZED
|
||||
));
|
||||
let forbidden_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v1/ping")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(forbidden_response.status(), StatusCode::FORBIDDEN);
|
||||
let bounded_client = runtime_api::RuntimeApiClient::builder(&format!("http://{address}"))
|
||||
.unwrap()
|
||||
.authorizer(TestRuntimeAuthorizer(token.to_string()))
|
||||
.response_body_limit(1)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
bounded_client.runtime_summary().await.unwrap_err(),
|
||||
runtime_api::client_support::ClientError::Failure(
|
||||
runtime_api::client_support::ClientFailure::ResponseTooLarge { .. }
|
||||
)
|
||||
));
|
||||
server.abort();
|
||||
|
||||
let response = authed_json_request(
|
||||
app.clone(),
|
||||
Method::POST,
|
||||
@@ -3322,8 +3635,12 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let restored: RuntimeHttpWorkerResponse = read_json(response).await;
|
||||
assert_eq!(restored.worker.status, WorkerStatus::Idle);
|
||||
let restored: runtime_api::WorkerRestoreResponse = read_json(response).await;
|
||||
assert_eq!(restored.state, WorkerRestoreState::Accepted);
|
||||
assert_eq!(
|
||||
restored.worker.unwrap().status,
|
||||
runtime_api::WorkerStatus::Idle
|
||||
);
|
||||
|
||||
let response = authed_empty_request(
|
||||
app.clone(),
|
||||
@@ -3383,11 +3700,11 @@ mod tests {
|
||||
#[test]
|
||||
fn ssh_probe_uses_workdir_operation_capability() {
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH),
|
||||
required_runtime_permission(&Method::POST, runtime_api::RUNTIME_ROUTE_SSH_PROBE),
|
||||
Some(SSH_HOST_KEY_PROBE_OPERATION)
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_runtime_operation(&Method::POST, SSH_HOST_KEY_PROBE_PATH),
|
||||
workspace_runtime_operation(&Method::POST, runtime_api::RUNTIME_ROUTE_SSH_PROBE),
|
||||
SSH_HOST_KEY_PROBE_OPERATION
|
||||
);
|
||||
}
|
||||
@@ -3404,7 +3721,7 @@ mod tests {
|
||||
let response = authed_json_request(
|
||||
app,
|
||||
Method::POST,
|
||||
SSH_HOST_KEY_PROBE_PATH,
|
||||
runtime_api::RUNTIME_ROUTE_SSH_PROBE,
|
||||
token,
|
||||
&SshHostKeyProbeRequest {
|
||||
hostname: "-oProxyCommand=malicious".to_string(),
|
||||
@@ -3469,8 +3786,14 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let response =
|
||||
authed_json_request(app, Method::POST, SSH_HOST_KEY_PROBE_PATH, token, &body).await;
|
||||
let response = authed_json_request(
|
||||
app,
|
||||
Method::POST,
|
||||
runtime_api::RUNTIME_ROUTE_SSH_PROBE,
|
||||
token,
|
||||
&body,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(recorded_arguments).unwrap(),
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
use super::*;
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
const MANAGEMENT_BODY_LIMIT: usize = 8 * 1024 * 1024;
|
||||
|
||||
// Axum request extensions remain the authorization authority. The generated adapter deliberately
|
||||
// has no authentication model, so the verified extension is scoped across its service call.
|
||||
tokio::task_local! {
|
||||
static AUTH_CONTEXT: Option<RuntimeAuthContext>;
|
||||
}
|
||||
|
||||
pub(super) async fn scope_auth<F>(context: Option<RuntimeAuthContext>, future: F) -> F::Output
|
||||
where
|
||||
F: Future,
|
||||
{
|
||||
AUTH_CONTEXT.scope(context, future).await
|
||||
}
|
||||
|
||||
fn auth_extension() -> Option<Extension<RuntimeAuthContext>> {
|
||||
AUTH_CONTEXT
|
||||
.try_with(Clone::clone)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(Extension)
|
||||
}
|
||||
|
||||
fn required_auth() -> Result<Extension<RuntimeAuthContext>, runtime_api::RuntimeApiError> {
|
||||
auth_extension().ok_or_else(|| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"workspace_scope_required",
|
||||
"Runtime operation requires an authorization context",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RuntimeManagementApi {
|
||||
state: RuntimeHttpState,
|
||||
}
|
||||
|
||||
pub(super) fn router(state: RuntimeHttpState) -> Router {
|
||||
runtime_api::RuntimeApiAxum::router(RuntimeManagementApi { state })
|
||||
.layer(axum::extract::DefaultBodyLimit::max(MANAGEMENT_BODY_LIMIT))
|
||||
.layer(middleware::from_fn(runtime_api_wire_compatibility))
|
||||
}
|
||||
|
||||
async fn runtime_api_wire_compatibility(
|
||||
mut request: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let is_json_request = !matches!(*request.method(), Method::GET | Method::HEAD);
|
||||
let is_query_request = request.method() == Method::GET;
|
||||
if request.uri().path() == "/v1/ping" && !request.headers().contains_key("x-yoi-workspace-id") {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"runtime_ping_workspace_scope_required",
|
||||
"Runtime ping requires an authenticated Workspace scope",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if is_json_request {
|
||||
if !request.headers().contains_key(header::CONTENT_TYPE) {
|
||||
request.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
}
|
||||
let (parts, body) = request.into_parts();
|
||||
let body = match axum::body::to_bytes(body, MANAGEMENT_BODY_LIMIT).await {
|
||||
Ok(body) if body.is_empty() => Bytes::from_static(b"{}"),
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"request_body_too_large",
|
||||
"request body exceeds Runtime management limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
request = axum::extract::Request::from_parts(parts, Body::from(body));
|
||||
}
|
||||
let response = next.run(request).await;
|
||||
let is_json_error = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.starts_with("application/json"));
|
||||
if matches!(
|
||||
response.status(),
|
||||
StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY
|
||||
) && !is_json_error
|
||||
{
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
if is_query_request {
|
||||
"invalid_query"
|
||||
} else {
|
||||
"invalid_json"
|
||||
},
|
||||
if is_query_request {
|
||||
"request query parameters are invalid"
|
||||
} else {
|
||||
"request body must be valid JSON"
|
||||
},
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn request<T, U>(value: T) -> Result<U, runtime_api::RuntimeApiError>
|
||||
where
|
||||
T: Serialize,
|
||||
U: serde::de::DeserializeOwned,
|
||||
{
|
||||
serde_json::to_value(value)
|
||||
.and_then(serde_json::from_value)
|
||||
.map_err(|error| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::BAD_REQUEST.as_u16(),
|
||||
"invalid_request",
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn response<T, U>(value: T) -> Result<U, runtime_api::RuntimeApiError>
|
||||
where
|
||||
T: Serialize,
|
||||
U: serde::de::DeserializeOwned,
|
||||
{
|
||||
serde_json::to_value(value)
|
||||
.and_then(serde_json::from_value)
|
||||
.map_err(|error| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
||||
"runtime_response_conversion_failed",
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn api_error(error: RuntimeHttpRestError) -> runtime_api::RuntimeApiError {
|
||||
runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message)
|
||||
}
|
||||
|
||||
impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
async fn ping(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
) -> Result<runtime_api::RuntimePingResponse, runtime_api::RuntimeApiError> {
|
||||
let Extension(auth) = required_auth()?;
|
||||
if workspace_id != auth.workspace_id {
|
||||
return Err(runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"runtime_ping_workspace_scope_mismatch",
|
||||
"Runtime ping Workspace scope does not match the authenticated capability",
|
||||
));
|
||||
}
|
||||
let runtime_id = self
|
||||
.state
|
||||
.workspace_auth
|
||||
.as_ref()
|
||||
.map(|auth| auth.signer.runtime_id().trim())
|
||||
.filter(|runtime_id| !runtime_id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE.as_u16(),
|
||||
"runtime_ping_identity_unavailable",
|
||||
"Runtime ping identity is not configured",
|
||||
)
|
||||
})?;
|
||||
Ok(runtime_api::RuntimePingResponse {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION,
|
||||
})
|
||||
}
|
||||
|
||||
async fn runtime_summary(
|
||||
&self,
|
||||
) -> Result<runtime_api::RuntimeSummaryResponse, runtime_api::RuntimeApiError> {
|
||||
let runtime = self
|
||||
.state
|
||||
.runtime
|
||||
.summary()
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let runtime = response(runtime)?;
|
||||
Ok(runtime_api::RuntimeSummaryResponse { runtime })
|
||||
}
|
||||
|
||||
async fn list_workers(
|
||||
&self,
|
||||
query: runtime_api::WorkerListQuery,
|
||||
) -> Result<runtime_api::WorkersResponse, runtime_api::RuntimeApiError> {
|
||||
let scope =
|
||||
auth_workspace_scope(&self.state, auth_extension().as_ref()).map_err(api_error)?;
|
||||
let workers = match (query.status, scope.as_ref()) {
|
||||
(Some(runtime_api::WorkerStatusFilter::Stopped), Some(scope)) => {
|
||||
self.state.runtime.list_stopped_workers_scoped(scope)
|
||||
}
|
||||
(Some(runtime_api::WorkerStatusFilter::Stopped), None) => {
|
||||
self.state.runtime.list_stopped_workers()
|
||||
}
|
||||
(None, Some(scope)) => self.state.runtime.list_workers_scoped(scope),
|
||||
(None, None) => self.state.runtime.list_workers(),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let workers = response(workers)?;
|
||||
Ok(runtime_api::WorkersResponse { workers })
|
||||
}
|
||||
|
||||
async fn get_worker(
|
||||
&self,
|
||||
worker_id: String,
|
||||
) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => self.state.runtime.worker_detail_scoped(&scope, &worker_ref),
|
||||
None => self.state.runtime.worker_detail(&worker_ref),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let worker = response(worker)?;
|
||||
Ok(runtime_api::WorkerResponse { worker })
|
||||
}
|
||||
|
||||
async fn worker_session(
|
||||
&self,
|
||||
worker_id: String,
|
||||
request: runtime_api::WorkerSessionRequest,
|
||||
) -> Result<runtime_api::WorkerSessionAvailability, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
.ok_or_else(|| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"runtime_worker_session_scope_required",
|
||||
"Worker Session observation requires a Workspace-scoped capability",
|
||||
)
|
||||
})?;
|
||||
if scope.workspace_id != request.workspace_id {
|
||||
return Err(runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"runtime_worker_session_workspace_scope_mismatch",
|
||||
"Worker Session Workspace scope does not match the authenticated capability",
|
||||
));
|
||||
}
|
||||
self.state
|
||||
.runtime
|
||||
.worker_session_scoped(&scope, &worker_ref)
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)
|
||||
}
|
||||
|
||||
async fn create_worker(
|
||||
&self,
|
||||
value: runtime_api::CreateWorkerRequest,
|
||||
) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> {
|
||||
let request: CreateWorkerRequest = request(value)?;
|
||||
let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => self.state.runtime.create_worker_scoped(&scope, request),
|
||||
None => self.state.runtime.create_worker(request),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let worker = response(worker)?;
|
||||
Ok(runtime_api::WorkerResponse { worker })
|
||||
}
|
||||
|
||||
async fn delete_worker(
|
||||
&self,
|
||||
worker_id: String,
|
||||
) -> Result<runtime_api::WorkerDeleteResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => self.state.runtime.delete_worker_scoped(&scope, &worker_ref),
|
||||
None => self.state.runtime.delete_worker(&worker_ref),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let worker = response(worker)?;
|
||||
Ok(runtime_api::WorkerDeleteResponse { worker })
|
||||
}
|
||||
|
||||
async fn send_worker_input(
|
||||
&self,
|
||||
worker_id: String,
|
||||
input: runtime_api::WorkerInput,
|
||||
) -> Result<runtime_api::WorkerInputResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let input: WorkerInput = request(input)?;
|
||||
let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => self
|
||||
.state
|
||||
.runtime
|
||||
.send_input_scoped(&scope, &worker_ref, input),
|
||||
None => self.state.runtime.send_input(&worker_ref, input),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let ack = response(ack)?;
|
||||
Ok(runtime_api::WorkerInputResponse { ack })
|
||||
}
|
||||
|
||||
async fn stop_worker(
|
||||
&self,
|
||||
worker_id: String,
|
||||
value: runtime_api::WorkerLifecycleRequest,
|
||||
) -> Result<runtime_api::WorkerLifecycleResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => self
|
||||
.state
|
||||
.runtime
|
||||
.stop_worker_scoped(&scope, &worker_ref, value.reason),
|
||||
None => self.state.runtime.stop_worker(&worker_ref, value.reason),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let ack = response(ack)?;
|
||||
Ok(runtime_api::WorkerLifecycleResponse { ack })
|
||||
}
|
||||
|
||||
async fn cancel_worker(
|
||||
&self,
|
||||
worker_id: String,
|
||||
value: runtime_api::WorkerLifecycleRequest,
|
||||
) -> Result<runtime_api::WorkerLifecycleResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => {
|
||||
self.state
|
||||
.runtime
|
||||
.cancel_worker_scoped(&scope, &worker_ref, value.reason)
|
||||
}
|
||||
None => self.state.runtime.cancel_worker(&worker_ref, value.reason),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let ack = response(ack)?;
|
||||
Ok(runtime_api::WorkerLifecycleResponse { ack })
|
||||
}
|
||||
|
||||
async fn restore_worker(
|
||||
&self,
|
||||
worker_id: String,
|
||||
_request: runtime_api::EmptyObjectRequest,
|
||||
) -> Result<runtime_api::WorkerRestoreResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let result = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => self
|
||||
.state
|
||||
.runtime
|
||||
.restore_worker_operation_scoped(&scope, &worker_ref),
|
||||
None => self.state.runtime.restore_worker_operation(&worker_ref),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
Ok(runtime_api::WorkerRestoreResponse {
|
||||
state: match result.state {
|
||||
server_api::WorkerRestoreState::Accepted => {
|
||||
runtime_api::WorkerRestoreState::Accepted
|
||||
}
|
||||
server_api::WorkerRestoreState::Rejected => {
|
||||
runtime_api::WorkerRestoreState::Rejected
|
||||
}
|
||||
server_api::WorkerRestoreState::RolledBack => {
|
||||
runtime_api::WorkerRestoreState::RolledBack
|
||||
}
|
||||
server_api::WorkerRestoreState::ReconciliationRequired => {
|
||||
runtime_api::WorkerRestoreState::ReconciliationRequired
|
||||
}
|
||||
},
|
||||
worker: result.worker.map(response).transpose()?,
|
||||
reason_code: result.reason_code,
|
||||
message: result.message,
|
||||
})
|
||||
}
|
||||
|
||||
async fn replace_worker_workspace_api(
|
||||
&self,
|
||||
worker_id: String,
|
||||
value: runtime_api::WorkerWorkspaceApiRequest,
|
||||
) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> {
|
||||
let workspace_api: WorkspaceApiRef = request(value.workspace_api)?;
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => self.state.runtime.replace_worker_workspace_api_scoped(
|
||||
&scope,
|
||||
&worker_ref,
|
||||
workspace_api,
|
||||
),
|
||||
None => self
|
||||
.state
|
||||
.runtime
|
||||
.replace_worker_workspace_api(&worker_ref, workspace_api),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let worker = response(worker)?;
|
||||
Ok(runtime_api::WorkerResponse { worker })
|
||||
}
|
||||
|
||||
async fn complete_worker_arguments(
|
||||
&self,
|
||||
worker_id: String,
|
||||
value: runtime_api::CompletionRequest,
|
||||
) -> Result<runtime_api::CompletionResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let kind = value.kind;
|
||||
let prefix = value.prefix;
|
||||
let entries = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => {
|
||||
self.state
|
||||
.runtime
|
||||
.worker_completions_scoped(&scope, &worker_ref, kind, &prefix)
|
||||
}
|
||||
None => self
|
||||
.state
|
||||
.runtime
|
||||
.worker_completions(&worker_ref, kind, &prefix),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let entries = response(entries)?;
|
||||
Ok(runtime_api::CompletionResponse {
|
||||
kind,
|
||||
prefix,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
async fn retention_inventory(
|
||||
&self,
|
||||
worker_id: String,
|
||||
) -> Result<runtime_api::WorkerRetentionInventory, runtime_api::RuntimeApiError> {
|
||||
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
.ok_or_else(|| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"workspace_scope_required",
|
||||
"Worker retention inventory requires workspace-scoped authorization",
|
||||
)
|
||||
})?;
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let inventory = self
|
||||
.state
|
||||
.runtime
|
||||
.worker_retention_inventory(&scope.workspace_id, &worker_ref)
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
response(inventory)
|
||||
}
|
||||
|
||||
async fn execute_retention(
|
||||
&self,
|
||||
worker_id: String,
|
||||
value: runtime_api::WorkerRetentionExecutionRequest,
|
||||
) -> Result<runtime_api::WorkerRetentionExecutionResult, runtime_api::RuntimeApiError> {
|
||||
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
.ok_or_else(|| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::FORBIDDEN.as_u16(),
|
||||
"workspace_scope_required",
|
||||
"Worker retention execution requires workspace-scoped authorization",
|
||||
)
|
||||
})?;
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let request: WorkerRetentionExecutionRequest = request(value)?;
|
||||
if request.workspace_id != scope.workspace_id {
|
||||
return Err(runtime_api::RuntimeApiError::new(
|
||||
StatusCode::NOT_FOUND.as_u16(),
|
||||
"worker_not_found",
|
||||
"worker does not belong to the authenticated Workspace",
|
||||
));
|
||||
}
|
||||
if request.worker_id != worker_ref.worker_id {
|
||||
return Err(runtime_api::RuntimeApiError::new(
|
||||
StatusCode::BAD_REQUEST.as_u16(),
|
||||
"worker_id_mismatch",
|
||||
"Worker retention request worker_id does not match route worker_id",
|
||||
));
|
||||
}
|
||||
let result = self
|
||||
.state
|
||||
.runtime
|
||||
.execute_worker_retention(&request)
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
response(result)
|
||||
}
|
||||
}
|
||||
@@ -1355,8 +1355,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() {
|
||||
use server_api::WorkspacePublicIdentityBundle;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use workspace_api::WorkspacePublicIdentityBundle;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let identity = RuntimeIdentityMaterial::generate("WK-1").unwrap();
|
||||
|
||||
@@ -15,7 +15,8 @@ use crate::execution::WorkerExecutionRestoreRequest;
|
||||
use crate::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle,
|
||||
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest,
|
||||
WorkerExecutionSpawnResult, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult,
|
||||
WorkerExecutionSpawnResult, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest,
|
||||
WorkspaceConfigFetchResult,
|
||||
};
|
||||
#[cfg(feature = "fs-store")]
|
||||
use crate::fs_store::{
|
||||
@@ -45,6 +46,7 @@ use protocol::subscription::{
|
||||
SubscriptionWorkerState,
|
||||
};
|
||||
use protocol::{Event, Method};
|
||||
use server_api::WorkerRestoreState;
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(feature = "ws-server")]
|
||||
use std::collections::VecDeque;
|
||||
@@ -83,6 +85,47 @@ enum WorkerRestoreMode {
|
||||
Automatic,
|
||||
}
|
||||
|
||||
/// Runtime-internal restore result consumed by both embedded and HTTP
|
||||
/// providers. Diagnostics are stable and path-free; detailed backend errors
|
||||
/// remain in Runtime logs rather than crossing the API boundary.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeWorkerRestoreResult {
|
||||
pub state: WorkerRestoreState,
|
||||
pub worker: Option<WorkerDetail>,
|
||||
pub reason_code: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl RuntimeWorkerRestoreResult {
|
||||
fn accepted(worker: WorkerDetail) -> Self {
|
||||
Self {
|
||||
state: WorkerRestoreState::Accepted,
|
||||
worker: Some(worker),
|
||||
reason_code: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(state: WorkerRestoreState, reason_code: &'static str, message: &'static str) -> Self {
|
||||
Self {
|
||||
state,
|
||||
worker: None,
|
||||
reason_code: Some(reason_code.to_string()),
|
||||
message: Some(message.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_legacy_result(self) -> Result<WorkerDetail, RuntimeError> {
|
||||
match (self.state, self.worker) {
|
||||
(WorkerRestoreState::Accepted, Some(worker)) => Ok(worker),
|
||||
_ => Err(RuntimeError::InvalidRequest(
|
||||
self.message
|
||||
.unwrap_or_else(|| "Worker restore did not commit".to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeSubscriptionUpdate {
|
||||
pub subject_revision: u64,
|
||||
@@ -1047,6 +1090,7 @@ impl Runtime {
|
||||
working_directory,
|
||||
} => (handle, worker_state, working_directory),
|
||||
WorkerExecutionSpawnResult::Rejected(result)
|
||||
| WorkerExecutionSpawnResult::RolledBack(result)
|
||||
| WorkerExecutionSpawnResult::Errored(result) => {
|
||||
self.rollback_failed_create(&worker_ref)?;
|
||||
return Err(RuntimeError::WorkerExecutionRejected {
|
||||
@@ -1057,6 +1101,16 @@ impl Runtime {
|
||||
result,
|
||||
});
|
||||
}
|
||||
WorkerExecutionSpawnResult::ReconciliationRequired { result, .. } => {
|
||||
self.rollback_failed_create(&worker_ref)?;
|
||||
return Err(RuntimeError::WorkerExecutionRejected {
|
||||
worker_id: worker_ref.worker_id.clone(),
|
||||
operation: result.operation,
|
||||
outcome: result.outcome,
|
||||
message: result.message_or_default(),
|
||||
result,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(mut initial_input) = {
|
||||
@@ -1371,13 +1425,53 @@ impl Runtime {
|
||||
Ok(state.worker(worker_ref)?.detail())
|
||||
}
|
||||
|
||||
/// Observe a Worker's live protocol availability or its retained Session.
|
||||
/// The same lifecycle lock used by stop/restore fences the decision and read.
|
||||
pub fn worker_session_scoped(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<runtime_api::WorkerSessionAvailability, RuntimeError> {
|
||||
let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?;
|
||||
let _operation_guard = operation_lock
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::StatePoisoned)?;
|
||||
self.ensure_worker_in_workspace(scope, worker_ref)?;
|
||||
let backend = {
|
||||
let state = self.lock()?;
|
||||
let worker = state.worker(worker_ref)?;
|
||||
if worker.execution_handle.is_some() && worker.status.is_active() {
|
||||
return Ok(runtime_api::WorkerSessionAvailability::LiveProtocol);
|
||||
}
|
||||
state.execution_backend.clone()
|
||||
};
|
||||
Ok(match backend {
|
||||
Some(backend) => backend.worker_session(WorkerSessionObservationRequest {
|
||||
worker_ref: worker_ref.clone(),
|
||||
}),
|
||||
None => runtime_api::WorkerSessionAvailability::Unavailable {
|
||||
reason: runtime_api::WorkerSessionUnavailableReason::StorageUnavailable,
|
||||
message: "retained session storage is unavailable".to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Attach a live execution through a workspace-scoped Runtime authorization context.
|
||||
pub fn restore_worker_scoped(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<WorkerDetail, RuntimeError> {
|
||||
self.restore_worker_with_scope(worker_ref, Some(scope))
|
||||
self.restore_worker_operation_scoped(scope, worker_ref)?
|
||||
.into_legacy_result()
|
||||
}
|
||||
|
||||
pub fn restore_worker_operation_scoped(
|
||||
&self,
|
||||
scope: &RuntimeWorkspaceScope,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<RuntimeWorkerRestoreResult, RuntimeError> {
|
||||
self.restore_worker_operation_with_scope(worker_ref, Some(scope))
|
||||
}
|
||||
|
||||
/// Attach a live execution to a persisted Worker definition.
|
||||
@@ -1387,14 +1481,22 @@ impl Runtime {
|
||||
/// converges on its already-installed execution rather than spawning another
|
||||
/// controller.
|
||||
pub fn restore_worker(&self, worker_ref: &WorkerRef) -> Result<WorkerDetail, RuntimeError> {
|
||||
self.restore_worker_with_scope(worker_ref, None)
|
||||
self.restore_worker_operation(worker_ref)?
|
||||
.into_legacy_result()
|
||||
}
|
||||
|
||||
fn restore_worker_with_scope(
|
||||
pub fn restore_worker_operation(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<RuntimeWorkerRestoreResult, RuntimeError> {
|
||||
self.restore_worker_operation_with_scope(worker_ref, None)
|
||||
}
|
||||
|
||||
fn restore_worker_operation_with_scope(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
scope: Option<&RuntimeWorkspaceScope>,
|
||||
) -> Result<WorkerDetail, RuntimeError> {
|
||||
) -> Result<RuntimeWorkerRestoreResult, RuntimeError> {
|
||||
let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?;
|
||||
let _operation_guard = operation_lock
|
||||
.lock()
|
||||
@@ -1409,58 +1511,54 @@ impl Runtime {
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
mode: WorkerRestoreMode,
|
||||
) -> Result<WorkerDetail, RuntimeError> {
|
||||
) -> Result<RuntimeWorkerRestoreResult, RuntimeError> {
|
||||
let (backend, request) = {
|
||||
let mut state = self.lock()?;
|
||||
let state = self.lock()?;
|
||||
state.ensure_running()?;
|
||||
let (worker_request, previous_working_directory) = {
|
||||
let worker = state.worker(worker_ref)?;
|
||||
if worker.execution_handle.is_some() {
|
||||
if worker.status.is_active() {
|
||||
return Ok(worker.detail());
|
||||
}
|
||||
return Err(RuntimeError::WorkerExecutionUnavailable {
|
||||
worker_id: worker_ref.worker_id,
|
||||
message: "previous execution cleanup has not completed".to_string(),
|
||||
});
|
||||
let worker = state.worker(worker_ref)?;
|
||||
if worker.execution_handle.is_some() {
|
||||
if worker.status.is_active() {
|
||||
return Ok(RuntimeWorkerRestoreResult::accepted(worker.detail()));
|
||||
}
|
||||
match mode {
|
||||
WorkerRestoreMode::Explicit
|
||||
if worker.execution_metadata_available
|
||||
&& worker.status != WorkerStatus::Stopped =>
|
||||
{
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"worker {} is not stopped",
|
||||
worker_ref.worker_id
|
||||
)));
|
||||
}
|
||||
WorkerRestoreMode::Automatic
|
||||
if !worker.status.is_active()
|
||||
|| worker.restore_intent != WorkerRestoreIntent::Automatic =>
|
||||
{
|
||||
return Ok(worker.detail());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let request = worker.request.clone().ok_or_else(|| {
|
||||
RuntimeError::WorkerExecutionUnavailable {
|
||||
worker_id: worker.worker_id,
|
||||
message: "persisted Worker restore request is unavailable".to_string(),
|
||||
}
|
||||
})?;
|
||||
(request, worker.working_directory.clone())
|
||||
};
|
||||
let backend = state.execution_backend.clone().ok_or_else(|| {
|
||||
RuntimeError::WorkerExecutionUnavailable {
|
||||
worker_id: worker_ref.worker_id,
|
||||
message: "runtime has no execution backend".to_string(),
|
||||
}
|
||||
})?;
|
||||
{
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
worker.execution_bound = true;
|
||||
return Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::ReconciliationRequired,
|
||||
"worker_restore_cleanup_pending",
|
||||
"Worker restore cleanup or reconciliation is still pending",
|
||||
));
|
||||
}
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
match mode {
|
||||
WorkerRestoreMode::Explicit
|
||||
if worker.execution_metadata_available
|
||||
&& worker.status != WorkerStatus::Stopped =>
|
||||
{
|
||||
return Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::Rejected,
|
||||
"worker_restore_not_stopped",
|
||||
"Worker restore requires a stopped Worker",
|
||||
));
|
||||
}
|
||||
WorkerRestoreMode::Automatic
|
||||
if !worker.status.is_active()
|
||||
|| worker.restore_intent != WorkerRestoreIntent::Automatic =>
|
||||
{
|
||||
return Ok(RuntimeWorkerRestoreResult::accepted(worker.detail()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(worker_request) = worker.request.clone() else {
|
||||
return Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::Rejected,
|
||||
"worker_restore_metadata_unavailable",
|
||||
"Persisted Worker restore metadata is unavailable",
|
||||
));
|
||||
};
|
||||
let Some(backend) = state.execution_backend.clone() else {
|
||||
return Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::Rejected,
|
||||
"worker_restore_backend_unavailable",
|
||||
"Runtime has no Worker execution backend",
|
||||
));
|
||||
};
|
||||
let workspace_scope = worker_request.workspace_api.as_ref().and_then(|api| {
|
||||
state
|
||||
.workspace_owners
|
||||
@@ -1472,13 +1570,21 @@ impl Runtime {
|
||||
request: worker_request,
|
||||
workspace_scope,
|
||||
context: self.execution_context(worker_ref.clone()),
|
||||
previous_working_directory,
|
||||
previous_working_directory: worker.working_directory.clone(),
|
||||
working_directory: None,
|
||||
config_bundle: None,
|
||||
};
|
||||
(backend, request)
|
||||
};
|
||||
|
||||
if let Err(_result) = backend.preflight_restore(&request) {
|
||||
return Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::Rejected,
|
||||
"worker_restore_preflight_rejected",
|
||||
"Worker restore preflight rejected the current request",
|
||||
));
|
||||
}
|
||||
|
||||
match backend.restore_worker(request) {
|
||||
WorkerExecutionSpawnResult::Connected {
|
||||
handle,
|
||||
@@ -1488,30 +1594,118 @@ impl Runtime {
|
||||
let commit = self.commit_restored_worker_execution(
|
||||
worker_ref,
|
||||
handle.clone(),
|
||||
worker_state,
|
||||
worker_state.clone(),
|
||||
WorkerStatus::Idle,
|
||||
working_directory,
|
||||
working_directory.clone(),
|
||||
);
|
||||
if let Err(error) = commit {
|
||||
self.cleanup_failed_restore(&backend, worker_ref, &handle)?;
|
||||
return Err(error);
|
||||
match commit {
|
||||
Ok(worker) => Ok(RuntimeWorkerRestoreResult::accepted(worker)),
|
||||
Err(error) => {
|
||||
match self.cleanup_failed_restore(&backend, worker_ref, &handle) {
|
||||
Ok(()) => {
|
||||
tracing::warn!(
|
||||
worker_id = %worker_ref.worker_id,
|
||||
error = %error,
|
||||
"Worker restore commit failed and was rolled back"
|
||||
);
|
||||
Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::RolledBack,
|
||||
"worker_restore_commit_rolled_back",
|
||||
"Worker restore commit failed and cleanup completed",
|
||||
))
|
||||
}
|
||||
Err(cleanup_error) => {
|
||||
tracing::error!(
|
||||
worker_id = %worker_ref.worker_id,
|
||||
error = %error,
|
||||
cleanup_error = %cleanup_error,
|
||||
"Worker restore requires reconciliation after commit and cleanup failures"
|
||||
);
|
||||
if let Err(retain_error) = self.retain_restore_execution_evidence(
|
||||
worker_ref,
|
||||
handle,
|
||||
worker_state,
|
||||
working_directory,
|
||||
) {
|
||||
tracing::error!(
|
||||
worker_id = %worker_ref.worker_id,
|
||||
error = %retain_error,
|
||||
"failed to persist restore execution evidence; in-process reconciliation remains required"
|
||||
);
|
||||
}
|
||||
Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::ReconciliationRequired,
|
||||
"worker_restore_reconciliation_required",
|
||||
"Worker restore result is uncertain; reread or retry the Worker restore",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.worker_detail(worker_ref)
|
||||
}
|
||||
WorkerExecutionSpawnResult::Rejected(result)
|
||||
| WorkerExecutionSpawnResult::Errored(result) => {
|
||||
WorkerExecutionSpawnResult::Rejected(_result) => {
|
||||
Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::Rejected,
|
||||
"worker_restore_rejected",
|
||||
"Worker restore was rejected before live work started",
|
||||
))
|
||||
}
|
||||
WorkerExecutionSpawnResult::RolledBack(_result) => {
|
||||
#[cfg(feature = "fs-store")]
|
||||
self.lock()?.record_restore_failure(worker_ref, _result)?;
|
||||
Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::RolledBack,
|
||||
"worker_restore_rolled_back",
|
||||
"Worker restore failed after live work started; cleanup completed",
|
||||
))
|
||||
}
|
||||
WorkerExecutionSpawnResult::ReconciliationRequired {
|
||||
result: _result,
|
||||
handle,
|
||||
worker_state,
|
||||
working_directory,
|
||||
} => {
|
||||
if let (Some(handle), Some(worker_state)) = (handle, worker_state) {
|
||||
if let Err(retain_error) = self.retain_restore_execution_evidence(
|
||||
worker_ref,
|
||||
handle,
|
||||
worker_state,
|
||||
working_directory,
|
||||
) {
|
||||
tracing::error!(
|
||||
worker_id = %worker_ref.worker_id,
|
||||
error = %retain_error,
|
||||
"failed to persist restore execution evidence; in-process reconciliation remains required"
|
||||
);
|
||||
}
|
||||
} else if let Err(retain_error) =
|
||||
self.retain_restore_reconciliation_pending(worker_ref)
|
||||
{
|
||||
self.lock()?
|
||||
.record_restore_failure(worker_ref, result.clone())?;
|
||||
tracing::error!(
|
||||
worker_id = %worker_ref.worker_id,
|
||||
error = %retain_error,
|
||||
"failed to persist pending restore reconciliation marker"
|
||||
);
|
||||
}
|
||||
Err(RuntimeError::WorkerExecutionRejected {
|
||||
worker_id: worker_ref.worker_id,
|
||||
operation: result.operation,
|
||||
outcome: result.outcome,
|
||||
message: result.message_or_default(),
|
||||
result,
|
||||
})
|
||||
Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::ReconciliationRequired,
|
||||
"worker_restore_reconciliation_required",
|
||||
"Worker restore result is uncertain; reread or retry the Worker restore",
|
||||
))
|
||||
}
|
||||
WorkerExecutionSpawnResult::Errored(_result) => {
|
||||
if let Err(retain_error) = self.retain_restore_reconciliation_pending(worker_ref) {
|
||||
tracing::error!(
|
||||
worker_id = %worker_ref.worker_id,
|
||||
error = %retain_error,
|
||||
"failed to persist pending restore reconciliation marker"
|
||||
);
|
||||
}
|
||||
Ok(RuntimeWorkerRestoreResult::failed(
|
||||
WorkerRestoreState::ReconciliationRequired,
|
||||
"worker_restore_backend_outcome_unknown",
|
||||
"Worker restore backend outcome is uncertain; reread or retry the Worker restore",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2469,27 +2663,83 @@ impl Runtime {
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
status: WorkerStatus,
|
||||
working_directory: Option<CatalogWorkingDirectoryStatus>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
) -> Result<WorkerDetail, RuntimeError> {
|
||||
let mut state = self.lock()?;
|
||||
state.ensure_worker_ref(worker_ref)?;
|
||||
{
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
if worker.execution_handle.is_some() {
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"worker {} already has a current execution",
|
||||
worker_ref.worker_id
|
||||
)));
|
||||
}
|
||||
worker.execution_handle = Some(handle);
|
||||
worker.execution_metadata_available = true;
|
||||
worker.execution_bound = true;
|
||||
worker.status = status;
|
||||
let _ = worker.apply_worker_state(&worker_state);
|
||||
worker.restore_intent = restore_intent_for_status(worker.status);
|
||||
worker.working_directory = working_directory;
|
||||
let mut candidate = state.worker(worker_ref)?.clone();
|
||||
if candidate.execution_handle.is_some() {
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"worker {} already has a current execution",
|
||||
worker_ref.worker_id
|
||||
)));
|
||||
}
|
||||
candidate.execution_handle = Some(handle);
|
||||
candidate.execution_metadata_available = true;
|
||||
candidate.execution_bound = true;
|
||||
candidate.status = status;
|
||||
let _ = candidate.apply_worker_state(&worker_state);
|
||||
candidate.restore_intent = restore_intent_for_status(candidate.status);
|
||||
candidate.working_directory = working_directory;
|
||||
|
||||
// Validate the exact subscription projection before persistence. The
|
||||
// subsequent publish uses the same pure projection while the Worker
|
||||
// operation lock keeps this candidate stable.
|
||||
let _subscription_projection = state.subscription_worker(&candidate)?;
|
||||
|
||||
// Persist the complete candidate before making it observable. A failed
|
||||
// write therefore leaves both the in-memory catalog and subscriptions
|
||||
// on the pre-restore record, so cleanup can produce a true rollback.
|
||||
state.persist_worker_record(&candidate)?;
|
||||
let detail = candidate.detail();
|
||||
state.workers.insert(worker_ref.worker_id, candidate);
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
if let Err(error) = state.persist_runtime_snapshot() {
|
||||
tracing::warn!(
|
||||
worker_id = %worker_ref.worker_id,
|
||||
error = %error,
|
||||
"Worker restore committed to its canonical record but Runtime snapshot refresh failed"
|
||||
);
|
||||
}
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
fn retain_restore_execution_evidence(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
handle: WorkerExecutionHandle,
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
working_directory: Option<CatalogWorkingDirectoryStatus>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let mut state = self.lock()?;
|
||||
let mut candidate = state.worker(worker_ref)?.clone();
|
||||
candidate.execution_handle = Some(handle);
|
||||
candidate.execution_metadata_available = true;
|
||||
candidate.execution_bound = true;
|
||||
candidate.status = WorkerStatus::Idle;
|
||||
let _ = candidate.apply_worker_state(&worker_state);
|
||||
candidate.restore_intent = WorkerRestoreIntent::Automatic;
|
||||
candidate.working_directory = working_directory;
|
||||
state.workers.insert(worker_ref.worker_id, candidate);
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retain_restore_reconciliation_pending(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let mut state = self.lock()?;
|
||||
let mut candidate = state.worker(worker_ref)?.clone();
|
||||
candidate.execution_handle = None;
|
||||
candidate.execution_metadata_available = false;
|
||||
candidate.execution_bound = true;
|
||||
// Active-without-metadata is the durable pending-reconciliation marker:
|
||||
// it prevents a false Stopped projection and blocks duplicate restore.
|
||||
candidate.status = WorkerStatus::Idle;
|
||||
candidate.restore_intent = WorkerRestoreIntent::Explicit;
|
||||
state.workers.insert(worker_ref.worker_id, candidate);
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2513,18 +2763,6 @@ impl Runtime {
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
let mut state = self.lock()?;
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
worker.execution_handle = None;
|
||||
worker.execution_bound = false;
|
||||
worker.status = WorkerStatus::Stopped;
|
||||
worker.worker_state = None;
|
||||
worker.restore_intent = WorkerRestoreIntent::Explicit;
|
||||
worker.internal_workers.clear();
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2908,6 +3146,14 @@ impl RuntimeState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
fn persist_worker_record(&self, worker: &WorkerRecord) -> Result<(), RuntimeError> {
|
||||
if let Some(store) = self.fs_store() {
|
||||
store.write_worker_record(&worker.persisted_record())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
fn persist_worker(&self, worker_id: &WorkerId) -> Result<(), RuntimeError> {
|
||||
if let Some(store) = self.fs_store() {
|
||||
@@ -2945,6 +3191,11 @@ impl RuntimeState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "fs-store"))]
|
||||
fn persist_worker_record(&self, _worker: &WorkerRecord) -> Result<(), RuntimeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "fs-store"))]
|
||||
fn persist_worker(&self, _worker_id: &WorkerId) -> Result<(), RuntimeError> {
|
||||
Ok(())
|
||||
@@ -3162,6 +3413,7 @@ impl RuntimeState {
|
||||
worker_id,
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: self
|
||||
.worker_subject_revisions
|
||||
.get(&worker.worker_id)
|
||||
@@ -3689,7 +3941,7 @@ struct RemovedInternalWorkerActivity {
|
||||
parent_session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct InternalWorkerActivityProjection {
|
||||
workers: BTreeMap<String, InternalWorkerActivity>,
|
||||
removed_workers: BTreeMap<String, RemovedInternalWorkerActivity>,
|
||||
@@ -3708,7 +3960,7 @@ impl InternalWorkerActivityProjection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct WorkerRecord {
|
||||
worker_ref: WorkerRef,
|
||||
worker_id: WorkerId,
|
||||
@@ -4681,8 +4933,8 @@ mod tests {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repository-1".to_string(),
|
||||
provider: "git".to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
source: server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
@@ -4705,7 +4957,7 @@ mod tests {
|
||||
}],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
@@ -4823,7 +5075,7 @@ mod tests {
|
||||
],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
@@ -4912,8 +5164,8 @@ mod tests {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repository-1".to_string(),
|
||||
provider: "git".to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
source: server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
@@ -4943,7 +5195,7 @@ mod tests {
|
||||
],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repository-1".to_string(),
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
@@ -5095,6 +5347,7 @@ mod tests {
|
||||
stop_gate: Mutex<Option<Arc<RestoreGate>>>,
|
||||
stop_count: Mutex<u64>,
|
||||
restore_result: Mutex<Option<WorkerExecutionSpawnResult>>,
|
||||
preflight_restore_result: Mutex<Option<WorkerExecutionResult>>,
|
||||
restore_gate: Mutex<Option<Arc<RestoreGate>>>,
|
||||
restore_count: Mutex<u64>,
|
||||
config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
|
||||
@@ -5214,6 +5467,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn preflight_restore(
|
||||
&self,
|
||||
_request: &WorkerExecutionRestoreRequest,
|
||||
) -> Result<(), WorkerExecutionResult> {
|
||||
match self.preflight_restore_result.lock().unwrap().clone() {
|
||||
Some(result) => Err(result),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_worker(
|
||||
&self,
|
||||
request: WorkerExecutionRestoreRequest,
|
||||
@@ -6610,6 +6873,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_observation_distinguishes_live_and_retained_without_restore() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
let scope = scope("local", "server-a");
|
||||
let detail = runtime
|
||||
.create_worker_scoped(&scope, task_request("observe without restore"))
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
runtime
|
||||
.worker_session_scoped(&scope, &detail.worker_ref)
|
||||
.unwrap(),
|
||||
runtime_api::WorkerSessionAvailability::LiveProtocol
|
||||
));
|
||||
|
||||
runtime
|
||||
.send_protocol_method(
|
||||
&detail.worker_ref,
|
||||
Method::Shutdown {
|
||||
command: test_command(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let observation = runtime
|
||||
.worker_session_scoped(&scope, &detail.worker_ref)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
observation,
|
||||
runtime_api::WorkerSessionAvailability::Unavailable { .. }
|
||||
));
|
||||
assert_eq!(*backend.restore_count.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_worker_rejects_input_until_explicitly_restored() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
@@ -7326,6 +7623,72 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_preflight_rejection_is_typed_and_side_effect_free() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let created = runtime
|
||||
.create_worker(task_request("restore preflight"))
|
||||
.unwrap();
|
||||
runtime.stop_worker(&created.worker_ref, None).unwrap();
|
||||
let before = runtime.worker_detail(&created.worker_ref).unwrap();
|
||||
backend
|
||||
.preflight_restore_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace(WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"profile is no longer permitted",
|
||||
));
|
||||
|
||||
let result = runtime
|
||||
.restore_worker_operation(&created.worker_ref)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.state, WorkerRestoreState::Rejected);
|
||||
assert!(result.worker.is_none());
|
||||
assert_eq!(*backend.restore_count.lock().unwrap(), 0);
|
||||
assert_eq!(runtime.worker_detail(&created.worker_ref).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncertain_restore_backend_outcome_requires_reconciliation() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let created = runtime
|
||||
.create_worker(task_request("restore reconciliation"))
|
||||
.unwrap();
|
||||
runtime.stop_worker(&created.worker_ref, None).unwrap();
|
||||
backend.restore_result.lock().unwrap().replace(
|
||||
WorkerExecutionSpawnResult::ReconciliationRequired {
|
||||
result: WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"controller handshake outcome was not observable",
|
||||
),
|
||||
handle: None,
|
||||
worker_state: None,
|
||||
working_directory: None,
|
||||
},
|
||||
);
|
||||
|
||||
let result = runtime
|
||||
.restore_worker_operation(&created.worker_ref)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.state, WorkerRestoreState::ReconciliationRequired);
|
||||
assert!(result.worker.is_none());
|
||||
assert_eq!(
|
||||
runtime.worker_detail(&created.worker_ref).unwrap().status,
|
||||
WorkerStatus::Idle
|
||||
);
|
||||
backend.restore_result.lock().unwrap().take();
|
||||
let retry = runtime
|
||||
.restore_worker_operation(&created.worker_ref)
|
||||
.unwrap();
|
||||
assert_eq!(retry.state, WorkerRestoreState::Accepted);
|
||||
assert_eq!(*backend.restore_count.lock().unwrap(), 2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
#[test]
|
||||
fn fs_store_automatically_restores_every_active_lifecycle_state() {
|
||||
@@ -7661,7 +8024,7 @@ mod tests {
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
#[test]
|
||||
fn fs_store_stops_worker_and_reports_when_execution_restore_fails() {
|
||||
fn fs_store_marks_worker_for_reconciliation_when_execution_restore_is_uncertain() {
|
||||
let root = fs_store_root("execution-restore-failed");
|
||||
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||
crate::fs_store::FsRuntimeStoreOptions {
|
||||
@@ -7695,27 +8058,20 @@ mod tests {
|
||||
|
||||
assert_eq!(*restoring_backend.restore_count.lock().unwrap(), 1);
|
||||
let restored_worker = restored.worker_detail(&worker.worker_ref).unwrap();
|
||||
assert_eq!(restored_worker.status, WorkerStatus::Stopped);
|
||||
assert!(
|
||||
restored
|
||||
.diagnostics()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(
|
||||
|diagnostic| diagnostic.code == "worker_execution_restore_failed"
|
||||
&& diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref)
|
||||
)
|
||||
);
|
||||
let err = restored
|
||||
assert_eq!(restored_worker.status, WorkerStatus::Idle);
|
||||
assert!(!restored_worker.execution_metadata_available);
|
||||
restoring_backend.restore_result.lock().unwrap().take();
|
||||
let retry = restored
|
||||
.restore_worker_operation(&worker.worker_ref)
|
||||
.unwrap();
|
||||
assert_eq!(retry.state, WorkerRestoreState::Accepted);
|
||||
assert_eq!(*restoring_backend.restore_count.lock().unwrap(), 2);
|
||||
restored
|
||||
.send_input(
|
||||
&worker.worker_ref,
|
||||
WorkerInput::user("after failed restore"),
|
||||
WorkerInput::user("after reconciled restore"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
RuntimeError::WorkerExecutionUnavailable { .. }
|
||||
));
|
||||
.unwrap();
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
@@ -18,13 +18,14 @@ use crate::auth::{BACKEND_RESOURCE_FETCH_PERMISSION, RuntimeIdentityMaterial};
|
||||
use crate::catalog::{
|
||||
CreateWorkerRequest, ProfileSourceArchiveSource, RepositoryRefObservation,
|
||||
RepositoryRefObservationRequest, WorkingDirectoryRepositoryAccessRequest,
|
||||
WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryStatusKind,
|
||||
};
|
||||
use crate::config_bundle::{ConfigBundle, workspace_config_etag};
|
||||
use crate::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
|
||||
WorkerExecutionRestoreRequest, WorkerExecutionResult, WorkerExecutionSpawnRequest,
|
||||
WorkerExecutionSpawnResult, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult,
|
||||
WorkerExecutionSpawnResult, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest,
|
||||
WorkspaceConfigFetchResult,
|
||||
};
|
||||
use crate::identity::WorkerRef;
|
||||
use crate::interaction::{WorkerInput, WorkerInputKind};
|
||||
@@ -59,7 +60,9 @@ fn next_internal_command(
|
||||
.max(floor);
|
||||
Ok(WorkerCommandEnvelope::new(command_id))
|
||||
}
|
||||
use session_store::{CombinedStore, WorkerAggregateStore, WorkerSessionStore};
|
||||
use session_store::{
|
||||
CombinedStore, Store, WorkerAggregateStore, WorkerMetadataStore, WorkerSessionStore,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use session_store::{FsStore, FsWorkerStore};
|
||||
use tokio::runtime::Runtime;
|
||||
@@ -117,11 +120,26 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static {
|
||||
Err("Runtime Worker factory does not support Workspace Config fetching".to_string())
|
||||
}
|
||||
|
||||
fn retained_session_snapshot(
|
||||
&self,
|
||||
_worker_ref: &WorkerRef,
|
||||
) -> Result<session_store::RetainedSessionSnapshot, session_store::RetainedSnapshotReadError>
|
||||
{
|
||||
Err(session_store::RetainedSnapshotReadError::RetentionMissing)
|
||||
}
|
||||
|
||||
async fn spawn_controller(
|
||||
&self,
|
||||
request: WorkerExecutionSpawnRequest,
|
||||
) -> Result<RuntimeWorkerController, String>;
|
||||
|
||||
async fn preflight_restore(
|
||||
&self,
|
||||
_request: &WorkerExecutionRestoreRequest,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_controller(
|
||||
&self,
|
||||
request: WorkerExecutionRestoreRequest,
|
||||
@@ -798,6 +816,22 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
fetch_workspace_config_http(&request, client).await
|
||||
}
|
||||
|
||||
fn retained_session_snapshot(
|
||||
&self,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<session_store::RetainedSessionSnapshot, session_store::RetainedSnapshotReadError>
|
||||
{
|
||||
let Some(root) = self.worker_aggregate_root.as_ref() else {
|
||||
return Err(session_store::RetainedSnapshotReadError::RetentionMissing);
|
||||
};
|
||||
let worker_name = Self::runtime_worker_name_for_ref(worker_ref);
|
||||
session_store::read_retained_session_snapshot(
|
||||
&root.join(&worker_name),
|
||||
&worker_name,
|
||||
session_store::DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
fn observe_workspace_prompt_projection(
|
||||
&self,
|
||||
projection: worker::WorkspacePromptProjection,
|
||||
@@ -1006,6 +1040,56 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
})
|
||||
}
|
||||
|
||||
async fn preflight_restore(
|
||||
&self,
|
||||
request: &WorkerExecutionRestoreRequest,
|
||||
) -> Result<(), String> {
|
||||
let worker_name = Self::runtime_worker_name_for_ref(&request.worker_ref);
|
||||
let (mut manifest, _) = Self::restore_fallback_manifest(&worker_name)?;
|
||||
bind_workspace_memory_settings(&mut manifest, &request.request)?;
|
||||
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
||||
if !worker_aggregate_dir.is_dir() {
|
||||
return Err("Persisted Worker aggregate metadata is unavailable".to_string());
|
||||
}
|
||||
if !worker_aggregate_dir.join("session").is_dir() {
|
||||
return Err("Persisted Worker Session metadata is unavailable".to_string());
|
||||
}
|
||||
let metadata_store = WorkerAggregateStore::new(&worker_aggregate_dir, &worker_name)
|
||||
.map_err(|error| format!("failed to read Worker aggregate metadata: {error}"))?;
|
||||
let metadata = metadata_store
|
||||
.read_by_name(&worker_name)
|
||||
.map_err(|error| format!("failed to read Worker metadata: {error}"))?
|
||||
.ok_or_else(|| "Persisted Worker metadata is unavailable".to_string())?;
|
||||
if let Some(active) = metadata.active.as_ref()
|
||||
&& let Some(segment_id) = active.segment_id
|
||||
{
|
||||
let session_store = WorkerSessionStore::new(worker_aggregate_dir.join("session"))
|
||||
.map_err(|error| format!("failed to read Worker Session store: {error}"))?;
|
||||
if !session_store
|
||||
.exists(active.session_id, segment_id)
|
||||
.map_err(|error| format!("failed to inspect Worker Session segment: {error}"))?
|
||||
{
|
||||
return Err("Persisted Worker Session segment is unavailable".to_string());
|
||||
}
|
||||
session_store
|
||||
.read_all(active.session_id, segment_id)
|
||||
.map_err(|error| format!("failed to read Worker Session segment: {error}"))?;
|
||||
}
|
||||
if request.request.workspace_api.is_some()
|
||||
&& metadata
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.segment_id.is_none())
|
||||
&& request.config_bundle.is_none()
|
||||
{
|
||||
return Err(
|
||||
"Pending Workspace Worker restore requires current profile launch authority"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_controller(
|
||||
&self,
|
||||
request: WorkerExecutionRestoreRequest,
|
||||
@@ -1314,6 +1398,7 @@ pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
|
||||
working_directory_materializer: Option<Arc<dyn WorkingDirectoryMaterializer>>,
|
||||
runtime: Mutex<Option<Runtime>>,
|
||||
workers: Mutex<HashMap<crate::identity::WorkerRef, RuntimeWorkerExecution>>,
|
||||
restore_lock: Mutex<()>,
|
||||
spawn_restore_timeout: Duration,
|
||||
}
|
||||
|
||||
@@ -1342,6 +1427,7 @@ where
|
||||
working_directory_materializer: None,
|
||||
runtime: Mutex::new(Some(runtime)),
|
||||
workers: Mutex::new(HashMap::new()),
|
||||
restore_lock: Mutex::new(()),
|
||||
spawn_restore_timeout: SPAWN_RESTORE_TASK_TIMEOUT,
|
||||
})
|
||||
}
|
||||
@@ -1573,6 +1659,39 @@ where
|
||||
.unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message))
|
||||
}
|
||||
|
||||
fn retain_uncertain_unconnected_controller(
|
||||
&self,
|
||||
worker_ref: &crate::identity::WorkerRef,
|
||||
handle: WorkerHandle,
|
||||
shutdown: Arc<tokio::sync::Mutex<Option<worker::ShutdownReceiver>>>,
|
||||
tasks: RuntimeExecutionTaskScope,
|
||||
worker_state: Arc<RwLock<protocol::WorkerStateSnapshot>>,
|
||||
workspace_client: Option<Arc<dyn WorkspaceClient>>,
|
||||
) -> Result<WorkerExecutionHandle, String> {
|
||||
let mut workers = self
|
||||
.workers
|
||||
.lock()
|
||||
.map_err(|_| "worker adapter registry lock is poisoned".to_string())?;
|
||||
if workers.contains_key(worker_ref) {
|
||||
return Err("Worker already has a retained execution handle".to_string());
|
||||
}
|
||||
workers.insert(
|
||||
worker_ref.clone(),
|
||||
RuntimeWorkerExecution {
|
||||
handle,
|
||||
shutdown,
|
||||
shutdown_requested: Arc::new(AtomicBool::new(false)),
|
||||
tasks,
|
||||
worker_state,
|
||||
workspace_client,
|
||||
},
|
||||
);
|
||||
Ok(WorkerExecutionHandle::new(
|
||||
worker_ref.clone(),
|
||||
self.backend_id(),
|
||||
))
|
||||
}
|
||||
|
||||
fn cleanup_unconnected_controller(
|
||||
&self,
|
||||
handle: &WorkerHandle,
|
||||
@@ -1622,11 +1741,19 @@ where
|
||||
working_directory: Option<WorkingDirectoryBinding>,
|
||||
workspace_client: Option<Arc<dyn WorkspaceClient>>,
|
||||
) -> WorkerExecutionSpawnResult {
|
||||
let worker_state = Arc::new(RwLock::new(handle.shared_state.snapshot()));
|
||||
#[cfg(feature = "ws-server")]
|
||||
let streams = subscribe_worker_protocol_session(&handle);
|
||||
#[cfg(feature = "ws-server")]
|
||||
let worker_state_snapshot = match &streams.snapshot_event {
|
||||
Event::Snapshot { state, .. } => state.clone(),
|
||||
_ => unreachable!("Worker protocol subscription snapshot must be a snapshot event"),
|
||||
};
|
||||
#[cfg(not(feature = "ws-server"))]
|
||||
let worker_state_snapshot = handle.shared_state.snapshot();
|
||||
let worker_state = Arc::new(RwLock::new(worker_state_snapshot));
|
||||
let tasks = RuntimeExecutionTaskScope::new(controller_task);
|
||||
#[cfg(feature = "ws-server")]
|
||||
{
|
||||
let streams = subscribe_worker_protocol_session(&handle);
|
||||
let mut events = streams.events;
|
||||
let mut entry_events = streams.log_entries;
|
||||
let bridge_worker_state = worker_state.clone();
|
||||
@@ -1670,15 +1797,50 @@ where
|
||||
}) {
|
||||
Ok(task) => task,
|
||||
Err(message) => {
|
||||
let cleanup = self
|
||||
.cleanup_unconnected_controller(&handle, &shutdown, &tasks, &worker_state)
|
||||
.err()
|
||||
.map(|cleanup| format!("; controller cleanup failed: {cleanup}"))
|
||||
.unwrap_or_default();
|
||||
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
|
||||
let cleanup = self.cleanup_unconnected_controller(
|
||||
&handle,
|
||||
&shutdown,
|
||||
&tasks,
|
||||
&worker_state,
|
||||
);
|
||||
let result = WorkerExecutionResult::errored(
|
||||
operation,
|
||||
format!("{message}{cleanup}"),
|
||||
));
|
||||
match &cleanup {
|
||||
Ok(()) => message,
|
||||
Err(cleanup) => {
|
||||
format!("{message}; controller cleanup failed: {cleanup}")
|
||||
}
|
||||
},
|
||||
);
|
||||
return if operation == WorkerExecutionOperation::Restore {
|
||||
match cleanup {
|
||||
Ok(()) => WorkerExecutionSpawnResult::RolledBack(result),
|
||||
Err(_) => {
|
||||
let retained_state =
|
||||
worker_state.read().ok().map(|state| state.clone());
|
||||
let retained_working_directory =
|
||||
working_directory.as_ref().map(|binding| binding.status());
|
||||
let retained_handle = self
|
||||
.retain_uncertain_unconnected_controller(
|
||||
&worker_ref,
|
||||
handle,
|
||||
shutdown,
|
||||
tasks,
|
||||
worker_state,
|
||||
workspace_client,
|
||||
)
|
||||
.ok();
|
||||
WorkerExecutionSpawnResult::ReconciliationRequired {
|
||||
result,
|
||||
handle: retained_handle,
|
||||
worker_state: retained_state,
|
||||
working_directory: retained_working_directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
WorkerExecutionSpawnResult::Errored(result)
|
||||
};
|
||||
}
|
||||
};
|
||||
tasks.push("protocol bridge", bridge_task, true);
|
||||
@@ -1688,31 +1850,40 @@ where
|
||||
let _ = bridge_context;
|
||||
}
|
||||
|
||||
let mut workers = match self.workers.lock() {
|
||||
Ok(workers) => workers,
|
||||
Err(_) => {
|
||||
let cleanup = self
|
||||
.cleanup_unconnected_controller(&handle, &shutdown, &tasks, &worker_state)
|
||||
.err()
|
||||
.map(|cleanup| format!("; controller cleanup failed: {cleanup}"))
|
||||
.unwrap_or_default();
|
||||
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
|
||||
operation,
|
||||
format!("worker adapter registry lock is poisoned{cleanup}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut workers = self
|
||||
.workers
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if workers.contains_key(&worker_ref) {
|
||||
let existing_worker_state = workers
|
||||
.get(&worker_ref)
|
||||
.and_then(|worker| worker.worker_state.read().ok().map(|state| state.clone()));
|
||||
let existing_handle = WorkerExecutionHandle::new(worker_ref.clone(), self.backend_id());
|
||||
drop(workers);
|
||||
let cleanup = self
|
||||
.cleanup_unconnected_controller(&handle, &shutdown, &tasks, &worker_state)
|
||||
.err()
|
||||
.map(|cleanup| format!("; controller cleanup failed: {cleanup}"))
|
||||
.unwrap_or_default();
|
||||
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::busy(
|
||||
let cleanup =
|
||||
self.cleanup_unconnected_controller(&handle, &shutdown, &tasks, &worker_state);
|
||||
let result = WorkerExecutionResult::busy(
|
||||
operation,
|
||||
format!("Worker is already connected to execution backend{cleanup}"),
|
||||
));
|
||||
match &cleanup {
|
||||
Ok(()) => "Worker is already connected to execution backend".to_string(),
|
||||
Err(cleanup) => format!(
|
||||
"Worker is already connected to execution backend; controller cleanup failed: {cleanup}"
|
||||
),
|
||||
},
|
||||
);
|
||||
return if operation == WorkerExecutionOperation::Restore {
|
||||
match cleanup {
|
||||
Ok(()) => WorkerExecutionSpawnResult::RolledBack(result),
|
||||
Err(_) => WorkerExecutionSpawnResult::ReconciliationRequired {
|
||||
result,
|
||||
handle: Some(existing_handle),
|
||||
worker_state: existing_worker_state,
|
||||
working_directory: None,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
WorkerExecutionSpawnResult::Rejected(result)
|
||||
};
|
||||
}
|
||||
let connected_worker_state = worker_state
|
||||
.read()
|
||||
@@ -1781,6 +1952,48 @@ where
|
||||
self.run_on_adapter_runtime(async move { factory.fetch_workspace_config(request).await })
|
||||
}
|
||||
|
||||
fn worker_session(
|
||||
&self,
|
||||
request: WorkerSessionObservationRequest,
|
||||
) -> runtime_api::WorkerSessionAvailability {
|
||||
match self.factory.retained_session_snapshot(&request.worker_ref) {
|
||||
Ok(retained) => runtime_api::WorkerSessionAvailability::RetainedSnapshot {
|
||||
identity: runtime_api::RetainedSessionIdentity {
|
||||
session_id: retained.identity.session_id,
|
||||
segment_id: retained.identity.segment_id,
|
||||
entry_count: retained.identity.entry_count,
|
||||
},
|
||||
snapshot: retained.snapshot,
|
||||
},
|
||||
Err(error) => {
|
||||
let reason = match error {
|
||||
session_store::RetainedSnapshotReadError::RetentionMissing => {
|
||||
runtime_api::WorkerSessionUnavailableReason::RetentionMissing
|
||||
}
|
||||
session_store::RetainedSnapshotReadError::ActivePointerMissing => {
|
||||
runtime_api::WorkerSessionUnavailableReason::ActivePointerMissing
|
||||
}
|
||||
session_store::RetainedSnapshotReadError::MigrationRequired => {
|
||||
runtime_api::WorkerSessionUnavailableReason::MigrationRequired
|
||||
}
|
||||
session_store::RetainedSnapshotReadError::CorruptLog => {
|
||||
runtime_api::WorkerSessionUnavailableReason::CorruptLog
|
||||
}
|
||||
session_store::RetainedSnapshotReadError::StorageUnavailable => {
|
||||
runtime_api::WorkerSessionUnavailableReason::StorageUnavailable
|
||||
}
|
||||
session_store::RetainedSnapshotReadError::SnapshotTooLarge => {
|
||||
runtime_api::WorkerSessionUnavailableReason::SnapshotTooLarge
|
||||
}
|
||||
};
|
||||
runtime_api::WorkerSessionAvailability::Unavailable {
|
||||
reason,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_workspace_prompt_projection(
|
||||
&self,
|
||||
projection: worker::WorkspacePromptProjection,
|
||||
@@ -1888,11 +2101,15 @@ where
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
||||
let _start_guard = self
|
||||
.restore_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if self
|
||||
.workers
|
||||
.lock()
|
||||
.map(|workers| workers.contains_key(&request.worker_ref))
|
||||
.unwrap_or(false)
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.contains_key(&request.worker_ref)
|
||||
{
|
||||
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::busy(
|
||||
WorkerExecutionOperation::Spawn,
|
||||
@@ -1999,10 +2216,111 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
fn preflight_restore(
|
||||
&self,
|
||||
request: &WorkerExecutionRestoreRequest,
|
||||
) -> Result<(), WorkerExecutionResult> {
|
||||
let workers = self
|
||||
.workers
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if workers.contains_key(&request.worker_ref) {
|
||||
return Err(WorkerExecutionResult::busy(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"Worker is already connected to execution backend",
|
||||
));
|
||||
}
|
||||
drop(workers);
|
||||
|
||||
if let Some(previous) = request.previous_working_directory.as_ref() {
|
||||
let materializer = self
|
||||
.working_directory_materializer
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"Persisted Worker Workdir binding cannot be restored by this Runtime",
|
||||
)
|
||||
})?;
|
||||
let current = materializer
|
||||
.working_directory_status(&previous.summary.working_directory_id)
|
||||
.map_err(|message| {
|
||||
WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
format!("Persisted Worker Workdir is unavailable: {message}"),
|
||||
)
|
||||
})?;
|
||||
if current.summary.status != WorkingDirectoryStatusKind::Active {
|
||||
return Err(WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"Persisted Worker Workdir is not active",
|
||||
));
|
||||
}
|
||||
}
|
||||
if request.previous_working_directory.is_none()
|
||||
&& request.request.working_directory_request.is_some()
|
||||
{
|
||||
return Err(WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"Persisted Worker Workdir allocation is unavailable",
|
||||
));
|
||||
}
|
||||
if request.previous_working_directory.is_none()
|
||||
&& request.request.working_directory.is_some()
|
||||
&& self.working_directory_materializer.is_none()
|
||||
{
|
||||
return Err(WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"Persisted Worker Workdir claim cannot be restored by this Runtime",
|
||||
));
|
||||
}
|
||||
if let Some(workspace_api) = request.request.workspace_api.as_ref() {
|
||||
let Some(scope) = request.workspace_scope.as_ref() else {
|
||||
return Err(WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"Persisted Workspace restore authorization is unavailable",
|
||||
));
|
||||
};
|
||||
if scope.workspace_id != workspace_api.workspace_id {
|
||||
return Err(WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"Persisted Workspace restore authorization does not match Worker authority",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let factory = Arc::clone(&self.factory);
|
||||
let request = request.clone();
|
||||
self.run_on_adapter_runtime(async move { factory.preflight_restore(&request).await })
|
||||
.map_err(|message| {
|
||||
WorkerExecutionResult::rejected(WorkerExecutionOperation::Restore, message)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_worker(
|
||||
&self,
|
||||
mut request: WorkerExecutionRestoreRequest,
|
||||
) -> WorkerExecutionSpawnResult {
|
||||
// Serialize backend restore starts so duplicate detection is completed
|
||||
// before any controller or bridge side effect. Runtime also serializes
|
||||
// per Worker; this guard protects direct/concurrent backend callers.
|
||||
let _restore_guard = self
|
||||
.restore_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let workers = self
|
||||
.workers
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if workers.contains_key(&request.worker_ref) {
|
||||
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::busy(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"Worker is already connected to execution backend",
|
||||
));
|
||||
}
|
||||
drop(workers);
|
||||
|
||||
let working_directory = match request.previous_working_directory.clone() {
|
||||
Some(status) => {
|
||||
let Some(materializer) = self.working_directory_materializer.as_ref() else {
|
||||
@@ -2080,10 +2398,15 @@ where
|
||||
let controller = match restore_result {
|
||||
Ok(controller) => controller,
|
||||
Err(message) => {
|
||||
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::Restore,
|
||||
message,
|
||||
));
|
||||
return WorkerExecutionSpawnResult::ReconciliationRequired {
|
||||
result: WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::Restore,
|
||||
message,
|
||||
),
|
||||
handle: None,
|
||||
worker_state: None,
|
||||
working_directory: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2122,7 +2445,6 @@ where
|
||||
Method::NotifyTracked {
|
||||
notification_request_id: notification_request_id.clone(),
|
||||
message: input.content,
|
||||
auto_run: true,
|
||||
source: protocol::AuthenticatedInputSource::Backend {
|
||||
operation_id: notification_request_id,
|
||||
},
|
||||
@@ -3265,8 +3587,8 @@ mod tests {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repo-main".to_string(),
|
||||
provider: "git".to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
source: server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::LocalPath,
|
||||
uri: repo.display().to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
|
||||
@@ -455,7 +455,7 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
request: worker::WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<workspace_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
) -> Result<server_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
let mut path = format!(
|
||||
"/api/w/{}/worker-discovery/workers?limit={}",
|
||||
self.workspace_id, request.limit
|
||||
|
||||
@@ -842,8 +842,8 @@ impl RuntimeGitMaterializer {
|
||||
binding.command_environment.insert(
|
||||
"YOI_REPOSITORY_ACCESS".to_string(),
|
||||
match access.access {
|
||||
workspace_api::RepositoryAccessMode::ReadOnly => "read_only",
|
||||
workspace_api::RepositoryAccessMode::ReadWrite => "read_write",
|
||||
server_api::RepositoryAccessMode::ReadOnly => "read_only",
|
||||
server_api::RepositoryAccessMode::ReadWrite => "read_write",
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
@@ -884,7 +884,7 @@ impl RuntimeGitMaterializer {
|
||||
Self::validate_plain_http_source(request)?;
|
||||
if matches!(
|
||||
request.repository.source.kind,
|
||||
workspace_api::RepositorySourceKind::Https | workspace_api::RepositorySourceKind::Ssh
|
||||
server_api::RepositorySourceKind::Https | server_api::RepositorySourceKind::Ssh
|
||||
) {
|
||||
validate_remote_source_uri(request)?;
|
||||
let materialization = request.materialization.as_ref().ok_or_else(|| {
|
||||
@@ -906,10 +906,10 @@ impl RuntimeGitMaterializer {
|
||||
}
|
||||
}
|
||||
match request.repository.source.kind {
|
||||
workspace_api::RepositorySourceKind::LocalPath
|
||||
| workspace_api::RepositorySourceKind::File
|
||||
| workspace_api::RepositorySourceKind::Https => {}
|
||||
workspace_api::RepositorySourceKind::Ssh => {
|
||||
server_api::RepositorySourceKind::LocalPath
|
||||
| server_api::RepositorySourceKind::File
|
||||
| server_api::RepositorySourceKind::Https => {}
|
||||
server_api::RepositorySourceKind::Ssh => {
|
||||
let ssh = request
|
||||
.materialization
|
||||
.as_ref()
|
||||
@@ -922,7 +922,7 @@ impl RuntimeGitMaterializer {
|
||||
})?;
|
||||
validate_ssh_materialization_access(ssh)?;
|
||||
}
|
||||
workspace_api::RepositorySourceKind::Invalid => {
|
||||
server_api::RepositorySourceKind::Invalid => {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_repository_source_invalid",
|
||||
"configured Repository source is invalid and cannot be materialized",
|
||||
@@ -939,7 +939,7 @@ impl RuntimeGitMaterializer {
|
||||
) -> Result<WorkingDirectoryRequest, WorkingDirectoryDiagnostic> {
|
||||
let mut request = request.clone();
|
||||
if request.repository.provider != "git"
|
||||
|| request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh
|
||||
|| request.repository.source.kind != server_api::RepositorySourceKind::Ssh
|
||||
{
|
||||
return Ok(request);
|
||||
}
|
||||
@@ -1911,7 +1911,7 @@ struct RepositorySshCommandPolicy {
|
||||
username: Option<String>,
|
||||
port: Option<u16>,
|
||||
repository_path: String,
|
||||
access: workspace_api::RepositoryAccessMode,
|
||||
access: server_api::RepositoryAccessMode,
|
||||
}
|
||||
|
||||
impl RepositorySshCommandPolicy {
|
||||
@@ -2053,9 +2053,7 @@ fn repository_command_matches(command: &str, policy: &RepositorySshCommandPolicy
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if operation == "receive-pack"
|
||||
&& policy.access != workspace_api::RepositoryAccessMode::ReadWrite
|
||||
{
|
||||
if operation == "receive-pack" && policy.access != server_api::RepositoryAccessMode::ReadWrite {
|
||||
return false;
|
||||
}
|
||||
let argument = argument
|
||||
@@ -2218,7 +2216,7 @@ impl RepositoryCommandAccess {
|
||||
runtime_root: &Path,
|
||||
request: &WorkingDirectoryRequest,
|
||||
) -> Result<Option<Self>, WorkingDirectoryDiagnostic> {
|
||||
if request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
|
||||
if request.repository.source.kind != server_api::RepositorySourceKind::Ssh {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(ssh) = request
|
||||
@@ -2403,7 +2401,7 @@ fn validate_ssh_materialization_access(
|
||||
fn validate_remote_source_uri(
|
||||
request: &WorkingDirectoryRequest,
|
||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh {
|
||||
if request.repository.source.kind == server_api::RepositorySourceKind::Ssh {
|
||||
let source =
|
||||
parse_repository_ssh_source(&request.repository.source.uri).ok_or_else(|| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
@@ -2441,7 +2439,7 @@ fn validate_remote_source_uri(
|
||||
"remote Repository source URI is invalid",
|
||||
)
|
||||
})?;
|
||||
if request.repository.source.kind != workspace_api::RepositorySourceKind::Https {
|
||||
if request.repository.source.kind != server_api::RepositorySourceKind::Https {
|
||||
return Ok(());
|
||||
}
|
||||
if url.scheme() != "https"
|
||||
@@ -2512,7 +2510,7 @@ fn repository_git_command(
|
||||
let mut command = isolated_git_command();
|
||||
let file_policy = if matches!(
|
||||
request.repository.source.kind,
|
||||
workspace_api::RepositorySourceKind::LocalPath | workspace_api::RepositorySourceKind::File
|
||||
server_api::RepositorySourceKind::LocalPath | server_api::RepositorySourceKind::File
|
||||
) {
|
||||
"always"
|
||||
} else {
|
||||
@@ -2589,7 +2587,7 @@ fn test_repository_git_invocation_count() -> usize {
|
||||
|
||||
fn run_repository_git_stdout(
|
||||
mut command: Command,
|
||||
source_kind: workspace_api::RepositorySourceKind,
|
||||
source_kind: server_api::RepositorySourceKind,
|
||||
) -> Result<String, WorkingDirectoryDiagnostic> {
|
||||
#[cfg(test)]
|
||||
TEST_REPOSITORY_GIT_INVOCATIONS.with(|count| count.set(count.get().saturating_add(1)));
|
||||
@@ -2837,10 +2835,10 @@ fn resolve_cloned_commit(
|
||||
|
||||
fn apply_repository_access_policy(
|
||||
repository_root: &Path,
|
||||
access: workspace_api::RepositoryAccessMode,
|
||||
access: server_api::RepositoryAccessMode,
|
||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
match access {
|
||||
workspace_api::RepositoryAccessMode::ReadOnly => {
|
||||
server_api::RepositoryAccessMode::ReadOnly => {
|
||||
let mut disable_push = isolated_git_command();
|
||||
disable_push.arg("-C").arg(repository_root).args([
|
||||
"remote",
|
||||
@@ -2851,7 +2849,7 @@ fn apply_repository_access_policy(
|
||||
]);
|
||||
run_repository_git(disable_push, "working_directory_repository_policy_failed")
|
||||
}
|
||||
workspace_api::RepositoryAccessMode::ReadWrite => {
|
||||
server_api::RepositoryAccessMode::ReadWrite => {
|
||||
if git_stdout(
|
||||
repository_root,
|
||||
["config", "--get-all", "remote.origin.pushurl"],
|
||||
@@ -3122,7 +3120,7 @@ mod tests {
|
||||
}],
|
||||
host_trust_id: "trust-1".to_string(),
|
||||
host_trust_revision: 4,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repo-main".to_string(),
|
||||
repository_source_fingerprint: "sha256:test".to_string(),
|
||||
@@ -3310,8 +3308,8 @@ mod tests {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repo-main".to_string(),
|
||||
provider: "git".to_string(),
|
||||
source: workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
||||
source: server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::LocalPath,
|
||||
uri: repo.display().to_string(),
|
||||
},
|
||||
source_revision: 1,
|
||||
@@ -3594,7 +3592,7 @@ mod tests {
|
||||
assert!(!runtime_root.path().join(".repository-cache").exists());
|
||||
|
||||
let mut file_request = request(repo.path());
|
||||
file_request.repository.source.kind = workspace_api::RepositorySourceKind::File;
|
||||
file_request.repository.source.kind = server_api::RepositorySourceKind::File;
|
||||
file_request.repository.source.uri = format!("file://{}", repo.path().display());
|
||||
file_request.repository.source_revision = 2;
|
||||
file_request.repository.source_fingerprint = "sha256:file-source".to_string();
|
||||
@@ -3723,7 +3721,7 @@ mod tests {
|
||||
],
|
||||
host_trust_id: "trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadWrite,
|
||||
access: server_api::RepositoryAccessMode::ReadWrite,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repo-main".to_string(),
|
||||
repository_source_fingerprint: "sha256:test".to_string(),
|
||||
@@ -3735,8 +3733,8 @@ mod tests {
|
||||
}),
|
||||
});
|
||||
let mut ssh_operation = request.clone();
|
||||
ssh_operation.repository.source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
ssh_operation.repository.source = server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
};
|
||||
let command_access = RepositoryCommandAccess::prepare(runtime_root.path(), &ssh_operation)
|
||||
@@ -3810,8 +3808,8 @@ mod tests {
|
||||
request.backend_workdir_id = Some(working_directory_id.clone());
|
||||
request.materialization.as_mut().unwrap().ssh = None;
|
||||
let mut authorized_ssh_request = request.clone();
|
||||
authorized_ssh_request.repository.source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
authorized_ssh_request.repository.source = server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
};
|
||||
assert!(
|
||||
@@ -3910,7 +3908,7 @@ mod tests {
|
||||
let mut rotated = initial_materialization.clone();
|
||||
rotated.operation_id = "operation-agent-rotated".to_string();
|
||||
rotated.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 2;
|
||||
rotated.ssh.as_mut().unwrap().access = workspace_api::RepositoryAccessMode::ReadOnly;
|
||||
rotated.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadOnly;
|
||||
materializer
|
||||
.authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest {
|
||||
working_directory_id: id.clone(),
|
||||
@@ -4028,7 +4026,7 @@ mod tests {
|
||||
let mut read_write = rotated;
|
||||
read_write.operation_id = "operation-agent-read-write".to_string();
|
||||
read_write.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 3;
|
||||
read_write.ssh.as_mut().unwrap().access = workspace_api::RepositoryAccessMode::ReadWrite;
|
||||
read_write.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadWrite;
|
||||
let read_write_command_policy =
|
||||
RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap();
|
||||
materializer
|
||||
@@ -4099,7 +4097,7 @@ mod tests {
|
||||
}],
|
||||
host_trust_id: "trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repo-main".to_string(),
|
||||
repository_source_fingerprint: "sha256:test".to_string(),
|
||||
@@ -4251,8 +4249,8 @@ mod tests {
|
||||
assert_eq!(policy.repository_path, "team/repo.git");
|
||||
|
||||
let mut request = request(Path::new("."));
|
||||
request.repository.source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
request.repository.source = server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: uri.to_string(),
|
||||
};
|
||||
request.materialization = Some(crate::catalog::RepositoryMaterializationContext {
|
||||
@@ -4294,8 +4292,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let mut https = request(repo.path());
|
||||
https.repository.source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Https,
|
||||
https.repository.source = server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::Https,
|
||||
uri: "https://token@example.test/repo.git".to_string(),
|
||||
};
|
||||
https.materialization = Some(context(None));
|
||||
@@ -4330,15 +4328,15 @@ mod tests {
|
||||
);
|
||||
|
||||
for (offset, kind) in [
|
||||
workspace_api::RepositorySourceKind::LocalPath,
|
||||
workspace_api::RepositorySourceKind::File,
|
||||
workspace_api::RepositorySourceKind::Https,
|
||||
server_api::RepositorySourceKind::LocalPath,
|
||||
server_api::RepositorySourceKind::File,
|
||||
server_api::RepositorySourceKind::Https,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut mismatched_http = request(repo.path());
|
||||
mismatched_http.repository.source = workspace_api::RepositorySource {
|
||||
mismatched_http.repository.source = server_api::RepositorySource {
|
||||
kind,
|
||||
uri: "http://example.test/repo.git".to_string(),
|
||||
};
|
||||
@@ -4358,8 +4356,8 @@ mod tests {
|
||||
}
|
||||
|
||||
let mut ssh = request(repo.path());
|
||||
ssh.repository.source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
ssh.repository.source = server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
};
|
||||
ssh.materialization = Some(context(Some(
|
||||
@@ -4371,7 +4369,7 @@ mod tests {
|
||||
}],
|
||||
host_trust_id: "trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
access: server_api::RepositoryAccessMode::ReadOnly,
|
||||
expires_at_epoch_seconds: u64::MAX,
|
||||
repository_id: "repo-main".to_string(),
|
||||
repository_source_fingerprint: "sha256:test".to_string(),
|
||||
@@ -4396,8 +4394,8 @@ mod tests {
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = RuntimeGitMaterializer::new(runtime_root.path());
|
||||
let mut remote = request(Path::new("."));
|
||||
remote.repository.source = workspace_api::RepositorySource {
|
||||
kind: workspace_api::RepositorySourceKind::Ssh,
|
||||
remote.repository.source = server_api::RepositorySource {
|
||||
kind: server_api::RepositorySourceKind::Ssh,
|
||||
uri: "ssh://git@example.invalid/repo.git".to_string(),
|
||||
};
|
||||
remote.materialization = Some(crate::catalog::RepositoryMaterializationContext {
|
||||
|
||||
@@ -8,8 +8,8 @@ use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use ring::signature::Ed25519KeyPair;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use server_api::WorkspacePublicIdentityBundle;
|
||||
use sha2::{Digest, Sha256};
|
||||
use workspace_api::WorkspacePublicIdentityBundle;
|
||||
|
||||
use crate::auth::RuntimeAuthError;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tools = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
workspace-api = { workspace = true }
|
||||
server-api = { workspace = true }
|
||||
minijinja = "2.19.0"
|
||||
chrono = "0.4"
|
||||
config-source = { path = "../config-source" }
|
||||
|
||||
+509
-210
File diff suppressed because it is too large
Load Diff
@@ -389,7 +389,7 @@ where
|
||||
reason: detail.live.error,
|
||||
};
|
||||
}
|
||||
match send_notify(&detail.live.socket_path, message, false).await {
|
||||
match send_notify(&detail.live.socket_path, message).await {
|
||||
Ok(()) => WeakNotifyDelivery::Delivered,
|
||||
Err(error) => WeakNotifyDelivery::SendFailed {
|
||||
error: error.to_string(),
|
||||
@@ -1009,17 +1009,16 @@ where
|
||||
}
|
||||
|
||||
async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()> {
|
||||
send_notify(socket_path, message, true).await
|
||||
send_notify(socket_path, message).await
|
||||
}
|
||||
|
||||
async fn send_notify(socket_path: &Path, message: String, auto_run: bool) -> io::Result<()> {
|
||||
async fn send_notify(socket_path: &Path, message: String) -> io::Result<()> {
|
||||
let notification_request_id = protocol::new_submission_request_id();
|
||||
connect_and_send(
|
||||
socket_path,
|
||||
&Method::NotifyTracked {
|
||||
notification_request_id: notification_request_id.clone(),
|
||||
message,
|
||||
auto_run,
|
||||
source: protocol::AuthenticatedInputSource::Backend {
|
||||
operation_id: notification_request_id,
|
||||
},
|
||||
@@ -1552,11 +1551,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let method = reader.next::<Method>().await.unwrap().unwrap();
|
||||
if let Method::NotifyTracked {
|
||||
message, auto_run, ..
|
||||
} = method
|
||||
{
|
||||
assert!(auto_run);
|
||||
if let Method::NotifyTracked { message, .. } = method {
|
||||
tx.send(message).await.unwrap();
|
||||
} else {
|
||||
panic!("expected Notify, got {method:?}");
|
||||
@@ -1575,7 +1570,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn weak_notify_to_live_peer_uses_notify_without_auto_run_and_noops_when_missing() {
|
||||
async fn weak_notify_to_live_peer_uses_realtime_notify_and_noops_when_missing() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let store_dir = root.path().join("store");
|
||||
let runtime_base = root.path().join("runtime");
|
||||
@@ -1674,11 +1669,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let method = reader.next::<Method>().await.unwrap().unwrap();
|
||||
if let Method::NotifyTracked {
|
||||
message, auto_run, ..
|
||||
} = method
|
||||
{
|
||||
assert!(!auto_run);
|
||||
if let Method::NotifyTracked { message, .. } = method {
|
||||
tx.send(message).await.unwrap();
|
||||
} else {
|
||||
panic!("expected Notify, got {method:?}");
|
||||
|
||||
@@ -23,7 +23,7 @@ use workdir::{
|
||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
use workspace_api::{
|
||||
use server_api::{
|
||||
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
|
||||
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
|
||||
WorkingDirectoryListResponse as WorkdirListResponse,
|
||||
|
||||
@@ -168,7 +168,7 @@ fn input_schema() -> serde_json::Value {
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use workspace_api::{
|
||||
use server_api::{
|
||||
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
};
|
||||
|
||||
|
||||
@@ -1543,7 +1543,7 @@ mod tests {
|
||||
PromptCatalog::from_projection(projection).unwrap(),
|
||||
));
|
||||
|
||||
buffer.push_notify("updated".to_string(), false);
|
||||
buffer.push_notify("updated".to_string());
|
||||
let appends = interceptor
|
||||
.pending_history_appends(PendingHistoryAppendsContext {
|
||||
invocation: Default::default(),
|
||||
@@ -1598,7 +1598,7 @@ mod tests {
|
||||
prompts.store(Arc::new(
|
||||
PromptCatalog::from_projection(projection).unwrap(),
|
||||
));
|
||||
buffer.push_notify("must persist".to_string(), false);
|
||||
buffer.push_notify("must persist".to_string());
|
||||
|
||||
let error = interceptor
|
||||
.pending_history_appends(PendingHistoryAppendsContext {
|
||||
@@ -1621,8 +1621,8 @@ mod tests {
|
||||
async fn pending_history_appends_drains_buffer_into_items() {
|
||||
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||
let buffer = NotifyBuffer::new();
|
||||
buffer.push_notify("first".into(), false);
|
||||
buffer.push_notify("second".into(), false);
|
||||
buffer.push_notify("first".into());
|
||||
buffer.push_notify("second".into());
|
||||
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
registry,
|
||||
@@ -1671,7 +1671,7 @@ mod tests {
|
||||
// anything itself.
|
||||
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||
let buffer = NotifyBuffer::new();
|
||||
buffer.push_notify("msg".into(), false);
|
||||
buffer.push_notify("msg".into());
|
||||
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
registry,
|
||||
|
||||
@@ -26,13 +26,9 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use protocol::WorkerEvent;
|
||||
use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
|
||||
/// Maximum queued pending entries. Oldest entries are dropped beyond this.
|
||||
const CAPACITY: usize = 128;
|
||||
|
||||
/// One pending entry awaiting drain into the next LLM request.
|
||||
///
|
||||
/// The buffer keeps the raw input shape so the drain step can decide
|
||||
@@ -43,7 +39,6 @@ const CAPACITY: usize = 128;
|
||||
pub enum PendingNotify {
|
||||
Notify {
|
||||
message: String,
|
||||
auto_run: bool,
|
||||
extensions: Vec<SessionExtension>,
|
||||
history_provenance: Option<LoggedSessionHistoryOrigin>,
|
||||
},
|
||||
@@ -76,6 +71,7 @@ impl PendingNotify {
|
||||
#[derive(Clone, Default)]
|
||||
pub struct NotifyBuffer {
|
||||
inner: Arc<Mutex<VecDeque<PendingNotify>>>,
|
||||
wake: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl NotifyBuffer {
|
||||
@@ -86,10 +82,9 @@ impl NotifyBuffer {
|
||||
/// Push a notify entry onto the queue. If the queue is full, the
|
||||
/// oldest entry is dropped and a `tracing::warn` is emitted — the
|
||||
/// caller should never hit this in normal operation.
|
||||
pub fn push_notify(&self, message: String, auto_run: bool) {
|
||||
pub fn push_notify(&self, message: String) {
|
||||
self.push_entry(PendingNotify::Notify {
|
||||
message,
|
||||
auto_run,
|
||||
extensions: Vec::new(),
|
||||
history_provenance: None,
|
||||
});
|
||||
@@ -98,53 +93,32 @@ impl NotifyBuffer {
|
||||
pub fn push_durable_notify(
|
||||
&self,
|
||||
message: String,
|
||||
auto_run: bool,
|
||||
history_provenance: LoggedSessionHistoryOrigin,
|
||||
extension: SessionExtension,
|
||||
) {
|
||||
self.push_entry(PendingNotify::Notify {
|
||||
message,
|
||||
auto_run,
|
||||
extensions: vec![extension],
|
||||
history_provenance: Some(history_provenance),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn replace_durable_notification_extension(
|
||||
&self,
|
||||
extension: SessionExtension,
|
||||
) -> bool {
|
||||
let mut queue = self.inner.lock().expect("notify buffer poisoned");
|
||||
let Some(extensions) = queue.iter_mut().rev().find_map(|pending| match pending {
|
||||
PendingNotify::Notify {
|
||||
auto_run: false,
|
||||
extensions,
|
||||
..
|
||||
} if !extensions.is_empty() => Some(extensions),
|
||||
_ => None,
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
*extensions = vec![extension];
|
||||
true
|
||||
}
|
||||
|
||||
/// Push a typed worker-event entry onto the queue.
|
||||
pub fn push_worker_event(&self, event: WorkerEvent) {
|
||||
self.push_entry(PendingNotify::WorkerEvent { event });
|
||||
}
|
||||
|
||||
fn push_entry(&self, entry: PendingNotify) {
|
||||
let mut q = self.inner.lock().expect("notify buffer poisoned");
|
||||
if q.len() >= CAPACITY {
|
||||
let dropped = q.pop_front();
|
||||
warn!(
|
||||
capacity = CAPACITY,
|
||||
dropped = ?dropped,
|
||||
"notify buffer overflow; dropped oldest"
|
||||
);
|
||||
}
|
||||
q.push_back(entry);
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("notify buffer poisoned")
|
||||
.push_back(entry);
|
||||
self.wake.notify_one();
|
||||
}
|
||||
|
||||
/// Wait until a producer adds work outside the controller method channel.
|
||||
pub async fn notified(&self) {
|
||||
self.wake.notified().await;
|
||||
}
|
||||
|
||||
/// Remove and return all pending entries in FIFO order.
|
||||
@@ -160,23 +134,17 @@ impl NotifyBuffer {
|
||||
for entry in entries.into_iter().rev() {
|
||||
q.push_front(entry);
|
||||
}
|
||||
while q.len() > CAPACITY {
|
||||
let dropped = q.pop_front();
|
||||
warn!(
|
||||
capacity = CAPACITY,
|
||||
dropped = ?dropped,
|
||||
"notify buffer overflow while restoring failed drain; dropped oldest"
|
||||
);
|
||||
}
|
||||
drop(q);
|
||||
self.wake.notify_one();
|
||||
}
|
||||
|
||||
/// Whether an undrained `Method::Notify { auto_run: true }` remains.
|
||||
pub fn has_auto_run_pending(&self) -> bool {
|
||||
/// Whether an undrained notification remains.
|
||||
pub fn has_notification_pending(&self) -> bool {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("notify buffer poisoned")
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, PendingNotify::Notify { auto_run: true, .. }))
|
||||
.any(|entry| matches!(entry, PendingNotify::Notify { .. }))
|
||||
}
|
||||
|
||||
/// Number of pending entries. Primarily for tests.
|
||||
@@ -233,12 +201,12 @@ mod tests {
|
||||
#[test]
|
||||
fn push_then_drain_preserves_order() {
|
||||
let buf = NotifyBuffer::new();
|
||||
buf.push_notify("one".into(), false);
|
||||
assert!(!buf.has_auto_run_pending());
|
||||
buf.push_notify("two".into(), true);
|
||||
assert!(buf.has_auto_run_pending());
|
||||
buf.push_notify("one".into());
|
||||
assert!(buf.has_notification_pending());
|
||||
buf.push_notify("two".into());
|
||||
assert!(buf.has_notification_pending());
|
||||
let drained = buf.drain();
|
||||
assert!(!buf.has_auto_run_pending());
|
||||
assert!(!buf.has_notification_pending());
|
||||
assert_eq!(drained.len(), 2);
|
||||
match &drained[0] {
|
||||
PendingNotify::Notify { message, .. } => assert_eq!(message, "one"),
|
||||
@@ -248,15 +216,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_drops_oldest() {
|
||||
fn notification_burst_is_not_truncated() {
|
||||
let buf = NotifyBuffer::new();
|
||||
for i in 0..(CAPACITY + 5) {
|
||||
buf.push_notify(format!("msg{i}"), false);
|
||||
for i in 0..300 {
|
||||
buf.push_notify(format!("msg{i}"));
|
||||
}
|
||||
let drained = buf.drain();
|
||||
assert_eq!(drained.len(), CAPACITY);
|
||||
assert_eq!(drained.len(), 300);
|
||||
match &drained[0] {
|
||||
PendingNotify::Notify { message, .. } => assert_eq!(message, "msg5"),
|
||||
PendingNotify::Notify { message, .. } => assert_eq!(message, "msg0"),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -265,7 +233,6 @@ mod tests {
|
||||
fn build_system_item_for_notify_carries_wrapper_body() {
|
||||
let entry = PendingNotify::Notify {
|
||||
message: "hello".into(),
|
||||
auto_run: false,
|
||||
extensions: Vec::new(),
|
||||
history_provenance: None,
|
||||
};
|
||||
|
||||
@@ -18,12 +18,16 @@ pub use conflict::{
|
||||
ConflictOwner, find_conflict_owner, find_conflict_owners, is_within_effective_write,
|
||||
};
|
||||
pub use error::ScopeLockError;
|
||||
#[cfg(test)]
|
||||
pub(crate) use lifecycle::install_top_level_at_for_test;
|
||||
pub use lifecycle::{
|
||||
ScopeAllocationGuard, SegmentLockInfo, adopt_allocation, install_top_level,
|
||||
install_top_level_with_deny, lookup_segment, update_segment,
|
||||
ScopeAllocationGuard, SegmentActivationCommitError, SegmentActivationGuard, SegmentLockInfo,
|
||||
adopt_allocation, install_top_level, install_top_level_with_deny, lookup_segment,
|
||||
};
|
||||
pub use mutate::{
|
||||
delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_worker,
|
||||
register_worker_with_deny, release_worker,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use table::fail_next_save_for_test;
|
||||
pub use table::{Allocation, LockFile, LockFileGuard, default_allocation_path};
|
||||
|
||||
@@ -37,4 +37,12 @@ pub enum ScopeLockError {
|
||||
worker_name: String,
|
||||
socket: PathBuf,
|
||||
},
|
||||
#[error(
|
||||
"worker `{worker_name}` allocation moved from expected Segment {expected} to {actual:?}"
|
||||
)]
|
||||
SegmentChanged {
|
||||
worker_name: String,
|
||||
expected: SegmentId,
|
||||
actual: Option<SegmentId>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,41 @@ pub struct ScopeAllocationGuard {
|
||||
lock_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Exclusive authority to activate a replacement Segment for one allocated Worker.
|
||||
///
|
||||
/// The allocation lock remains held across the durable metadata CAS and the final
|
||||
/// allocation-table update, so restore admission cannot observe an intermediate
|
||||
/// ownership state.
|
||||
pub struct SegmentActivationGuard {
|
||||
guard: LockFileGuard,
|
||||
worker_name: String,
|
||||
expected: SegmentId,
|
||||
replacement: SegmentId,
|
||||
}
|
||||
|
||||
/// A failed allocation-table publication together with the still-held machine-wide
|
||||
/// activation authority. Callers must retain `authority` until recovery or process
|
||||
/// teardown so restore admission cannot register a competing writer.
|
||||
pub struct SegmentActivationCommitError {
|
||||
source: ScopeLockError,
|
||||
authority: SegmentActivationGuard,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SegmentActivationCommitError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("SegmentActivationCommitError")
|
||||
.field("source", &self.source)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl SegmentActivationCommitError {
|
||||
pub fn into_parts(self) -> (ScopeLockError, SegmentActivationGuard) {
|
||||
(self.source, self.authority)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScopeAllocationGuard {
|
||||
pub fn worker_name(&self) -> &str {
|
||||
&self.worker_name
|
||||
@@ -28,6 +63,86 @@ impl ScopeAllocationGuard {
|
||||
pub fn lock_path(&self) -> &Path {
|
||||
&self.lock_path
|
||||
}
|
||||
|
||||
/// Lock the machine-wide table and validate a Segment replacement before
|
||||
/// its durable metadata is changed.
|
||||
pub fn begin_segment_activation(
|
||||
&self,
|
||||
expected: SegmentId,
|
||||
replacement: SegmentId,
|
||||
) -> Result<SegmentActivationGuard, ScopeLockError> {
|
||||
let guard = LockFileGuard::open(&self.lock_path)?;
|
||||
let actual = guard
|
||||
.data()
|
||||
.allocations
|
||||
.iter()
|
||||
.find(|allocation| allocation.worker_name == self.worker_name)
|
||||
.and_then(|allocation| allocation.segment_id);
|
||||
|
||||
if actual != Some(expected) {
|
||||
return Err(ScopeLockError::SegmentChanged {
|
||||
worker_name: self.worker_name.clone(),
|
||||
expected,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(existing) = guard.data().allocations.iter().find(|allocation| {
|
||||
allocation.segment_id == Some(replacement) && allocation.worker_name != self.worker_name
|
||||
}) {
|
||||
return Err(ScopeLockError::SegmentConflict {
|
||||
segment_id: replacement,
|
||||
worker_name: existing.worker_name.clone(),
|
||||
socket: existing.socket.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SegmentActivationGuard {
|
||||
guard,
|
||||
worker_name: self.worker_name.clone(),
|
||||
expected,
|
||||
replacement,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SegmentActivationGuard {
|
||||
/// Persist the replacement Segment while retaining exclusive allocation
|
||||
/// authority. This is intentionally consumed so a successful metadata CAS
|
||||
/// cannot accidentally be followed by an unlocked update.
|
||||
pub fn commit(mut self) -> Result<(), SegmentActivationCommitError> {
|
||||
let actual = self
|
||||
.guard
|
||||
.data()
|
||||
.allocations
|
||||
.iter()
|
||||
.find(|allocation| allocation.worker_name == self.worker_name)
|
||||
.and_then(|allocation| allocation.segment_id);
|
||||
let Some(allocation) = self
|
||||
.guard
|
||||
.data_mut()
|
||||
.allocations
|
||||
.iter_mut()
|
||||
.find(|allocation| allocation.worker_name == self.worker_name)
|
||||
else {
|
||||
return Err(SegmentActivationCommitError {
|
||||
source: ScopeLockError::SegmentChanged {
|
||||
worker_name: self.worker_name.clone(),
|
||||
expected: self.expected,
|
||||
actual,
|
||||
},
|
||||
authority: self,
|
||||
});
|
||||
};
|
||||
allocation.segment_id = Some(self.replacement);
|
||||
if let Err(source) = self.guard.save() {
|
||||
return Err(SegmentActivationCommitError {
|
||||
source: ScopeLockError::Io(source),
|
||||
authority: self,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScopeAllocationGuard {
|
||||
@@ -85,6 +200,31 @@ pub fn install_top_level_with_deny(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_top_level_at_for_test(
|
||||
lock_path: PathBuf,
|
||||
worker_name: String,
|
||||
pid: u32,
|
||||
socket: PathBuf,
|
||||
scope_allow: Vec<ScopeRule>,
|
||||
segment_id: SegmentId,
|
||||
) -> Result<ScopeAllocationGuard, ScopeLockError> {
|
||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||
super::mutate::register_worker_with_deny(
|
||||
&mut guard,
|
||||
worker_name.clone(),
|
||||
pid,
|
||||
socket,
|
||||
scope_allow,
|
||||
Vec::new(),
|
||||
segment_id,
|
||||
)?;
|
||||
Ok(ScopeAllocationGuard {
|
||||
worker_name,
|
||||
lock_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Take ownership of an existing allocation that was pre-registered by
|
||||
/// a spawning Worker.
|
||||
///
|
||||
@@ -114,46 +254,6 @@ pub fn adopt_allocation(
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewrite the `segment_id` recorded for `worker_name` to
|
||||
/// `new_segment_id`.
|
||||
///
|
||||
/// The Worker's in-memory `segment_id` can change underneath the
|
||||
/// allocation in two normal places:
|
||||
///
|
||||
/// - `Worker::compact` mints a fresh Segment in the same Session.
|
||||
/// - `session_store::ensure_head_or_fork` auto-forks within that Session when another
|
||||
/// writer has advanced the store head behind our back.
|
||||
///
|
||||
/// Both paths must call this so subsequent [`lookup_segment`] queries
|
||||
/// find the live Segment id, not the old one. Without this update a
|
||||
/// concurrent `restore_from_manifest(new_id)` would see "no live
|
||||
/// writer" and proceed to register a competing allocation on the
|
||||
/// Segment lineage this Worker just moved into.
|
||||
///
|
||||
/// The lock is opened once and the allocation is rewritten inside the
|
||||
/// guard, so the segment_id collision check is atomic with the
|
||||
/// rewrite.
|
||||
pub fn update_segment(worker_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> {
|
||||
let lock_path = default_allocation_path()?;
|
||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||
if let Some(other) = guard.data().find_by_segment(new_segment_id) {
|
||||
if other.worker_name != worker_name {
|
||||
return Err(ScopeLockError::SegmentConflict {
|
||||
segment_id: new_segment_id,
|
||||
worker_name: other.worker_name.clone(),
|
||||
socket: other.socket.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let alloc = guard
|
||||
.data_mut()
|
||||
.find_mut(worker_name)
|
||||
.ok_or_else(|| ScopeLockError::UnknownWorker(worker_name.into()))?;
|
||||
alloc.segment_id = Some(new_segment_id);
|
||||
guard.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Information about a Worker that currently holds an allocation for a
|
||||
/// given session.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -184,7 +284,7 @@ pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>,
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::table::Allocation;
|
||||
use super::super::table::{Allocation, LockFile};
|
||||
use super::super::test_util::*;
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
@@ -283,32 +383,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_session_rewrites_allocation_session_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let original = sid();
|
||||
let updated = sid();
|
||||
let _guard = install_top_level(
|
||||
"p".into(),
|
||||
std::process::id(),
|
||||
sock("p"),
|
||||
vec![write_rule("/work", true)],
|
||||
original,
|
||||
)
|
||||
.unwrap();
|
||||
update_segment("p", updated).unwrap();
|
||||
// lookup against the original is now empty, the updated id wins.
|
||||
assert!(lookup_segment(original).unwrap().is_none());
|
||||
assert_eq!(lookup_segment(updated).unwrap().unwrap().worker_name, "p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_session_rejects_when_target_already_held() {
|
||||
fn segment_activation_rejects_when_target_already_held() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let s_a = sid();
|
||||
let s_b = sid();
|
||||
let _g_a = install_top_level(
|
||||
let g_a = install_top_level(
|
||||
"a".into(),
|
||||
std::process::id(),
|
||||
sock("a"),
|
||||
@@ -324,8 +404,11 @@ mod tests {
|
||||
s_b,
|
||||
)
|
||||
.unwrap();
|
||||
// `a` cannot adopt b's live session id.
|
||||
let err = update_segment("a", s_b).unwrap_err();
|
||||
// `a` cannot activate b's live Segment id.
|
||||
let err = match g_a.begin_segment_activation(s_a, s_b) {
|
||||
Ok(_) => panic!("expected SegmentConflict"),
|
||||
Err(error) => error,
|
||||
};
|
||||
match err {
|
||||
ScopeLockError::SegmentConflict {
|
||||
worker_name,
|
||||
@@ -338,4 +421,191 @@ mod tests {
|
||||
other => panic!("expected SegmentConflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_activation_commits_verified_replacement() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let lock_path = dir.path().join("workers.json");
|
||||
let old_segment = sid();
|
||||
let replacement = sid();
|
||||
let guard = install_top_level(
|
||||
"activation".into(),
|
||||
std::process::id(),
|
||||
sock("activation"),
|
||||
vec![write_rule("/work", true)],
|
||||
old_segment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
guard
|
||||
.begin_segment_activation(old_segment, replacement)
|
||||
.unwrap()
|
||||
.commit()
|
||||
.unwrap();
|
||||
|
||||
let table = LockFileGuard::open(&lock_path).unwrap();
|
||||
assert_eq!(
|
||||
table
|
||||
.data()
|
||||
.find("activation")
|
||||
.and_then(|allocation| allocation.segment_id),
|
||||
Some(replacement)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_segment_activation_returns_locked_recovery_authority() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let old_segment = sid();
|
||||
let replacement = sid();
|
||||
let guard = install_top_level(
|
||||
"activation".into(),
|
||||
std::process::id(),
|
||||
sock("activation"),
|
||||
vec![write_rule("/work", true)],
|
||||
old_segment,
|
||||
)
|
||||
.unwrap();
|
||||
let mut activation = guard
|
||||
.begin_segment_activation(old_segment, replacement)
|
||||
.unwrap();
|
||||
activation
|
||||
.guard
|
||||
.data_mut()
|
||||
.allocations
|
||||
.retain(|allocation| allocation.worker_name != "activation");
|
||||
|
||||
let error = activation.commit().unwrap_err();
|
||||
let (source, authority) = error.into_parts();
|
||||
assert!(matches!(
|
||||
source,
|
||||
ScopeLockError::SegmentChanged {
|
||||
expected,
|
||||
actual: None,
|
||||
..
|
||||
} if expected == old_segment
|
||||
));
|
||||
// The authority is returned rather than dropped on failure. Production
|
||||
// stores it on Worker to keep restore admission blocked.
|
||||
let (sent, received) = std::sync::mpsc::channel();
|
||||
let lookup = std::thread::spawn(move || {
|
||||
sent.send(lookup_segment(replacement).unwrap()).unwrap();
|
||||
});
|
||||
assert!(
|
||||
received
|
||||
.recv_timeout(std::time::Duration::from_millis(50))
|
||||
.is_err()
|
||||
);
|
||||
drop(authority);
|
||||
assert!(
|
||||
received
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
lookup.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_save_failure_preserves_source_table_and_returns_authority() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let lock_path = dir.path().join("workers.json");
|
||||
let old_segment = sid();
|
||||
let replacement = sid();
|
||||
let guard = install_top_level(
|
||||
"activation".into(),
|
||||
std::process::id(),
|
||||
sock("activation"),
|
||||
vec![write_rule("/work", true)],
|
||||
old_segment,
|
||||
)
|
||||
.unwrap();
|
||||
let activation = guard
|
||||
.begin_segment_activation(old_segment, replacement)
|
||||
.unwrap();
|
||||
crate::runtime::worker_allocation::table::fail_next_save_for_test(&lock_path);
|
||||
|
||||
let error = activation.commit().unwrap_err();
|
||||
let (source, authority) = error.into_parts();
|
||||
assert!(matches!(source, ScopeLockError::Io(_)));
|
||||
let persisted: LockFile =
|
||||
serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
persisted
|
||||
.find("activation")
|
||||
.and_then(|allocation| allocation.segment_id),
|
||||
Some(old_segment)
|
||||
);
|
||||
drop(authority);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_segment_activation_preserves_source_allocation() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let lock_path = dir.path().join("workers.json");
|
||||
let old_segment = sid();
|
||||
let replacement = sid();
|
||||
let guard = install_top_level(
|
||||
"activation".into(),
|
||||
std::process::id(),
|
||||
sock("activation"),
|
||||
vec![write_rule("/work", true)],
|
||||
old_segment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
drop(
|
||||
guard
|
||||
.begin_segment_activation(old_segment, replacement)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let table = LockFileGuard::open(&lock_path).unwrap();
|
||||
assert_eq!(
|
||||
table
|
||||
.data()
|
||||
.find("activation")
|
||||
.and_then(|allocation| allocation.segment_id),
|
||||
Some(old_segment)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_activation_rejects_stale_expected_segment_without_mutation() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let lock_path = dir.path().join("workers.json");
|
||||
let allocated = sid();
|
||||
let stale = sid();
|
||||
let replacement = sid();
|
||||
let guard = install_top_level(
|
||||
"activation".into(),
|
||||
std::process::id(),
|
||||
sock("activation"),
|
||||
vec![write_rule("/work", true)],
|
||||
allocated,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
guard.begin_segment_activation(stale, replacement),
|
||||
Err(ScopeLockError::SegmentChanged {
|
||||
expected,
|
||||
actual: Some(actual),
|
||||
..
|
||||
}) if expected == stale && actual == allocated
|
||||
));
|
||||
let table = LockFileGuard::open(&lock_path).unwrap();
|
||||
assert_eq!(
|
||||
table
|
||||
.data()
|
||||
.find("activation")
|
||||
.and_then(|allocation| allocation.segment_id),
|
||||
Some(allocated)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
//! On-disk allocation table and the `flock`-protected guard.
|
||||
|
||||
use std::fs::{DirBuilder, File, OpenOptions};
|
||||
use std::fs::{self, DirBuilder, File, OpenOptions};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SaveFaultPoint {
|
||||
BeforeRename,
|
||||
AfterRename,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static SAVE_FAULTS: std::sync::LazyLock<
|
||||
std::sync::Mutex<std::collections::HashMap<PathBuf, SaveFaultPoint>>,
|
||||
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fail_next_save_for_test(path: &Path) {
|
||||
fail_next_save_at_for_test(path, SaveFaultPoint::BeforeRename);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fail_next_save_at_for_test(path: &Path, point: SaveFaultPoint) {
|
||||
SAVE_FAULTS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.to_path_buf(), point);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn take_save_fault(path: &Path, point: SaveFaultPoint) -> bool {
|
||||
let mut faults = SAVE_FAULTS.lock().unwrap();
|
||||
if faults.get(path) == Some(&point) {
|
||||
faults.remove(path);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
use fs4::fs_std::FileExt;
|
||||
use manifest::{ScopeRule, paths};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -101,7 +137,9 @@ pub fn default_allocation_path() -> io::Result<PathBuf> {
|
||||
/// never call `save` leave the table unchanged, which is the right
|
||||
/// behaviour for error paths.
|
||||
pub struct LockFileGuard {
|
||||
file: File,
|
||||
data_file: File,
|
||||
lock_file: File,
|
||||
data_path: PathBuf,
|
||||
data: LockFile,
|
||||
}
|
||||
|
||||
@@ -121,16 +159,19 @@ impl LockFileGuard {
|
||||
.mode(0o700)
|
||||
.create(parent)?;
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
let mut lock_os = path.as_os_str().to_owned();
|
||||
lock_os.push(".lock");
|
||||
let lock_path = PathBuf::from(lock_os);
|
||||
let lock_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
.open(&lock_path)?;
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match FileExt::try_lock_exclusive(&file) {
|
||||
match FileExt::try_lock_exclusive(&lock_file) {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {
|
||||
if started.elapsed() >= LOCK_WAIT_TIMEOUT {
|
||||
@@ -159,8 +200,17 @@ impl LockFileGuard {
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let data_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
let mut this = Self {
|
||||
file,
|
||||
data_file,
|
||||
lock_file,
|
||||
data_path: path.to_path_buf(),
|
||||
data: LockFile::default(),
|
||||
};
|
||||
this.reload()?;
|
||||
@@ -168,9 +218,9 @@ impl LockFileGuard {
|
||||
}
|
||||
|
||||
fn reload(&mut self) -> io::Result<()> {
|
||||
self.file.seek(SeekFrom::Start(0))?;
|
||||
self.data_file.seek(SeekFrom::Start(0))?;
|
||||
let mut buf = String::new();
|
||||
self.file.read_to_string(&mut buf)?;
|
||||
self.data_file.read_to_string(&mut buf)?;
|
||||
self.data = if buf.trim().is_empty() {
|
||||
LockFile::default()
|
||||
} else {
|
||||
@@ -192,20 +242,49 @@ impl LockFileGuard {
|
||||
&mut self.data
|
||||
}
|
||||
|
||||
/// Serialise `self.data` back to the file (truncate + rewrite).
|
||||
/// Persist with atomic replacement while the separate allocation lock stays
|
||||
/// held. A crash exposes either the old complete table or the new one.
|
||||
pub fn save(&mut self) -> io::Result<()> {
|
||||
let json = serde_json::to_vec_pretty(&self.data).map_err(io::Error::other)?;
|
||||
self.file.seek(SeekFrom::Start(0))?;
|
||||
self.file.set_len(0)?;
|
||||
self.file.write_all(&json)?;
|
||||
self.file.sync_data()?;
|
||||
let mut temp_os = self.data_path.as_os_str().to_owned();
|
||||
temp_os.push(".tmp");
|
||||
let temp_path = PathBuf::from(temp_os);
|
||||
let mut temp_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&temp_path)?;
|
||||
temp_file.write_all(&json)?;
|
||||
temp_file.sync_all()?;
|
||||
#[cfg(test)]
|
||||
if take_save_fault(&self.data_path, SaveFaultPoint::BeforeRename) {
|
||||
return Err(io::Error::other(
|
||||
"injected allocation save failure before rename",
|
||||
));
|
||||
}
|
||||
fs::rename(&temp_path, &self.data_path)?;
|
||||
#[cfg(test)]
|
||||
if take_save_fault(&self.data_path, SaveFaultPoint::AfterRename) {
|
||||
return Err(io::Error::other(
|
||||
"injected allocation save failure after rename",
|
||||
));
|
||||
}
|
||||
if let Some(parent) = self.data_path.parent() {
|
||||
File::open(parent)?.sync_all()?;
|
||||
}
|
||||
self.data_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(&self.data_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LockFileGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = FileExt::unlock(&self.file);
|
||||
let _ = FileExt::unlock(&self.lock_file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +338,76 @@ mod tests {
|
||||
assert_eq!(guard.data().allocations[0].worker_name, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_save_reopens_complete_table_at_both_rename_boundaries() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("workers.json");
|
||||
let old_segment = sid();
|
||||
let replacement = sid();
|
||||
{
|
||||
let mut guard = open_empty(&path);
|
||||
register_worker(
|
||||
&mut guard,
|
||||
"a".into(),
|
||||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/src", true)],
|
||||
old_segment,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let mut guard = LockFileGuard::open(&path).unwrap();
|
||||
guard.data_mut().find_mut("a").unwrap().segment_id = Some(replacement);
|
||||
fail_next_save_at_for_test(&path, SaveFaultPoint::BeforeRename);
|
||||
assert!(guard.save().is_err());
|
||||
}
|
||||
let guard = LockFileGuard::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
guard
|
||||
.data()
|
||||
.find("a")
|
||||
.and_then(|allocation| allocation.segment_id),
|
||||
Some(old_segment)
|
||||
);
|
||||
drop(guard);
|
||||
|
||||
{
|
||||
let mut guard = LockFileGuard::open(&path).unwrap();
|
||||
guard.data_mut().find_mut("a").unwrap().segment_id = Some(replacement);
|
||||
fail_next_save_at_for_test(&path, SaveFaultPoint::AfterRename);
|
||||
assert!(guard.save().is_err());
|
||||
}
|
||||
let guard = LockFileGuard::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
guard
|
||||
.data()
|
||||
.find("a")
|
||||
.and_then(|allocation| allocation.segment_id),
|
||||
Some(replacement)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_lock_remains_exclusive_across_atomic_save() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("workers.json");
|
||||
let mut guard = open_empty(&path);
|
||||
guard.save().unwrap();
|
||||
|
||||
let (sent, received) = std::sync::mpsc::channel();
|
||||
let contender_path = path.clone();
|
||||
let contender = std::thread::spawn(move || {
|
||||
let contender = LockFileGuard::open(&contender_path).unwrap();
|
||||
sent.send(contender.data().allocations.len()).unwrap();
|
||||
});
|
||||
assert!(received.recv_timeout(Duration::from_millis(50)).is_err());
|
||||
drop(guard);
|
||||
assert_eq!(received.recv_timeout(Duration::from_secs(1)).unwrap(), 0);
|
||||
contender.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_by_session_skips_none_placeholders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::worker::WorkspaceClient;
|
||||
|
||||
pub use workspace_api::{
|
||||
pub use server_api::{
|
||||
SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
|
||||
SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity, SkillProjectionStatus,
|
||||
SkillProvenance, SkillResourceRef, SkillSourceKind,
|
||||
@@ -29,7 +29,7 @@ pub enum SkillClientError {
|
||||
#[error("Skill API response JSON is invalid: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Skill API response violates the shared contract: {0}")]
|
||||
InvalidResponse(#[from] workspace_api::SkillApiValidationError),
|
||||
InvalidResponse(#[from] server_api::SkillApiValidationError),
|
||||
#[error("Skill API returned HTTP {status}: {body}")]
|
||||
Http {
|
||||
status: reqwest::StatusCode,
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
|
||||
//! rules to the parent scope.
|
||||
|
||||
#[cfg(test)]
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io;
|
||||
use std::sync::{
|
||||
@@ -311,6 +313,8 @@ impl Drop for InternalSpawnReservation {
|
||||
pub struct SpawnedWorkerRegistry {
|
||||
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
||||
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
|
||||
#[cfg(test)]
|
||||
fail_service_stops: std::sync::Mutex<HashMap<String, usize>>,
|
||||
internal_names: std::sync::Mutex<HashSet<String>>,
|
||||
internal_shutting_down: AtomicBool,
|
||||
pending_internal_spawns: AtomicUsize,
|
||||
@@ -331,6 +335,8 @@ impl SpawnedWorkerRegistry {
|
||||
Arc::new(Self {
|
||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||
service_records: std::sync::Mutex::new(Vec::new()),
|
||||
#[cfg(test)]
|
||||
fail_service_stops: std::sync::Mutex::new(HashMap::new()),
|
||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||
internal_shutting_down: AtomicBool::new(false),
|
||||
pending_internal_spawns: AtomicUsize::new(0),
|
||||
@@ -346,6 +352,8 @@ impl SpawnedWorkerRegistry {
|
||||
Arc::new(Self {
|
||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||
service_records: std::sync::Mutex::new(Vec::new()),
|
||||
#[cfg(test)]
|
||||
fail_service_stops: std::sync::Mutex::new(HashMap::new()),
|
||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||
internal_shutting_down: AtomicBool::new(false),
|
||||
pending_internal_spawns: AtomicUsize::new(0),
|
||||
@@ -360,6 +368,8 @@ impl SpawnedWorkerRegistry {
|
||||
Arc::new(Self {
|
||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||
service_records: std::sync::Mutex::new(Vec::new()),
|
||||
#[cfg(test)]
|
||||
fail_service_stops: std::sync::Mutex::new(HashMap::new()),
|
||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||
internal_shutting_down: AtomicBool::new(false),
|
||||
pending_internal_spawns: AtomicUsize::new(0),
|
||||
@@ -443,6 +453,8 @@ impl SpawnedWorkerRegistry {
|
||||
registry: Arc::new(Self {
|
||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||
service_records: std::sync::Mutex::new(Vec::new()),
|
||||
#[cfg(test)]
|
||||
fail_service_stops: std::sync::Mutex::new(HashMap::new()),
|
||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||
internal_shutting_down: AtomicBool::new(false),
|
||||
pending_internal_spawns: AtomicUsize::new(0),
|
||||
@@ -535,9 +547,53 @@ impl SpawnedWorkerRegistry {
|
||||
Ok(worker_ref)
|
||||
}
|
||||
|
||||
/// Stop and remove one parent-owned service Worker. This is host-only and is
|
||||
/// intentionally separate from the SubWorker control surface.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_service_for_test(
|
||||
&self,
|
||||
) -> (String, tokio::sync::broadcast::Sender<protocol::Event>) {
|
||||
let (session, status_tx) = crate::internal_worker::test_internal_worker_session(
|
||||
crate::internal_worker::InternalWorkerVisibility::ServicePrivate,
|
||||
);
|
||||
let session_id = session.session_id_string();
|
||||
self.service_records
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(InternalServiceWorkerRecord::new(
|
||||
"test-service",
|
||||
"test-service",
|
||||
session,
|
||||
));
|
||||
(session_id, status_tx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_service_for_test(&self, session_id: &str) -> bool {
|
||||
self.service_records
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|record| record.session.session_id_string() == session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fail_service_stops_for_test(&self, session_id: &str, count: usize) {
|
||||
self.fail_service_stops
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(session_id.to_owned(), count);
|
||||
}
|
||||
|
||||
pub(crate) async fn stop_service(&self, session_id: &str) -> io::Result<bool> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let mut failures = self.fail_service_stops.lock().unwrap();
|
||||
if let Some(remaining) = failures.get_mut(session_id) {
|
||||
if *remaining > 0 {
|
||||
*remaining -= 1;
|
||||
return Err(io::Error::other("injected service stop failure"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let record = self
|
||||
.service_records
|
||||
.lock()
|
||||
|
||||
@@ -262,28 +262,28 @@ impl ParentNotificationTarget {
|
||||
Self::Controller { sender, fallback }
|
||||
}
|
||||
|
||||
pub(crate) fn notify(&self, child_session_id: String, message: String, auto_run: bool) {
|
||||
pub(crate) fn notify(&self, child_session_id: String, message: String) {
|
||||
let method = Method::NotifyTracked {
|
||||
notification_request_id: protocol::new_submission_request_id(),
|
||||
message,
|
||||
auto_run,
|
||||
source: protocol::AuthenticatedInputSource::SubWorker {
|
||||
session_id: child_session_id,
|
||||
},
|
||||
};
|
||||
match self {
|
||||
Self::Controller { sender, fallback } => {
|
||||
// Persist through the Worker-owned pending authority before the
|
||||
// child completion path returns. The Controller delivery is only
|
||||
// a wake-up hint; replay is idempotent by notification_request_id.
|
||||
fallback(method.clone());
|
||||
let Some(parent_method_tx) = sender.upgrade() else {
|
||||
fallback(method);
|
||||
return;
|
||||
};
|
||||
let fallback = fallback.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = parent_method_tx.send(method).await {
|
||||
if parent_method_tx.send(method).await.is_err() {
|
||||
tracing::warn!(
|
||||
"failed to notify parent Controller; using durable pending authority"
|
||||
"failed to wake parent Controller after durable notification acceptance"
|
||||
);
|
||||
fallback(error.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -588,7 +588,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
let message = format!(
|
||||
"SubWorker `{child_name}` turn ended with status {status:?}. Inspect its committed session with worker-observation tools before making completion decisions."
|
||||
);
|
||||
parent_notifications.notify(child_name.clone(), message, true);
|
||||
parent_notifications.notify(child_name.clone(), message);
|
||||
})),
|
||||
Some(child_workdir_tool_broker.clone()),
|
||||
)
|
||||
@@ -1199,10 +1199,34 @@ enabled = false
|
||||
drop(parent_method_tx);
|
||||
|
||||
assert!(parent_method_rx.recv().await.is_none());
|
||||
target.notify("child-session".into(), "late completion".to_string(), true);
|
||||
target.notify("child-session".into(), "late completion".to_string());
|
||||
assert!(*captured.lock().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_controller_notification_is_durable_before_wakeup_delivery() {
|
||||
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(1);
|
||||
let durable = Arc::new(AtomicBool::new(false));
|
||||
let durable_for_fallback = Arc::clone(&durable);
|
||||
let target = ParentNotificationTarget::with_controller_fallback(
|
||||
parent_method_tx.downgrade(),
|
||||
ParentNotificationTarget::Durable(Arc::new(move |_| {
|
||||
durable_for_fallback.store(true, Ordering::Release);
|
||||
})),
|
||||
);
|
||||
|
||||
target.notify("child-session".into(), "completed".into());
|
||||
let wakeup = parent_method_rx.recv().await.unwrap();
|
||||
assert!(durable.load(Ordering::Acquire));
|
||||
assert!(matches!(
|
||||
wakeup,
|
||||
Method::NotifyTracked {
|
||||
source: protocol::AuthenticatedInputSource::SubWorker { session_id },
|
||||
..
|
||||
} if session_id == "child-session"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_parent_notification_target_preserves_child_source() {
|
||||
let captured = Arc::new(std::sync::Mutex::new(None));
|
||||
@@ -1211,13 +1235,12 @@ enabled = false
|
||||
*captured_for_target.lock().unwrap() = Some(method);
|
||||
}));
|
||||
|
||||
target.notify("child-session".into(), "completed".into(), true);
|
||||
target.notify("child-session".into(), "completed".into());
|
||||
|
||||
assert!(matches!(
|
||||
captured.lock().unwrap().take(),
|
||||
Some(Method::NotifyTracked {
|
||||
message,
|
||||
auto_run: true,
|
||||
source: protocol::AuthenticatedInputSource::SubWorker { session_id },
|
||||
..
|
||||
}) if session_id == "child-session" && message == "completed"
|
||||
@@ -1361,7 +1384,6 @@ enabled = false
|
||||
completion,
|
||||
Method::NotifyTracked {
|
||||
message,
|
||||
auto_run: true,
|
||||
source: protocol::AuthenticatedInputSource::SubWorker { session_id },
|
||||
..
|
||||
} if session_id == "reviewer-child"
|
||||
|
||||
+746
-219
File diff suppressed because it is too large
Load Diff
@@ -344,7 +344,10 @@ target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
|
||||
async fn make_worker_with_manifest<C>(manifest_toml: &str, client: C) -> Worker<C, TestStore>
|
||||
async fn make_worker_with_manifest_and_store<C>(
|
||||
manifest_toml: &str,
|
||||
client: C,
|
||||
) -> (Worker<C, TestStore>, TestStore)
|
||||
where
|
||||
C: LlmClient + Clone + Send + Sync + 'static,
|
||||
{
|
||||
@@ -364,6 +367,7 @@ where
|
||||
|
||||
let worker =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||
let observed_store = store.clone();
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
worker,
|
||||
@@ -375,7 +379,16 @@ where
|
||||
.await
|
||||
.unwrap();
|
||||
worker.enable_worker_metadata_write_through().unwrap();
|
||||
worker
|
||||
(worker, observed_store)
|
||||
}
|
||||
|
||||
async fn make_worker_with_manifest<C>(manifest_toml: &str, client: C) -> Worker<C, TestStore>
|
||||
where
|
||||
C: LlmClient + Clone + Send + Sync + 'static,
|
||||
{
|
||||
make_worker_with_manifest_and_store(manifest_toml, client)
|
||||
.await
|
||||
.0
|
||||
}
|
||||
|
||||
async fn make_worker(client: MockClient) -> Worker<MockClient, TestStore> {
|
||||
@@ -922,7 +935,7 @@ async fn pre_run_compact_publishes_runtime_progress_phases() {
|
||||
|
||||
let session_before = worker.session_id();
|
||||
let segment_before = worker.segment_id();
|
||||
worker.try_pre_run_compact().await;
|
||||
worker.try_pre_run_compact().await.unwrap();
|
||||
assert_eq!(worker.session_id(), session_before);
|
||||
assert_ne!(worker.segment_id(), segment_before);
|
||||
|
||||
@@ -1083,7 +1096,7 @@ async fn pre_run_compact_failure_clears_runtime_progress() {
|
||||
let _ = drain(&mut rx);
|
||||
|
||||
// Best-effort: returns Ok(()) even on failure and clears runtime progress.
|
||||
worker.try_pre_run_compact().await;
|
||||
worker.try_pre_run_compact().await.unwrap();
|
||||
|
||||
let events = drain(&mut rx);
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
@@ -1104,8 +1117,11 @@ async fn pre_run_compact_failure_clears_runtime_progress() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
||||
let worker =
|
||||
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()).await;
|
||||
let (worker, observed_store) =
|
||||
make_worker_with_manifest_and_store(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new())
|
||||
.await;
|
||||
let source_session = worker.session_id();
|
||||
let source_segment = worker.segment_id();
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, shutdown_receiver) =
|
||||
@@ -1201,6 +1217,15 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let completion_request_id = "subworker-completion-during-compact";
|
||||
handle
|
||||
.send(Method::NotifyTracked {
|
||||
notification_request_id: completion_request_id.into(),
|
||||
message: "subworker completed".into(),
|
||||
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
||||
})
|
||||
.await
|
||||
.expect("send completion notification during compact");
|
||||
let shutdown = protocol::WorkerCommandEnvelope::new(4);
|
||||
handle
|
||||
.send(Method::Shutdown { command: shutdown })
|
||||
@@ -1230,6 +1255,18 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
||||
.await
|
||||
.expect("controller shutdown timeout")
|
||||
.expect("shutdown confirmation");
|
||||
|
||||
let persisted = observed_store
|
||||
.read_all(source_session, source_segment)
|
||||
.expect("read source Segment after shutdown");
|
||||
assert!(persisted.iter().any(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
LogEntry::Extension { domain, payload, .. }
|
||||
if domain == "worker.pending_activations.v1"
|
||||
&& payload.to_string().contains(completion_request_id)
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1783,7 +1783,9 @@ async fn run_with_unresolved_segment_emits_alert_and_placeholder() {
|
||||
async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let worker = make_worker(client).await;
|
||||
let worker = make_worker(client)
|
||||
.await
|
||||
.with_notification_coalesce_delay(std::time::Duration::from_millis(20));
|
||||
let handle = spawn_controller(worker).await;
|
||||
let mut rx = handle.subscribe();
|
||||
|
||||
@@ -1791,7 +1793,6 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
||||
.send(Method::Notify {
|
||||
notification_request_id: protocol::new_submission_request_id(),
|
||||
message: "turn finished".into(),
|
||||
auto_run: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1883,10 +1884,12 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_while_idle_with_auto_run_false_waits_for_explicit_run() {
|
||||
async fn repeated_notify_while_idle_coalesces_and_auto_starts_one_turn() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let worker = make_worker(client).await;
|
||||
let worker = make_worker(client)
|
||||
.await
|
||||
.with_notification_coalesce_delay(std::time::Duration::from_millis(50));
|
||||
let handle = spawn_controller(worker).await;
|
||||
let notification_request_id = protocol::new_submission_request_id();
|
||||
|
||||
@@ -1895,26 +1898,18 @@ async fn notify_while_idle_with_auto_run_false_waits_for_explicit_run() {
|
||||
.send(Method::Notify {
|
||||
notification_request_id: notification_request_id.clone(),
|
||||
message: "progress snapshot".into(),
|
||||
auto_run: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
|
||||
assert!(
|
||||
client_for_assert.captured_requests().is_empty(),
|
||||
"weak Notify must not stage RunForNotification while idle"
|
||||
);
|
||||
|
||||
handle
|
||||
.send(Method::submit_text(
|
||||
protocol::new_submission_request_id(),
|
||||
"continue",
|
||||
))
|
||||
.send(Method::Notify {
|
||||
notification_request_id: "notify-coalesced-2".into(),
|
||||
message: "second coalesced notification".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
if !client_for_assert.captured_requests().is_empty() {
|
||||
@@ -1922,30 +1917,26 @@ async fn notify_while_idle_with_auto_run_false_waits_for_explicit_run() {
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"explicit run did not reach the mock LLM"
|
||||
"coalescing deadline did not start a notification turn"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||
let requests = client_for_assert.captured_requests();
|
||||
assert_eq!(requests.len(), 1, "coalesced notifications need one turn");
|
||||
let notifications = requests[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_text())
|
||||
.filter(|text| text.contains("[Notification]"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"explicit run should drain the queued notification"
|
||||
);
|
||||
let notify_in_request = requests[0].items.iter().any(|i| {
|
||||
i.as_text()
|
||||
.is_some_and(|t| t.contains("[Notification]") && t.contains("progress snapshot"))
|
||||
});
|
||||
assert!(
|
||||
notify_in_request,
|
||||
"queued weak notification must be history-backed on the next explicit run; got items: {:?}",
|
||||
requests[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| i.as_text())
|
||||
.collect::<Vec<_>>()
|
||||
notifications.len(),
|
||||
2,
|
||||
"duplicate receipt must not duplicate content"
|
||||
);
|
||||
assert!(notifications[0].contains("progress snapshot"));
|
||||
assert!(notifications[1].contains("second coalesced notification"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2105,7 +2096,6 @@ async fn notify_while_running_does_not_emit_already_running_error() {
|
||||
.send(Method::Notify {
|
||||
notification_request_id: protocol::new_submission_request_id(),
|
||||
message: "ping".into(),
|
||||
auto_run: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2137,7 +2127,7 @@ async fn notify_while_running_does_not_emit_already_running_error() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weak_notify_while_running_is_deduped_and_survives_until_next_submit() {
|
||||
async fn notify_while_running_is_deduped_and_survives_until_next_model_boundary() {
|
||||
let client = MockClient::sequential(vec![
|
||||
MockResponse::Hang(Vec::new()),
|
||||
MockResponse::Complete(simple_text_events()),
|
||||
@@ -2160,7 +2150,6 @@ async fn weak_notify_while_running_is_deduped_and_survives_until_next_submit() {
|
||||
.send(Method::Notify {
|
||||
notification_request_id: notification_request_id.clone(),
|
||||
message: "durable weak notice".into(),
|
||||
auto_run: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2586,11 +2575,11 @@ async fn drain_until<F: FnMut(&Event) -> bool>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause mid-stream, then Resume: status round-trips Running →
|
||||
/// Paused → Running → Idle, and the final history contains exactly
|
||||
/// one user turn plus the assistant reply produced by the resume call.
|
||||
/// Paused → Running → Idle. A notification accepted while Paused must not
|
||||
/// auto-resume the Worker, and explicit Resume must inject it while preserving
|
||||
/// the interrupted turn's history consistency.
|
||||
#[tokio::test]
|
||||
async fn pause_then_resume_transitions_and_preserves_history_consistency() {
|
||||
async fn pause_then_resume_preserves_notifications_and_history_consistency() {
|
||||
// Response 1: hang after opening a text block (no stop / completed),
|
||||
// so the Engine is parked inside the stream read and `cancel_rx`
|
||||
// races it cleanly on Method::Pause.
|
||||
@@ -2608,7 +2597,10 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() {
|
||||
}),
|
||||
]);
|
||||
let client = MockClient::sequential(vec![hang, ok]);
|
||||
let worker = make_worker(client).await;
|
||||
let client_for_assert = client.clone();
|
||||
let worker = make_worker(client)
|
||||
.await
|
||||
.with_notification_coalesce_delay(std::time::Duration::from_millis(20));
|
||||
let handle = spawn_controller(worker).await;
|
||||
let mut rx = handle.subscribe();
|
||||
|
||||
@@ -2654,6 +2646,25 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Paused);
|
||||
|
||||
handle
|
||||
.send(Method::Notify {
|
||||
notification_request_id: "notify-while-paused".into(),
|
||||
message: "resume context".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
|
||||
assert_eq!(
|
||||
handle.shared_state.catalog_status(),
|
||||
WorkerStatus::Paused,
|
||||
"notification deadline must not auto-resume a paused Worker"
|
||||
);
|
||||
assert_eq!(
|
||||
client_for_assert.captured_requests().len(),
|
||||
1,
|
||||
"paused notification must wait for an explicit resume"
|
||||
);
|
||||
|
||||
handle
|
||||
.send(Method::Resume {
|
||||
command: worker_command(&handle),
|
||||
@@ -2674,10 +2685,15 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() {
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
|
||||
let requests = client_for_assert.captured_requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(requests[1].items.iter().any(|item| {
|
||||
item.as_text()
|
||||
.is_some_and(|text| text.contains("[Notification]") && text.contains("resume context"))
|
||||
}));
|
||||
|
||||
// History consistency: exactly [user "hello", assistant
|
||||
// "resumed output"]. No artifacts from the aborted stream
|
||||
// (partial text is not committed), no orphan tool_use.
|
||||
// History consistency: the interrupted partial response is absent, while
|
||||
// the queued notification is committed on the explicit resume.
|
||||
let history = history_from_sink(&handle);
|
||||
let roles: Vec<&str> = history
|
||||
.iter()
|
||||
@@ -2692,8 +2708,8 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() {
|
||||
.collect();
|
||||
assert_eq!(
|
||||
roles,
|
||||
vec!["user", "assistant"],
|
||||
"history = user + assistant only; got {history:?}"
|
||||
vec!["user", "system", "assistant"],
|
||||
"history should retain user input, queued notification, and resumed output; got {history:?}"
|
||||
);
|
||||
let assistant_text = history
|
||||
.iter()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user