diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c29f86dc..a82ad9f7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -265,6 +265,16 @@ jobs: - name: cargo test --workspace -- --skip dst_ run: cargo test --workspace -- --skip dst_ + # spec_validate_endpoint declares required-features = ["observe"]. The + # workspace run above usually still covers it via feature unification + # (temper-cli/temper-mcp enable temper-server's observe feature), but + # that coverage is incidental — it evaporates if those edges change. + # Run the observe-gated tests explicitly so coverage is guaranteed. + - name: cargo test observe-gated tests + run: | + cargo test -p temper-server --features observe --test spec_validate_endpoint + cargo test -p temper-server --features observe --lib observe:: + # ───────────────────────────────────────────────────── # Gate 3b: DST/platform coverage (matrixed) # ───────────────────────────────────────────────────── diff --git a/crates/temper-server/Cargo.toml b/crates/temper-server/Cargo.toml index 6ce2378df..0ed9c51e5 100644 --- a/crates/temper-server/Cargo.toml +++ b/crates/temper-server/Cargo.toml @@ -6,6 +6,10 @@ license.workspace = true rust-version.workspace = true description = "HTTP server assembly for Temper entity services" +[[test]] +name = "spec_validate_endpoint" +required-features = ["observe"] + [features] default = [] observe = ["temper-verify"] diff --git a/crates/temper-server/src/observe/mod.rs b/crates/temper-server/src/observe/mod.rs index fbd3e3cfd..8a0c58f78 100644 --- a/crates/temper-server/src/observe/mod.rs +++ b/crates/temper-server/src/observe/mod.rs @@ -74,6 +74,25 @@ pub struct ActionDetail { pub guards: Vec, /// Effects (Debug representation). pub effects: Vec, + /// Parameters this action accepts, in spec order. + #[serde(default)] + pub params: Vec, + /// Agent-facing hint from the spec (empty when the spec has none). + #[serde(default)] + pub hint: String, +} + +/// A single action parameter: name plus its declared type ("string" default). +#[derive(Serialize, Deserialize)] +pub struct ActionParamDetail { + /// Parameter name exactly as declared in the spec's `params` list. + pub name: String, + /// Declared parameter type, serialized under the JSON key `"type"`. + /// Bare-named params carry `"string"`; typed params carry the spec's + /// type name (e.g. `"uint64"`). This is an OPEN set — generated clients + /// must not assume a closed enum. + #[serde(rename = "type")] + pub param_type: String, } /// Detail of a single invariant. diff --git a/crates/temper-server/src/observe/mod_test.rs b/crates/temper-server/src/observe/mod_test.rs index b5bf6abd0..9c7a44460 100644 --- a/crates/temper-server/src/observe/mod_test.rs +++ b/crates/temper-server/src/observe/mod_test.rs @@ -874,6 +874,66 @@ async fn test_get_spec_detail_found() { assert_eq!(detail.entity_type, "Order"); assert!(!detail.states.is_empty()); assert!(!detail.actions.is_empty()); + + // The params/hint contract (consumed by generated typed clients): + // AddItem declares two bare-named params (default "string" type, spec + // order preserved) and a hint; RemoveItem declares no hint (empty, not + // null). + let add_item = detail + .actions + .iter() + .find(|a| a.name == "AddItem") + .expect("Order fixture declares AddItem"); + let param_pairs: Vec<(&str, &str)> = add_item + .params + .iter() + .map(|p| (p.name.as_str(), p.param_type.as_str())) + .collect(); + assert_eq!( + param_pairs, + vec![("ProductId", "string"), ("Quantity", "string")] + ); + assert!(add_item.hint.starts_with("Add a product")); + let remove_item = detail + .actions + .iter() + .find(|a| a.name == "RemoveItem") + .expect("Order fixture declares RemoveItem"); + assert_eq!(remove_item.params.len(), 1); + assert_eq!(remove_item.params[0].name, "ItemId"); + assert_eq!(remove_item.hint, ""); + + // Wire-level contract: the type field serializes under the key "type", + // and params are objects, not bare strings. + let raw: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let raw_add_item = raw["actions"] + .as_array() + .unwrap() + .iter() + .find(|a| a["name"] == "AddItem") + .unwrap(); + assert_eq!(raw_add_item["params"][0]["name"], "ProductId"); + assert_eq!(raw_add_item["params"][0]["type"], "string"); + assert_eq!(raw_add_item["hint"], add_item.hint); +} + +#[test] +fn action_param_detail_serializes_typed_params_under_type_key() { + // Typed params (e.g. `{ name = "sleep_seconds", type = "uint64" }` in a + // spec) reach ActionParamDetail through ActionParam::name()/param_type(); + // this locks the wire shape those values serialize into. + let detail = ActionParamDetail { + name: "sleep_seconds".to_string(), + param_type: "uint64".to_string(), + }; + let json = serde_json::to_value(&detail).unwrap(); + assert_eq!( + json, + serde_json::json!({"name": "sleep_seconds", "type": "uint64"}) + ); + let back: ActionParamDetail = serde_json::from_value(json).unwrap(); + assert_eq!(back.name, "sleep_seconds"); + assert_eq!(back.param_type, "uint64"); } #[tokio::test] diff --git a/crates/temper-server/src/observe/specs.rs b/crates/temper-server/src/observe/specs.rs index dab48e848..69905e8c4 100644 --- a/crates/temper-server/src/observe/specs.rs +++ b/crates/temper-server/src/observe/specs.rs @@ -8,7 +8,9 @@ use crate::authz::{observe_tenant_scope, require_observe_auth}; use crate::registry::VerificationStatus; use crate::state::ServerState; -use super::{ActionDetail, InvariantDetail, SpecDetail, SpecSummary, StateVarDetail}; +use super::{ + ActionDetail, ActionParamDetail, InvariantDetail, SpecDetail, SpecSummary, StateVarDetail, +}; mod load_dir; mod load_inline; @@ -115,6 +117,15 @@ pub(crate) async fn handle_get_spec_detail( to: a.to.clone(), guards: a.guard.iter().map(|g| format!("{g:?}")).collect(), effects: a.effect.iter().map(|e| format!("{e:?}")).collect(), + params: a + .params + .iter() + .map(|p| ActionParamDetail { + name: p.name().to_string(), + param_type: p.param_type().to_string(), + }) + .collect(), + hint: a.hint.clone().unwrap_or_default(), }) .collect(), invariants: automaton diff --git a/crates/temper-server/tests/wasm_dispatch.rs b/crates/temper-server/tests/wasm_dispatch.rs index 50c4b29ff..2619d3b1b 100644 --- a/crates/temper-server/tests/wasm_dispatch.rs +++ b/crates/temper-server/tests/wasm_dispatch.rs @@ -354,7 +354,7 @@ async fn persisted_wasm_modules_are_lazy_compiled_on_first_invoke() { "EchoTest", "echo-lazy-1", &["Done", "Failed"], - Duration::from_secs(5), + Duration::from_secs(45), ) .await; assert_eq!(final_status, "Done"); @@ -423,7 +423,7 @@ async fn persisted_wasm_modules_with_legacy_db_blob_fallback_execute_after_start "EchoTest", "echo-legacy-hash", &["Done", "Failed"], - Duration::from_secs(5), + Duration::from_secs(45), ) .await; assert_eq!(final_status, "Done");