feat: merge develop into hare/develop
This commit is contained in:
Generated
+111
-17
@@ -168,6 +168,34 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api-macros"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"api-macros-impl",
|
||||
"axum",
|
||||
"futures",
|
||||
"oas3",
|
||||
"reqwest",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"tokio",
|
||||
"trybuild",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "api-macros-impl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
@@ -624,15 +652,16 @@ dependencies = [
|
||||
"futures",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"runtime-api",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"server-api",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"uuid",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1058,6 +1087,7 @@ dependencies = [
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.117",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2778,6 +2808,24 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
|
||||
|
||||
[[package]]
|
||||
name = "oas3"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da5a5aa72eddcc53edfd06f287a2c10f872d88e8b72c650234cd8a227572424a"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"http",
|
||||
"indexmap",
|
||||
"log",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.7.1"
|
||||
@@ -3238,6 +3286,15 @@ dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -3671,6 +3728,19 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runtime-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"api-macros",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.37.0"
|
||||
@@ -4080,6 +4150,23 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "server-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"api-macros",
|
||||
"protocol",
|
||||
"runtime-api",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"webauthn-rs-proto",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "session-analytics"
|
||||
version = "0.1.0"
|
||||
@@ -4107,8 +4194,10 @@ dependencies = [
|
||||
"agen",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"filetime",
|
||||
"fs4",
|
||||
"futures",
|
||||
"libc",
|
||||
"protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4827,6 +4916,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.13+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -5883,6 +5984,9 @@ name = "winnow"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
@@ -6010,7 +6114,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6043,6 +6146,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"server-api",
|
||||
"session-metrics",
|
||||
"session-store",
|
||||
"sha2 0.11.0",
|
||||
@@ -6057,7 +6161,6 @@ dependencies = [
|
||||
"tungstenite 0.28.0",
|
||||
"uuid",
|
||||
"workdir",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6076,9 +6179,11 @@ dependencies = [
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"runtime-api",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"server-api",
|
||||
"session-store",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
@@ -6094,21 +6199,9 @@ dependencies = [
|
||||
"uuid",
|
||||
"workdir",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "workspace-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ts-rs",
|
||||
"webauthn-rs-proto",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
@@ -6171,6 +6264,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"server-api",
|
||||
"session-analytics",
|
||||
"session-store",
|
||||
"tempfile",
|
||||
@@ -6180,7 +6274,6 @@ dependencies = [
|
||||
"tui",
|
||||
"uuid",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6222,10 +6315,12 @@ dependencies = [
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"runtime-api",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"server-api",
|
||||
"sha2 0.11.0",
|
||||
"ssh-key",
|
||||
"tempfile",
|
||||
@@ -6244,7 +6339,6 @@ dependencies = [
|
||||
"workdir",
|
||||
"worker",
|
||||
"worker-runtime",
|
||||
"workspace-api",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
||||
+11
-3
@@ -4,6 +4,8 @@ members = [
|
||||
"crates/client",
|
||||
"crates/agen",
|
||||
"crates/agen-macros",
|
||||
"crates/api-macros",
|
||||
"crates/api-macros-impl",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/secrets",
|
||||
@@ -11,6 +13,8 @@ members = [
|
||||
"crates/mcp",
|
||||
"crates/worker",
|
||||
"crates/worker-runtime",
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
@@ -28,7 +32,6 @@ members = [
|
||||
"crates/ticket",
|
||||
"crates/merge-request",
|
||||
"crates/project-record",
|
||||
"crates/workspace-api",
|
||||
"crates/workspace-server",
|
||||
"tests/e2e",
|
||||
]
|
||||
@@ -36,6 +39,8 @@ default-members = [
|
||||
"crates/client",
|
||||
"crates/agen",
|
||||
"crates/agen-macros",
|
||||
"crates/api-macros",
|
||||
"crates/api-macros-impl",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/secrets",
|
||||
@@ -43,6 +48,8 @@ default-members = [
|
||||
"crates/mcp",
|
||||
"crates/worker",
|
||||
"crates/worker-runtime",
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
@@ -60,7 +67,6 @@ default-members = [
|
||||
"crates/ticket",
|
||||
"crates/merge-request",
|
||||
"crates/project-record",
|
||||
"crates/workspace-api",
|
||||
"crates/workspace-server",
|
||||
]
|
||||
|
||||
@@ -79,6 +85,7 @@ debug = false
|
||||
client = { path = "crates/client" }
|
||||
agen = { path = "crates/agen", version = "0.2" }
|
||||
agen-macros = { path = "crates/agen-macros", version = "0.2" }
|
||||
api-macros = { path = "crates/api-macros" }
|
||||
manifest = { path = "crates/manifest" }
|
||||
mcp = { path = "crates/mcp" }
|
||||
lint-common = { path = "crates/lint-common" }
|
||||
@@ -88,7 +95,6 @@ ticket = { path = "crates/ticket" }
|
||||
project-record = { path = "crates/project-record" }
|
||||
worker = { path = "crates/worker" }
|
||||
worker-runtime = { path = "crates/worker-runtime" }
|
||||
workspace-api = { path = "crates/workspace-api" }
|
||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||
yoi = { path = "crates/yoi" }
|
||||
protocol = { path = "crates/protocol" }
|
||||
@@ -96,6 +102,8 @@ session-metrics = { path = "crates/session-metrics" }
|
||||
session-analytics = { path = "crates/session-analytics" }
|
||||
session-store = { path = "crates/session-store" }
|
||||
standalone = { path = "crates/standalone" }
|
||||
runtime-api = { path = "crates/runtime-api" }
|
||||
server-api = { path = "crates/server-api" }
|
||||
secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
config-source = { path = "crates/config-source" }
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user