Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ─────────────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions crates/temper-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
19 changes: 19 additions & 0 deletions crates/temper-server/src/observe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,25 @@ pub struct ActionDetail {
pub guards: Vec<String>,
/// Effects (Debug representation).
pub effects: Vec<String>,
/// Parameters this action accepts, in spec order.
#[serde(default)]
pub params: Vec<ActionParamDetail>,
/// 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.
Expand Down
60 changes: 60 additions & 0 deletions crates/temper-server/src/observe/mod_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 12 additions & 1 deletion crates/temper-server/src/observe/specs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/temper-server/tests/wasm_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
Loading