1use std::sync::LazyLock;
4use std::time::Duration;
5
6use rlmesh_proto::core::v1::{AutoresetMode, EnvContract};
7use rlmesh_proto::spaces::v1::SpaceSpec;
8use serde::{Deserialize, Serialize};
9
10static EMPTY_SPACE_SPEC: LazyLock<SpaceSpec> = LazyLock::new(SpaceSpec::default);
14
15#[derive(Debug, Clone, PartialEq)]
19pub struct RuntimeSessionSpec {
20 pub session_id: String,
24 pub env_id: String,
29 pub env_component_id: String,
30 pub model_component_id: String,
31 pub workflow_edition: String,
34 pub env_contract: EnvContract,
35 pub num_envs: usize,
36 pub base_seed: Option<i64>,
37 pub max_episodes: Option<u64>,
38 pub close_env_on_end: bool,
39 pub limits: RuntimeLimits,
40}
41
42impl RuntimeSessionSpec {
43 pub fn validate(&self) -> Result<(), String> {
44 if self.session_id.trim().is_empty() {
45 return Err("runtime session_id must not be empty".to_string());
46 }
47 if self.env_id.trim().is_empty() {
48 return Err("runtime env_id must not be empty".to_string());
49 }
50 if self.env_component_id.trim().is_empty() {
51 return Err("runtime env_component_id must not be empty".to_string());
52 }
53 if self.model_component_id.trim().is_empty() {
54 return Err("runtime model_component_id must not be empty".to_string());
55 }
56 if self.num_envs == 0 {
57 return Err("runtime num_envs must be greater than zero".to_string());
58 }
59 if self.observation_space().is_none() {
60 return Err("runtime env_contract is missing observation_space".to_string());
61 }
62 if self.action_space().is_none() {
63 return Err("runtime env_contract is missing action_space".to_string());
64 }
65 if self.max_episodes == Some(0) {
66 return Err("runtime max_episodes must be greater than zero when set".to_string());
67 }
68 if !rlmesh_proto::is_supported_edition(&self.workflow_edition) {
73 return Err(format!(
74 "runtime cannot drive workflow edition {:?}; this build implements {:?}",
75 self.workflow_edition,
76 rlmesh_proto::SUPPORTED_WORKFLOW_EDITIONS
77 ));
78 }
79 if AutoresetMode::try_from(self.env_contract.autoreset_mode).is_err() {
83 return Err(format!(
84 "unknown autoreset mode {} on the wire; this build supports \
85 UNSPECIFIED, NEXT_STEP, SAME_STEP, DISABLED only",
86 self.env_contract.autoreset_mode
87 ));
88 }
89 if self.env_contract.autoreset_mode == AutoresetMode::SameStep as i32 {
95 return Err(
96 "SAME_STEP autoreset is reserved but not yet supported by the runtime; \
97 construct the env with NEXT_STEP or DISABLED autoreset"
98 .to_string(),
99 );
100 }
101 if self.num_envs > 1 && self.env_contract.autoreset_mode != AutoresetMode::NextStep as i32 {
111 return Err(
112 "vectorized runtime sessions (num_envs > 1) require NEXT_STEP autoreset; \
113 DISABLED autoreset needs per-lane reset, which is unavailable for stock \
114 gymnasium vector envs. Use NEXT_STEP autoreset, or run with num_envs == 1."
115 .to_string(),
116 );
117 }
118 Ok(())
119 }
120
121 pub fn env_context(&self) -> crate::hooks::RuntimeEnvContext {
122 crate::hooks::RuntimeEnvContext {
123 env_id: self.env_id.clone(),
124 env_component_id: self.env_component_id.clone(),
125 model_component_id: self.model_component_id.clone(),
126 }
127 }
128
129 pub fn observation_space(&self) -> Option<&SpaceSpec> {
136 self.env_contract
137 .spec
138 .as_ref()
139 .and_then(|spec| spec.observation_space.as_ref())
140 }
141
142 pub fn action_space(&self) -> Option<&SpaceSpec> {
147 self.env_contract
148 .spec
149 .as_ref()
150 .and_then(|spec| spec.action_space.as_ref())
151 }
152
153 pub(crate) fn observation_space_validated(&self) -> &SpaceSpec {
156 debug_assert!(
157 self.observation_space().is_some(),
158 "observation_space accessed before validate()"
159 );
160 self.observation_space()
162 .unwrap_or_else(|| &EMPTY_SPACE_SPEC)
163 }
164
165 pub(crate) fn action_space_validated(&self) -> &SpaceSpec {
168 debug_assert!(
169 self.action_space().is_some(),
170 "action_space accessed before validate()"
171 );
172 self.action_space().unwrap_or_else(|| &EMPTY_SPACE_SPEC)
174 }
175}
176
177#[derive(Debug, Clone, PartialEq)]
180pub struct RuntimeReport {
181 pub session_id: String,
182 pub env_id: String,
183 pub total_steps: i64,
184 pub total_episodes: i64,
185 pub telemetry: crate::telemetry::Snapshot,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct RuntimeLimits {
195 #[serde(
196 default = "default_connect_timeout",
197 rename = "envConnectTimeoutMs",
198 serialize_with = "duration_millis::serialize",
199 deserialize_with = "duration_millis::deserialize"
200 )]
201 pub env_connect_timeout: Duration,
202 #[serde(
203 default = "default_model_connect_timeout",
204 rename = "modelConnectTimeoutMs",
205 serialize_with = "duration_millis::serialize",
206 deserialize_with = "duration_millis::deserialize"
207 )]
208 pub model_connect_timeout: Duration,
209 #[serde(
210 default = "default_configure_route_timeout",
211 rename = "configureRouteTimeoutMs",
212 serialize_with = "duration_millis::serialize",
213 deserialize_with = "duration_millis::deserialize"
214 )]
215 pub configure_route_timeout: Duration,
216 #[serde(
217 default = "default_env_reset_timeout",
218 rename = "envResetTimeoutMs",
219 serialize_with = "duration_millis::serialize",
220 deserialize_with = "duration_millis::deserialize"
221 )]
222 pub env_reset_timeout: Duration,
223 #[serde(
224 default = "default_model_predict_timeout",
225 rename = "modelPredictTimeoutMs",
226 serialize_with = "duration_millis::serialize",
227 deserialize_with = "duration_millis::deserialize"
228 )]
229 pub model_predict_timeout: Duration,
230 #[serde(
231 default = "default_env_step_timeout",
232 rename = "envStepTimeoutMs",
233 serialize_with = "duration_millis::serialize",
234 deserialize_with = "duration_millis::deserialize"
235 )]
236 pub env_step_timeout: Duration,
237 #[serde(
238 default = "default_service_close_timeout",
239 rename = "serviceCloseTimeoutMs",
240 serialize_with = "duration_millis::serialize",
241 deserialize_with = "duration_millis::deserialize"
242 )]
243 pub service_close_timeout: Duration,
244 #[serde(
249 default = "default_telemetry_window",
250 rename = "telemetryWindowMs",
251 serialize_with = "duration_millis::serialize",
252 deserialize_with = "duration_millis::deserialize"
253 )]
254 pub telemetry_window: Duration,
255}
256
257impl Default for RuntimeLimits {
258 fn default() -> Self {
259 Self {
260 env_connect_timeout: default_connect_timeout(),
261 model_connect_timeout: default_model_connect_timeout(),
262 configure_route_timeout: default_configure_route_timeout(),
263 env_reset_timeout: default_env_reset_timeout(),
264 model_predict_timeout: default_model_predict_timeout(),
265 env_step_timeout: default_env_step_timeout(),
266 service_close_timeout: default_service_close_timeout(),
267 telemetry_window: default_telemetry_window(),
268 }
269 }
270}
271
272impl RuntimeLimits {
273 pub fn env_step_timeout_ms(&self) -> i64 {
274 duration_ms_i64(self.env_step_timeout)
275 }
276
277 pub fn env_reset_timeout_ms(&self) -> i64 {
278 duration_ms_i64(self.env_reset_timeout)
279 }
280}
281
282fn default_connect_timeout() -> Duration {
283 Duration::from_secs(60)
284}
285
286fn default_model_connect_timeout() -> Duration {
287 Duration::from_secs(600)
288}
289
290fn default_configure_route_timeout() -> Duration {
291 Duration::from_secs(600)
292}
293
294fn default_env_reset_timeout() -> Duration {
295 Duration::from_secs(300)
296}
297
298fn default_model_predict_timeout() -> Duration {
299 Duration::from_secs(300)
300}
301
302fn default_env_step_timeout() -> Duration {
303 Duration::from_secs(300)
304}
305
306fn default_service_close_timeout() -> Duration {
307 Duration::from_secs(5)
308}
309
310fn default_telemetry_window() -> Duration {
311 Duration::from_secs(1)
312}
313
314fn duration_ms_i64(duration: Duration) -> i64 {
315 duration.as_millis().try_into().unwrap_or(i64::MAX)
316}
317
318mod duration_millis {
319 use std::time::Duration;
320
321 use serde::{Deserialize, Deserializer, Serializer};
322
323 pub(super) fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
324 where
325 S: Serializer,
326 {
327 let millis = duration.as_millis().try_into().unwrap_or(u64::MAX);
328 serializer.serialize_u64(millis)
329 }
330
331 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
332 where
333 D: Deserializer<'de>,
334 {
335 Ok(Duration::from_millis(u64::deserialize(deserializer)?))
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use std::time::Duration;
342
343 use rlmesh_proto::core::v1::{AutoresetMode, EnvContract, EnvSpec};
344 use rlmesh_proto::spaces::v1::SpaceSpec;
345 use serde_json::json;
346
347 use super::{RuntimeLimits, RuntimeSessionSpec};
348
349 fn valid_spec() -> RuntimeSessionSpec {
350 RuntimeSessionSpec {
351 session_id: "session".to_string(),
352 env_id: "env-id".to_string(),
353 env_component_id: "env".to_string(),
354 model_component_id: "model".to_string(),
355 workflow_edition: rlmesh_proto::CURRENT_WORKFLOW_EDITION.to_string(),
356 env_contract: EnvContract {
357 spec: Some(EnvSpec {
358 observation_space: Some(SpaceSpec::default()),
359 action_space: Some(SpaceSpec::default()),
360 ..Default::default()
361 }),
362 num_envs: 1,
363 ..Default::default()
364 },
365 num_envs: 1,
366 base_seed: None,
367 max_episodes: Some(1),
368 close_env_on_end: true,
369 limits: RuntimeLimits::default(),
370 }
371 }
372
373 #[test]
374 fn validate_rejects_an_edition_the_runtime_cannot_drive() {
375 let mut spec = valid_spec();
376 spec.workflow_edition = "2099.01".to_string();
377 let error = spec.validate().unwrap_err();
378 assert!(
379 error.contains("2099.01") && error.contains("cannot drive"),
380 "expected an edition-refusal error, got: {error}"
381 );
382
383 spec.workflow_edition = rlmesh_proto::CURRENT_WORKFLOW_EDITION.to_string();
385 assert!(spec.validate().is_ok());
386 }
387
388 #[test]
389 fn space_accessors_return_none_on_unvalidated_spec() {
390 let mut spec = valid_spec();
391 spec.env_contract = EnvContract::default();
394
395 assert!(spec.observation_space().is_none());
396 assert!(spec.action_space().is_none());
397 }
398
399 #[test]
400 fn space_accessors_return_some_on_populated_spec() {
401 let spec = valid_spec();
402 assert!(spec.observation_space().is_some());
403 assert!(spec.action_space().is_some());
404 }
405
406 #[test]
407 fn validate_accepts_vectorized_next_step_runtime_sessions() {
408 let mut spec = valid_spec();
411 spec.num_envs = 4;
412 spec.env_contract.num_envs = 4;
413 spec.env_contract.autoreset_mode = AutoresetMode::NextStep as i32;
414
415 assert!(spec.validate().is_ok());
416 }
417
418 #[test]
419 fn validate_rejects_disabled_vectorized_sessions() {
420 for mode in [AutoresetMode::Disabled, AutoresetMode::Unspecified] {
424 let mut spec = valid_spec();
425 spec.num_envs = 4;
426 spec.env_contract.num_envs = 4;
427 spec.env_contract.autoreset_mode = mode as i32;
428
429 let error = spec.validate().unwrap_err();
430 assert!(
431 error.contains("NEXT_STEP"),
432 "expected a NEXT_STEP-guidance rejection for {mode:?}, got: {error}"
433 );
434 }
435 }
436
437 #[test]
438 fn validate_rejects_same_step_autoreset() {
439 let mut spec = valid_spec();
442 spec.env_contract.autoreset_mode = AutoresetMode::SameStep as i32;
443
444 let error = spec.validate().unwrap_err();
445 assert!(
446 error.contains("SAME_STEP"),
447 "expected SAME_STEP rejection, got: {error}"
448 );
449 }
450
451 #[test]
452 fn runtime_limits_json_uses_explicit_millisecond_fields() {
453 let value = serde_json::to_value(RuntimeLimits::default()).unwrap();
454
455 assert_eq!(value["envConnectTimeoutMs"], json!(60_000));
456 assert_eq!(value["modelConnectTimeoutMs"], json!(600_000));
457 assert_eq!(value["configureRouteTimeoutMs"], json!(600_000));
458 assert_eq!(value["envResetTimeoutMs"], json!(300_000));
459 assert_eq!(value["modelPredictTimeoutMs"], json!(300_000));
460 assert_eq!(value["envStepTimeoutMs"], json!(300_000));
461 assert_eq!(value["serviceCloseTimeoutMs"], json!(5_000));
462 assert_eq!(value["telemetryWindowMs"], json!(1_000));
463 assert!(value.get("envConnectTimeout").is_none());
464
465 let parsed: RuntimeLimits = serde_json::from_value(json!({
466 "envConnectTimeoutMs": 1,
467 "modelConnectTimeoutMs": 2,
468 "configureRouteTimeoutMs": 3,
469 "envResetTimeoutMs": 4,
470 "modelPredictTimeoutMs": 5,
471 "envStepTimeoutMs": 6,
472 "serviceCloseTimeoutMs": 7,
473 "telemetryWindowMs": 8
474 }))
475 .unwrap();
476
477 assert_eq!(parsed.env_connect_timeout, Duration::from_millis(1));
478 assert_eq!(parsed.model_connect_timeout, Duration::from_millis(2));
479 assert_eq!(parsed.configure_route_timeout, Duration::from_millis(3));
480 assert_eq!(parsed.env_reset_timeout, Duration::from_millis(4));
481 assert_eq!(parsed.model_predict_timeout, Duration::from_millis(5));
482 assert_eq!(parsed.env_step_timeout, Duration::from_millis(6));
483 assert_eq!(parsed.service_close_timeout, Duration::from_millis(7));
484 assert_eq!(parsed.telemetry_window, Duration::from_millis(8));
485 }
486
487 #[test]
488 fn runtime_limits_reject_legacy_unsuffixed_fields() {
489 let error = serde_json::from_value::<RuntimeLimits>(json!({
490 "envConnectTimeout": 1
491 }))
492 .unwrap_err();
493
494 assert!(error.to_string().contains("envConnectTimeout"));
495 }
496}