1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::Arc;
4use std::sync::Mutex;
5
6use serde::{Deserialize, Serialize};
7
8use super::events::{
9 ProcessAwaitOutput, ProcessEventType, ProcessTerminalSemantics, ProcessTerminalState,
10 default_process_event_types,
11};
12use super::validation::{
13 ensure_core_event_types, process_registration_hash, validate_process_registration,
14};
15
16pub type ProcessId = String;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(transparent)]
26pub struct ProcessChangeCursor(u64);
27
28impl ProcessChangeCursor {
29 pub fn initial() -> Self {
30 Self(0)
31 }
32
33 pub fn from_store_sequence(sequence: u64) -> Self {
34 Self(sequence)
35 }
36
37 pub fn store_sequence(self) -> u64 {
38 self.0
39 }
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
43#[serde(transparent)]
44pub struct SessionScopeId(String);
45
46impl SessionScopeId {
47 pub fn new(value: impl Into<String>) -> Self {
48 Self(value.into())
49 }
50
51 pub fn as_str(&self) -> &str {
52 &self.0
53 }
54}
55
56impl fmt::Display for SessionScopeId {
57 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58 formatter.write_str(&self.0)
59 }
60}
61
62impl From<String> for SessionScopeId {
63 fn from(value: String) -> Self {
64 Self::new(value)
65 }
66}
67
68impl From<&str> for SessionScopeId {
69 fn from(value: &str) -> Self {
70 Self::new(value)
71 }
72}
73
74#[derive(Debug, Serialize, Deserialize)]
83#[serde(tag = "type", rename_all = "snake_case")]
84pub enum ProcessInput {
85 ToolCall {
86 call: crate::PreparedToolCall,
87 },
88 Engine {
89 kind: String,
90 #[serde(default)]
91 payload: serde_json::Value,
92 },
93 SessionTurn {
94 create_request: Box<crate::SessionCreateRequest>,
95 turn_input: Box<crate::TurnInput>,
96 output_contract: crate::ToolOutputContract,
97 },
98 External {
99 #[serde(default)]
100 metadata: serde_json::Value,
101 },
102}
103
104impl Clone for ProcessInput {
105 fn clone(&self) -> Self {
106 match self {
107 Self::ToolCall { call } => Self::ToolCall { call: call.clone() },
108 Self::Engine { kind, payload } => Self::Engine {
109 kind: kind.clone(),
110 payload: payload.clone(),
111 },
112 Self::SessionTurn {
113 create_request,
114 turn_input,
115 output_contract,
116 } => Self::SessionTurn {
117 create_request: create_request.clone(),
118 turn_input: turn_input.clone(),
119 output_contract: output_contract.clone(),
120 },
121 Self::External { metadata } => Self::External {
122 metadata: metadata.clone(),
123 },
124 }
125 }
126}
127
128impl PartialEq for ProcessInput {
129 fn eq(&self, other: &Self) -> bool {
130 serde_json::to_value(self).ok() == serde_json::to_value(other).ok()
131 }
132}
133
134impl ProcessInput {
135 pub fn engine_kind(&self) -> &'static str {
136 match self {
137 Self::ToolCall { .. } => "tool",
138 Self::Engine { .. } => "engine",
139 Self::SessionTurn { .. } => "session_turn",
140 Self::External { .. } => "external",
141 }
142 }
143
144 pub fn engine_specific_kind(&self) -> Option<&str> {
145 match self {
146 Self::Engine { kind, .. } => Some(kind.as_str()),
147 _ => None,
148 }
149 }
150}
151
152#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum RecoveryDisposition {
162 Rerunnable,
165 OwnerBound,
169 ExternallyOwned,
172}
173
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ProcessStarted {
180 pub owner: crate::LeaseOwnerIdentity,
181 pub started_at_ms: u64,
182}
183
184#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
190pub struct AbandonRequest {
191 pub requested_by: String,
192 pub requested_at_ms: u64,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub reason: Option<String>,
195}
196
197#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(transparent)]
199pub struct ProcessExecutionEnvRef(String);
200
201impl ProcessExecutionEnvRef {
202 pub fn new(value: impl Into<String>) -> Self {
203 Self(value.into())
204 }
205
206 pub fn as_str(&self) -> &str {
207 &self.0
208 }
209}
210
211impl fmt::Display for ProcessExecutionEnvRef {
212 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213 formatter.write_str(&self.0)
214 }
215}
216
217#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
218#[serde(deny_unknown_fields)]
219pub struct ProcessExecutionEnvSpec {
220 #[serde(default)]
221 pub plugin_options: crate::PluginOptions,
222 #[serde(default)]
223 pub policy: crate::SessionPolicy,
224}
225
226impl ProcessExecutionEnvSpec {
227 pub fn new(plugin_options: crate::PluginOptions, policy: crate::SessionPolicy) -> Self {
228 Self {
229 plugin_options,
230 policy,
231 }
232 }
233
234 pub fn stable_ref(&self) -> Result<ProcessExecutionEnvRef, serde_json::Error> {
235 crate::stable_hash::stable_json_sha256_hex(self)
236 .map(|hash| ProcessExecutionEnvRef::new(format!("process-env:sha256:{hash}")))
237 }
238
239 pub fn to_store_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
240 serde_json::to_vec(self)
241 }
242
243 pub fn from_store_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
244 serde_json::from_slice(bytes)
245 }
246}
247
248#[async_trait::async_trait]
249pub trait ProcessExecutionEnvStore: Send + Sync {
250 fn durability_tier(&self) -> crate::DurabilityTier {
251 crate::DurabilityTier::Inline
252 }
253
254 async fn put_process_execution_env(
255 &self,
256 env_ref: &ProcessExecutionEnvRef,
257 bytes: &[u8],
258 ) -> Result<(), crate::PluginError>;
259
260 async fn get_process_execution_env(
261 &self,
262 env_ref: &ProcessExecutionEnvRef,
263 ) -> Result<Option<Vec<u8>>, crate::PluginError>;
264}
265
266#[derive(Default)]
267pub struct InMemoryProcessExecutionEnvStore {
268 envs: Mutex<BTreeMap<String, Vec<u8>>>,
269}
270
271impl InMemoryProcessExecutionEnvStore {
272 pub fn new() -> Self {
273 Self::default()
274 }
275}
276
277#[async_trait::async_trait]
278impl ProcessExecutionEnvStore for InMemoryProcessExecutionEnvStore {
279 async fn put_process_execution_env(
280 &self,
281 env_ref: &ProcessExecutionEnvRef,
282 bytes: &[u8],
283 ) -> Result<(), crate::PluginError> {
284 self.envs
285 .lock()
286 .map_err(|_| {
287 crate::PluginError::Session("process execution env store lock poisoned".to_string())
288 })?
289 .insert(env_ref.as_str().to_string(), bytes.to_vec());
290 Ok(())
291 }
292
293 async fn get_process_execution_env(
294 &self,
295 env_ref: &ProcessExecutionEnvRef,
296 ) -> Result<Option<Vec<u8>>, crate::PluginError> {
297 Ok(self
298 .envs
299 .lock()
300 .map_err(|_| {
301 crate::PluginError::Session("process execution env store lock poisoned".to_string())
302 })?
303 .get(env_ref.as_str())
304 .cloned())
305 }
306}
307
308pub async fn persist_process_execution_env(
309 env_store: &dyn ProcessExecutionEnvStore,
310 spec: &ProcessExecutionEnvSpec,
311) -> Result<ProcessExecutionEnvRef, crate::PluginError> {
312 let env_ref = spec.stable_ref().map_err(|err| {
313 crate::PluginError::Session(format!("failed to hash process execution env: {err}"))
314 })?;
315 let bytes = spec.to_store_bytes().map_err(|err| {
316 crate::PluginError::Session(format!("failed to encode process execution env: {err}"))
317 })?;
318 env_store
319 .put_process_execution_env(&env_ref, &bytes)
320 .await?;
321 Ok(env_ref)
322}
323
324pub async fn load_process_execution_env(
325 env_store: &dyn ProcessExecutionEnvStore,
326 env_ref: &ProcessExecutionEnvRef,
327) -> Result<ProcessExecutionEnvSpec, crate::PluginError> {
328 let bytes = env_store
329 .get_process_execution_env(env_ref)
330 .await?
331 .ok_or_else(|| {
332 crate::PluginError::Session(format!("missing process execution env `{env_ref}`"))
333 })?;
334 ProcessExecutionEnvSpec::from_store_bytes(&bytes).map_err(|err| {
335 crate::PluginError::Session(format!(
336 "failed to decode process execution env `{env_ref}`: {err}"
337 ))
338 })
339}
340
341#[derive(Clone, Debug, Default, Serialize, Deserialize)]
344pub struct ProcessExecutionContext {
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub causal_invocation: Option<crate::RuntimeInvocation>,
347}
348
349impl ProcessExecutionContext {
350 pub fn with_causal_invocation(mut self, invocation: Option<crate::RuntimeInvocation>) -> Self {
351 self.causal_invocation = invocation;
352 self
353 }
354
355 pub fn is_empty(&self) -> bool {
356 self.causal_invocation.is_none()
357 }
358}
359
360#[derive(Clone)]
361pub struct ProcessOpScope<'scope> {
362 pub(crate) parent_invocation: Option<crate::RuntimeInvocation>,
363 pub(crate) effect_controller: crate::runtime::RuntimeEffectControllerHandle<'scope>,
364 pub(crate) agent_frame_id: Option<crate::AgentFrameId>,
365 pub(crate) target_agent_frame_id: Option<crate::AgentFrameId>,
366}
367
368impl<'scope> ProcessOpScope<'scope> {
369 pub fn new(scoped_effect_controller: crate::ScopedEffectController<'scope>) -> Self {
370 Self {
371 parent_invocation: None,
372 effect_controller: crate::runtime::RuntimeEffectControllerHandle::borrowed(
373 scoped_effect_controller,
374 ),
375 agent_frame_id: None,
376 target_agent_frame_id: None,
377 }
378 }
379
380 pub fn with_parent_invocation(
381 mut self,
382 parent_invocation: Option<crate::RuntimeInvocation>,
383 ) -> Self {
384 self.parent_invocation = parent_invocation;
385 self
386 }
387
388 pub fn with_agent_frame_id(mut self, agent_frame_id: Option<crate::AgentFrameId>) -> Self {
389 self.agent_frame_id = agent_frame_id;
390 self
391 }
392
393 pub fn with_target_agent_frame_id(
394 mut self,
395 agent_frame_id: Option<crate::AgentFrameId>,
396 ) -> Self {
397 self.target_agent_frame_id = agent_frame_id;
398 self
399 }
400
401 pub fn agent_frame_id(&self) -> Option<&str> {
402 self.agent_frame_id.as_deref()
403 }
404
405 pub fn target_agent_frame_id(&self) -> Option<&str> {
406 self.target_agent_frame_id.as_deref()
407 }
408
409 pub(crate) fn controller(&self) -> &dyn crate::RuntimeEffectController {
410 self.effect_controller.controller()
411 }
412}
413
414#[derive(Clone, Debug, Default)]
415pub struct ProcessStartOptions {
416 pub descriptor: Option<ProcessHandleDescriptor>,
417 pub spawn_provenance: Option<ProcessSpawnProvenance>,
425}
426
427#[derive(Clone, Debug, PartialEq, Eq)]
432pub struct ProcessSpawnProvenance {
433 pub originator: ProcessOriginator,
434 pub wake_target: Option<SessionScope>,
435}
436
437impl ProcessStartOptions {
438 pub fn new() -> Self {
439 Self::default()
440 }
441
442 pub fn with_descriptor(mut self, descriptor: ProcessHandleDescriptor) -> Self {
443 self.descriptor = Some(descriptor);
444 self
445 }
446
447 pub fn with_optional_descriptor(mut self, descriptor: Option<ProcessHandleDescriptor>) -> Self {
448 self.descriptor = descriptor;
449 self
450 }
451
452 pub fn with_spawn_provenance(mut self, spawn_provenance: ProcessSpawnProvenance) -> Self {
453 self.spawn_provenance = Some(spawn_provenance);
454 self
455 }
456
457 pub fn execution_context(&self, scope: &ProcessOpScope<'_>) -> ProcessExecutionContext {
458 ProcessExecutionContext {
459 causal_invocation: scope.parent_invocation.clone(),
460 }
461 }
462}
463
464#[derive(Clone, Debug, Serialize, Deserialize)]
466pub struct ProcessStartRequest {
467 pub id: ProcessId,
468 pub input: ProcessInput,
469 pub disposition: RecoveryDisposition,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub env_spec: Option<ProcessExecutionEnvSpec>,
472 pub originator: ProcessOriginator,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub wake_target: Option<SessionScope>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub grant: Option<ProcessStartGrant>,
477 #[serde(default)]
478 pub event_types: Vec<ProcessEventType>,
479}
480
481impl ProcessStartRequest {
482 pub fn new(
483 id: impl Into<ProcessId>,
484 input: ProcessInput,
485 disposition: RecoveryDisposition,
486 originator: ProcessOriginator,
487 ) -> Self {
488 Self {
489 id: id.into(),
490 input,
491 disposition,
492 env_spec: None,
493 originator,
494 wake_target: None,
495 grant: None,
496 event_types: default_process_event_types(),
497 }
498 }
499
500 pub fn external(
503 id: impl Into<ProcessId>,
504 originator: ProcessOriginator,
505 metadata: serde_json::Value,
506 ) -> Self {
507 Self::new(
508 id,
509 ProcessInput::External { metadata },
510 RecoveryDisposition::ExternallyOwned,
511 originator,
512 )
513 }
514
515 pub fn with_env_spec(mut self, env_spec: ProcessExecutionEnvSpec) -> Self {
516 self.env_spec = Some(env_spec);
517 self
518 }
519
520 pub fn with_wake_target(mut self, wake_target: Option<SessionScope>) -> Self {
521 self.wake_target = wake_target;
522 self
523 }
524
525 pub fn with_grant(mut self, grant: Option<ProcessStartGrant>) -> Self {
526 self.grant = grant;
527 self
528 }
529
530 pub fn with_event_types(
531 mut self,
532 event_types: impl IntoIterator<Item = ProcessEventType>,
533 ) -> Self {
534 self.event_types = event_types.into_iter().collect();
535 self
536 }
537
538 pub fn with_extra_event_types(
539 mut self,
540 event_types: impl IntoIterator<Item = ProcessEventType>,
541 ) -> Self {
542 self.event_types.extend(event_types);
543 self
544 }
545
546 pub fn into_registration(self, env_ref: Option<ProcessExecutionEnvRef>) -> ProcessRegistration {
547 ProcessRegistration::new(
548 self.id,
549 self.input,
550 self.disposition,
551 ProcessProvenance::new(self.originator),
552 )
553 .with_event_types(self.event_types)
554 .with_execution_env_ref(env_ref)
555 .with_wake_target(self.wake_target)
556 }
557}
558
559#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
560pub struct SessionScope {
561 pub session_id: String,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub agent_frame_id: Option<crate::AgentFrameId>,
564}
565
566#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
567pub struct ProcessProvenance {
568 pub originator: ProcessOriginator,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub caused_by: Option<crate::CausalRef>,
571}
572
573impl ProcessProvenance {
574 pub fn new(originator: ProcessOriginator) -> Self {
575 Self {
576 originator,
577 caused_by: None,
578 }
579 }
580
581 pub fn host() -> Self {
582 Self::new(ProcessOriginator::host())
583 }
584
585 pub fn session(scope: SessionScope) -> Self {
586 Self::new(ProcessOriginator::session(scope))
587 }
588
589 pub fn with_caused_by(mut self, caused_by: Option<crate::CausalRef>) -> Self {
590 self.caused_by = caused_by;
591 self
592 }
593}
594
595#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
596#[serde(tag = "type", rename_all = "snake_case")]
597pub enum ProcessOriginator {
598 Host {
599 #[serde(default, skip_serializing_if = "Option::is_none")]
600 scope: Option<String>,
601 },
602 Session {
603 scope: SessionScope,
604 },
605}
606
607impl ProcessOriginator {
608 pub fn host() -> Self {
609 Self::Host { scope: None }
610 }
611
612 pub fn host_scoped(scope: impl Into<String>) -> Self {
613 Self::Host {
614 scope: Some(scope.into()),
615 }
616 }
617
618 pub fn session(scope: SessionScope) -> Self {
619 Self::Session { scope }
620 }
621
622 pub fn scope_id(&self) -> String {
623 match self {
624 Self::Host { scope } => scope
625 .as_ref()
626 .map(|scope| format!("host:{scope}"))
627 .unwrap_or_else(|| "host".to_string()),
628 Self::Session { scope } => scope.id().to_string(),
629 }
630 }
631}
632
633impl SessionScope {
634 pub fn new(session_id: impl Into<String>) -> Self {
635 Self {
636 session_id: session_id.into(),
637 agent_frame_id: None,
638 }
639 }
640
641 pub fn for_agent_frame(
642 session_id: impl Into<String>,
643 agent_frame_id: impl Into<crate::AgentFrameId>,
644 ) -> Self {
645 Self {
646 session_id: session_id.into(),
647 agent_frame_id: Some(agent_frame_id.into()),
648 }
649 }
650
651 pub fn id(&self) -> SessionScopeId {
652 match self.agent_frame_id.as_deref() {
653 Some(frame_id) if !frame_id.is_empty() => {
654 SessionScopeId::new(format!("session:{}/frame:{frame_id}", self.session_id))
655 }
656 _ => SessionScopeId::new(format!("session:{}", self.session_id)),
657 }
658 }
659
660 pub fn is_empty(&self) -> bool {
661 self.session_id.is_empty()
662 }
663}
664
665#[derive(Debug, Serialize, Deserialize)]
667pub struct ProcessRegistration {
668 pub id: ProcessId,
669 pub input: Arc<ProcessInput>,
670 pub disposition: RecoveryDisposition,
671 pub identity: ProcessIdentity,
672 #[serde(default)]
673 pub event_types: Vec<ProcessEventType>,
674 pub provenance: ProcessProvenance,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub env_ref: Option<ProcessExecutionEnvRef>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub wake_target: Option<SessionScope>,
679}
680
681impl Clone for ProcessRegistration {
682 fn clone(&self) -> Self {
683 Self {
684 id: self.id.clone(),
685 input: Arc::clone(&self.input),
686 disposition: self.disposition,
687 identity: self.identity.clone(),
688 event_types: self.event_types.clone(),
689 provenance: self.provenance.clone(),
690 env_ref: self.env_ref.clone(),
691 wake_target: self.wake_target.clone(),
692 }
693 }
694}
695
696impl ProcessRegistration {
697 pub fn new(
698 id: impl Into<ProcessId>,
699 input: ProcessInput,
700 disposition: RecoveryDisposition,
701 provenance: ProcessProvenance,
702 ) -> Self {
703 let identity = ProcessIdentity::from_process_input(&input);
704 Self {
705 id: id.into(),
706 input: Arc::new(input),
707 disposition,
708 identity,
709 event_types: default_process_event_types(),
710 provenance,
711 env_ref: None,
712 wake_target: None,
713 }
714 }
715
716 pub(crate) fn session_start_draft(
717 id: impl Into<ProcessId>,
718 input: ProcessInput,
719 disposition: RecoveryDisposition,
720 ) -> Self {
721 Self::new(id, input, disposition, ProcessProvenance::host())
722 }
723
724 pub fn with_process_provenance(mut self, provenance: ProcessProvenance) -> Self {
725 self.provenance = provenance;
726 self
727 }
728
729 pub fn with_execution_env_ref(mut self, env_ref: Option<ProcessExecutionEnvRef>) -> Self {
730 self.env_ref = env_ref;
731 self
732 }
733
734 pub fn with_wake_target(mut self, wake_target: Option<SessionScope>) -> Self {
735 self.wake_target = wake_target;
736 self
737 }
738
739 pub fn with_identity(mut self, identity: ProcessIdentity) -> Self {
740 self.identity = identity;
741 self
742 }
743
744 pub fn with_event_types(
745 mut self,
746 event_types: impl IntoIterator<Item = ProcessEventType>,
747 ) -> Self {
748 self.event_types = event_types.into_iter().collect();
749 self
750 }
751
752 pub fn with_extra_event_types(
753 mut self,
754 event_types: impl IntoIterator<Item = ProcessEventType>,
755 ) -> Self {
756 self.event_types.extend(event_types);
757 self
758 }
759}
760
761#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
762#[serde(tag = "state", rename_all = "snake_case")]
763pub enum ProcessStatus {
764 #[default]
765 Running,
766 Completed {
767 await_output: ProcessAwaitOutput,
768 },
769 Failed {
770 await_output: ProcessAwaitOutput,
771 },
772 Cancelled {
773 await_output: ProcessAwaitOutput,
774 },
775 Abandoned {
776 await_output: ProcessAwaitOutput,
777 },
778}
779
780impl ProcessStatus {
781 pub fn from_terminal(terminal: ProcessTerminalSemantics) -> Self {
782 match terminal.state {
783 ProcessTerminalState::Completed => Self::Completed {
784 await_output: terminal.await_output,
785 },
786 ProcessTerminalState::Failed => Self::Failed {
787 await_output: terminal.await_output,
788 },
789 ProcessTerminalState::Cancelled => Self::Cancelled {
790 await_output: terminal.await_output,
791 },
792 ProcessTerminalState::Abandoned => Self::Abandoned {
793 await_output: terminal.await_output,
794 },
795 }
796 }
797
798 pub fn is_terminal(&self) -> bool {
799 !matches!(self, Self::Running)
800 }
801
802 pub fn label(&self) -> &'static str {
803 match self {
804 Self::Running => "running",
805 Self::Completed { .. } => "completed",
806 Self::Failed { .. } => "failed",
807 Self::Cancelled { .. } => "cancelled",
808 Self::Abandoned { .. } => "abandoned",
809 }
810 }
811
812 pub fn terminal_state(&self) -> Option<ProcessTerminalState> {
813 match self {
814 Self::Running => None,
815 Self::Completed { .. } => Some(ProcessTerminalState::Completed),
816 Self::Failed { .. } => Some(ProcessTerminalState::Failed),
817 Self::Cancelled { .. } => Some(ProcessTerminalState::Cancelled),
818 Self::Abandoned { .. } => Some(ProcessTerminalState::Abandoned),
819 }
820 }
821
822 pub fn await_output(&self) -> Option<&ProcessAwaitOutput> {
823 match self {
824 Self::Running => None,
825 Self::Completed { await_output }
826 | Self::Failed { await_output }
827 | Self::Cancelled { await_output }
828 | Self::Abandoned { await_output } => Some(await_output),
829 }
830 }
831
832 pub fn terminal_semantics(&self) -> Option<ProcessTerminalSemantics> {
833 Some(ProcessTerminalSemantics {
834 state: self.terminal_state()?,
835 await_output: self.await_output()?.clone(),
836 })
837 }
838}
839
840#[derive(Clone, Debug, Serialize, Deserialize)]
843pub struct ProcessRecord {
844 pub id: ProcessId,
845 pub registration_hash: String,
846 pub input: Arc<ProcessInput>,
847 pub disposition: RecoveryDisposition,
851 pub identity: ProcessIdentity,
852 #[serde(default)]
853 pub event_types: Vec<ProcessEventType>,
854 pub provenance: ProcessProvenance,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub env_ref: Option<ProcessExecutionEnvRef>,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub wake_target: Option<SessionScope>,
859 #[serde(default)]
860 pub created_at_ms: u64,
861 #[serde(default)]
862 pub updated_at_ms: u64,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub external_ref: Option<ProcessExternalRef>,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub first_started: Option<Box<ProcessStarted>>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub abandon_request: Option<Box<AbandonRequest>>,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
876 pub wait: Option<WaitState>,
877 #[serde(default)]
878 pub status: ProcessStatus,
879}
880
881#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
882pub struct WaitState {
883 pub kind: WaitKind,
884 pub since_ms: u64,
885}
886
887#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(tag = "kind", rename_all = "snake_case")]
889pub enum WaitKind {
890 Signal {
891 name: String,
892 event_type: String,
893 key: String,
894 ordinal: u64,
895 },
896}
897
898impl ProcessRecord {
899 pub fn from_registration(registration: ProcessRegistration) -> Self {
900 Self::from_registration_with_clock(registration, &crate::SystemClock)
901 }
902
903 pub fn from_registration_with_clock(
904 mut registration: ProcessRegistration,
905 clock: &dyn crate::Clock,
906 ) -> Self {
907 ensure_core_event_types(&mut registration);
908 validate_process_registration(®istration)
909 .expect("process registration should be valid before record construction");
910 let registration_hash = process_registration_hash(®istration)
911 .expect("process registration should hash before record construction");
912 Self::from_prepared_registration(registration, registration_hash, clock.timestamp_ms())
913 }
914
915 pub fn from_prepared_registration(
916 registration: ProcessRegistration,
917 registration_hash: String,
918 now_ms: u64,
919 ) -> Self {
920 Self {
921 id: registration.id,
922 registration_hash,
923 input: registration.input,
924 disposition: registration.disposition,
925 identity: registration.identity,
926 event_types: registration.event_types,
927 provenance: registration.provenance,
928 env_ref: registration.env_ref,
929 wake_target: registration.wake_target,
930 created_at_ms: now_ms,
931 updated_at_ms: now_ms,
932 external_ref: None,
933 first_started: None,
934 abandon_request: None,
935 wait: None,
936 status: ProcessStatus::Running,
937 }
938 }
939
940 pub fn is_terminal(&self) -> bool {
941 self.status.is_terminal()
942 }
943
944 pub fn clear_wake_target_for_session(&mut self, session_id: &str) -> bool {
945 self.clear_wake_target_for_session_with_clock(session_id, &crate::SystemClock)
946 }
947
948 pub fn clear_wake_target_for_session_with_clock(
949 &mut self,
950 session_id: &str,
951 clock: &dyn crate::Clock,
952 ) -> bool {
953 let should_clear = self
954 .wake_target
955 .as_ref()
956 .is_some_and(|scope| scope.session_id == session_id);
957 if should_clear {
958 self.wake_target = None;
959 self.updated_at_ms = clock.timestamp_ms();
960 }
961 should_clear
962 }
963
964 pub fn originator_scope_id(&self) -> String {
965 self.provenance.originator.scope_id()
966 }
967}
968
969#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
976pub struct ProcessIdentity {
977 pub kind: String,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub label: Option<String>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub definition: Option<serde_json::Value>,
982}
983
984impl ProcessIdentity {
985 pub fn new(kind: impl Into<String>) -> Self {
986 Self {
987 kind: kind.into(),
988 label: None,
989 definition: None,
990 }
991 }
992
993 pub fn with_label(mut self, label: Option<impl Into<String>>) -> Self {
994 self.label = label.map(Into::into);
995 self
996 }
997
998 pub fn with_definition(mut self, definition: Option<serde_json::Value>) -> Self {
999 self.definition = definition;
1000 self
1001 }
1002
1003 pub fn from_process_input(input: &ProcessInput) -> Self {
1004 match input {
1005 ProcessInput::ToolCall { call } => {
1006 Self::new("tool").with_label(Some(call.tool_name.clone()))
1007 }
1008 ProcessInput::Engine { kind, .. } => Self::new(kind.clone()),
1009 ProcessInput::SessionTurn { create_request, .. } => {
1010 let label = create_request
1011 .subagent
1012 .as_ref()
1013 .map(|subagent| subagent.capability.clone())
1014 .or_else(|| create_request.usage_source.clone())
1015 .or_else(|| create_request.session_id.clone());
1016 Self::new("session_turn").with_label(label)
1017 }
1018 ProcessInput::External { metadata } => {
1019 let label = metadata
1020 .get("label")
1021 .or_else(|| metadata.get("name"))
1022 .or_else(|| metadata.get("title"))
1023 .and_then(serde_json::Value::as_str)
1024 .map(str::to_string);
1025 Self::new("external").with_label(label)
1026 }
1027 }
1028 }
1029}
1030
1031pub const PROCESS_LEASE_SCHEMA_VERSION: u32 = 2;
1038
1039#[derive(Clone, Debug, Serialize, Deserialize)]
1059pub struct ProcessLease {
1060 pub schema_version: u32,
1061 pub process_id: ProcessId,
1062 pub owner: crate::LeaseOwnerIdentity,
1063 pub lease_token: String,
1064 pub fencing_token: u64,
1065 pub claimed_at_epoch_ms: u64,
1066 pub expires_at_epoch_ms: u64,
1067}
1068
1069#[derive(Clone, Debug, Serialize, Deserialize)]
1075pub enum ProcessLeaseClaimOutcome {
1076 Acquired(ProcessLease),
1077 Busy { holder: ProcessLease },
1078}
1079
1080impl ProcessLeaseClaimOutcome {
1081 pub fn acquired(self) -> Option<ProcessLease> {
1082 match self {
1083 Self::Acquired(lease) => Some(lease),
1084 Self::Busy { .. } => None,
1085 }
1086 }
1087}
1088
1089#[derive(Clone, Debug, Serialize, Deserialize)]
1090pub struct ProcessLeaseCompletion {
1091 pub process_id: ProcessId,
1092 pub lease_token: String,
1093}
1094
1095impl ProcessLeaseCompletion {
1096 pub fn from_lease(lease: &ProcessLease) -> Self {
1097 Self {
1098 process_id: lease.process_id.clone(),
1099 lease_token: lease.lease_token.clone(),
1100 }
1101 }
1102}
1103
1104#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
1106pub struct ProcessExternalRef {
1107 pub backend: String,
1108 pub id: String,
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1110 pub metadata: Option<serde_json::Value>,
1111}
1112
1113#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1114pub struct ProcessHandleDescriptor {
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub kind: Option<String>,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub label: Option<String>,
1119}
1120
1121impl ProcessHandleDescriptor {
1122 pub fn new(kind: Option<impl Into<String>>, label: Option<impl Into<String>>) -> Self {
1123 Self {
1124 kind: kind.map(Into::into),
1125 label: label.map(Into::into),
1126 }
1127 }
1128}
1129
1130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1131pub struct ProcessHandleGrant {
1132 pub session_id: String,
1133 pub process_id: ProcessId,
1134 pub descriptor: ProcessHandleDescriptor,
1135}
1136
1137pub type ProcessHandleGrantEntry = (ProcessHandleGrant, ProcessRecord);
1138
1139#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1140#[serde(rename_all = "snake_case")]
1141pub enum ProcessLifecycleStatus {
1142 #[default]
1143 Running,
1144 Completed,
1145 Failed,
1146 Cancelled,
1147 Abandoned,
1148}
1149
1150impl ProcessLifecycleStatus {
1151 pub fn label(self) -> &'static str {
1152 match self {
1153 Self::Running => "running",
1154 Self::Completed => "completed",
1155 Self::Failed => "failed",
1156 Self::Cancelled => "cancelled",
1157 Self::Abandoned => "abandoned",
1158 }
1159 }
1160
1161 pub fn is_terminal(self) -> bool {
1162 !matches!(self, Self::Running)
1163 }
1164
1165 pub fn terminal_state(self) -> Option<ProcessTerminalState> {
1166 match self {
1167 Self::Running => None,
1168 Self::Completed => Some(ProcessTerminalState::Completed),
1169 Self::Failed => Some(ProcessTerminalState::Failed),
1170 Self::Cancelled => Some(ProcessTerminalState::Cancelled),
1171 Self::Abandoned => Some(ProcessTerminalState::Abandoned),
1172 }
1173 }
1174}
1175
1176impl From<&ProcessStatus> for ProcessLifecycleStatus {
1177 fn from(status: &ProcessStatus) -> Self {
1178 match status {
1179 ProcessStatus::Running => Self::Running,
1180 ProcessStatus::Completed { .. } => Self::Completed,
1181 ProcessStatus::Failed { .. } => Self::Failed,
1182 ProcessStatus::Cancelled { .. } => Self::Cancelled,
1183 ProcessStatus::Abandoned { .. } => Self::Abandoned,
1184 }
1185 }
1186}
1187
1188impl From<ProcessStatus> for ProcessLifecycleStatus {
1189 fn from(status: ProcessStatus) -> Self {
1190 Self::from(&status)
1191 }
1192}
1193
1194#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1195pub struct ProcessHandleSummary {
1196 #[serde(rename = "__handle__")]
1197 pub handle_type: String,
1198 pub id: ProcessId,
1199 pub process_id: ProcessId,
1200 pub descriptor: ProcessHandleDescriptor,
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub definition: Option<serde_json::Value>,
1203 pub status: ProcessLifecycleStatus,
1204}
1205
1206impl ProcessHandleSummary {
1207 pub fn new(
1208 process_id: impl Into<ProcessId>,
1209 descriptor: ProcessHandleDescriptor,
1210 status: ProcessLifecycleStatus,
1211 ) -> Self {
1212 let process_id = process_id.into();
1213 Self {
1214 handle_type: "process".to_string(),
1215 id: process_id.clone(),
1216 process_id,
1217 descriptor,
1218 definition: None,
1219 status,
1220 }
1221 }
1222
1223 pub fn with_definition(mut self, definition: Option<serde_json::Value>) -> Self {
1224 self.definition = definition;
1225 self
1226 }
1227
1228 pub fn from_grant_record(grant: ProcessHandleGrant, record: ProcessRecord) -> Self {
1229 let definition = record.identity.definition.clone();
1230 Self::new(
1231 record.id,
1232 grant.descriptor,
1233 ProcessLifecycleStatus::from(record.status),
1234 )
1235 .with_definition(definition)
1236 }
1237}
1238
1239impl From<ProcessHandleGrantEntry> for ProcessHandleSummary {
1240 fn from((grant, record): ProcessHandleGrantEntry) -> Self {
1241 Self::from_grant_record(grant, record)
1242 }
1243}
1244
1245#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1246pub struct ProcessCancelSummary {
1247 pub process_id: ProcessId,
1248 pub status: ProcessLifecycleStatus,
1249}
1250
1251impl ProcessCancelSummary {
1252 pub fn from_record(record: ProcessRecord) -> Self {
1253 Self {
1254 process_id: record.id,
1255 status: ProcessLifecycleStatus::from(record.status),
1256 }
1257 }
1258}
1259
1260#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1261pub enum ProcessStatusFilter {
1262 #[default]
1263 Running,
1264 Completed,
1265 Failed,
1266 Cancelled,
1267 Abandoned,
1268 Any,
1269}
1270
1271impl ProcessStatusFilter {
1272 pub fn decode(value: Option<&str>) -> Result<Self, String> {
1273 match value.unwrap_or("running") {
1274 "running" => Ok(Self::Running),
1275 "completed" => Ok(Self::Completed),
1276 "failed" => Ok(Self::Failed),
1277 "cancelled" => Ok(Self::Cancelled),
1278 "abandoned" => Ok(Self::Abandoned),
1279 "any" => Ok(Self::Any),
1280 other => Err(format!(
1281 "processes.list status must be `running`, `completed`, `failed`, `cancelled`, `abandoned`, or `any`, got `{other}`"
1282 )),
1283 }
1284 }
1285
1286 pub fn list_mode(self) -> ProcessListMode {
1287 match self {
1288 Self::Running => ProcessListMode::Live,
1289 Self::Completed | Self::Failed | Self::Cancelled | Self::Abandoned | Self::Any => {
1290 ProcessListMode::All
1291 }
1292 }
1293 }
1294
1295 pub fn matches(self, status: ProcessLifecycleStatus) -> bool {
1296 match self {
1297 Self::Running => status == ProcessLifecycleStatus::Running,
1298 Self::Completed => status == ProcessLifecycleStatus::Completed,
1299 Self::Failed => status == ProcessLifecycleStatus::Failed,
1300 Self::Cancelled => status == ProcessLifecycleStatus::Cancelled,
1301 Self::Abandoned => status == ProcessLifecycleStatus::Abandoned,
1302 Self::Any => true,
1303 }
1304 }
1305}
1306
1307#[derive(Clone, Debug, Default, PartialEq)]
1308pub struct ProcessListFilter {
1309 pub definition: Option<serde_json::Value>,
1310 pub status: ProcessStatusFilter,
1311 pub waiting: Option<bool>,
1312 pub originator_scope_id: Option<String>,
1313 pub identity_kind: Option<String>,
1314 pub identity_label: Option<String>,
1315 pub caused_by_occurrence_id: Option<String>,
1316 pub caused_by_subscription_id: Option<String>,
1317 pub created_at_start_ms: Option<u64>,
1320 pub created_at_end_ms: Option<u64>,
1323}
1324
1325impl ProcessListFilter {
1326 pub fn decode(args: &serde_json::Value) -> Result<Self, String> {
1327 let map = args
1328 .as_object()
1329 .ok_or_else(|| "processes.list expects a record of process filters".to_string())?;
1330 for key in map.keys() {
1331 match key.as_str() {
1332 "definition"
1333 | "status"
1334 | "waiting"
1335 | "originator_scope_id"
1336 | "identity_kind"
1337 | "identity_label"
1338 | "caused_by_occurrence_id"
1339 | "caused_by_subscription_id"
1340 | "created_at_start_ms"
1341 | "created_at_end_ms" => {}
1342 _ => return Err(format!("processes.list unknown filter `{key}`")),
1343 }
1344 }
1345 let definition = args.get("definition").cloned();
1346 let status =
1347 ProcessStatusFilter::decode(args.get("status").and_then(serde_json::Value::as_str))?;
1348 let waiting = args
1349 .get("waiting")
1350 .map(|value| {
1351 value
1352 .as_bool()
1353 .ok_or_else(|| "processes.list `waiting` filter must be a boolean".to_string())
1354 })
1355 .transpose()?;
1356 let originator_scope_id = optional_string_filter(args, "originator_scope_id")?;
1357 let identity_kind = optional_string_filter(args, "identity_kind")?;
1358 let identity_label = optional_string_filter(args, "identity_label")?;
1359 let caused_by_occurrence_id = optional_string_filter(args, "caused_by_occurrence_id")?;
1360 let caused_by_subscription_id = optional_string_filter(args, "caused_by_subscription_id")?;
1361 let created_at_start_ms = optional_u64_filter(args, "created_at_start_ms")?;
1362 let created_at_end_ms = optional_u64_filter(args, "created_at_end_ms")?;
1363 Ok(Self {
1364 definition,
1365 status,
1366 waiting,
1367 originator_scope_id,
1368 identity_kind,
1369 identity_label,
1370 caused_by_occurrence_id,
1371 caused_by_subscription_id,
1372 created_at_start_ms,
1373 created_at_end_ms,
1374 })
1375 }
1376
1377 pub fn list_mode(&self) -> ProcessListMode {
1378 self.status.list_mode()
1379 }
1380
1381 pub fn matches_entry(&self, entry: &ProcessHandleGrantEntry) -> bool {
1382 let (_grant, record) = entry;
1383 self.matches_record(record)
1384 }
1385
1386 pub fn matches_record(&self, record: &ProcessRecord) -> bool {
1387 let status = ProcessLifecycleStatus::from(&record.status);
1388 self.status.matches(status)
1389 && self
1390 .definition
1391 .as_ref()
1392 .is_none_or(|definition| record.identity.definition.as_ref() == Some(definition))
1393 && self
1394 .waiting
1395 .is_none_or(|waiting| record.wait.is_some() == waiting)
1396 && self
1397 .originator_scope_id
1398 .as_ref()
1399 .is_none_or(|scope_id| record.originator_scope_id() == scope_id.as_str())
1400 && self
1401 .identity_kind
1402 .as_ref()
1403 .is_none_or(|kind| record.identity.kind.as_str() == kind.as_str())
1404 && self
1405 .identity_label
1406 .as_ref()
1407 .is_none_or(|label| record.identity.label.as_deref() == Some(label.as_str()))
1408 && self
1409 .caused_by_occurrence_id
1410 .as_ref()
1411 .is_none_or(|occurrence_id| caused_by_occurrence_matches(record, occurrence_id))
1412 && self
1413 .caused_by_subscription_id
1414 .as_ref()
1415 .is_none_or(|subscription_id| {
1416 caused_by_subscription_matches(record, subscription_id)
1417 })
1418 && self
1419 .created_at_start_ms
1420 .is_none_or(|start_ms| record.created_at_ms >= start_ms)
1421 && self
1422 .created_at_end_ms
1423 .is_none_or(|end_ms| record.created_at_ms < end_ms)
1424 }
1425}
1426
1427fn optional_string_filter(args: &serde_json::Value, key: &str) -> Result<Option<String>, String> {
1428 args.get(key)
1429 .map(|value| {
1430 value
1431 .as_str()
1432 .map(str::to_string)
1433 .ok_or_else(|| format!("processes.list `{key}` filter must be a string"))
1434 })
1435 .transpose()
1436}
1437
1438fn optional_u64_filter(args: &serde_json::Value, key: &str) -> Result<Option<u64>, String> {
1439 args.get(key)
1440 .map(|value| {
1441 value
1442 .as_u64()
1443 .ok_or_else(|| format!("processes.list `{key}` filter must be an integer"))
1444 })
1445 .transpose()
1446}
1447
1448fn caused_by_occurrence_matches(record: &ProcessRecord, occurrence_id: &str) -> bool {
1449 matches!(
1450 record.provenance.caused_by.as_ref(),
1451 Some(crate::CausalRef::TriggerOccurrence { occurrence_id: actual, .. }) if actual == occurrence_id
1452 )
1453}
1454
1455fn caused_by_subscription_matches(record: &ProcessRecord, subscription_id: &str) -> bool {
1456 matches!(
1457 record.provenance.caused_by.as_ref(),
1458 Some(crate::CausalRef::TriggerOccurrence {
1459 subscription_id: Some(actual),
1460 ..
1461 }) if actual == subscription_id
1462 )
1463}
1464
1465#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1466#[serde(rename_all = "snake_case")]
1467pub enum ProcessListMode {
1468 #[default]
1469 Live,
1470 All,
1471}
1472
1473impl ProcessListMode {
1474 pub fn as_str(self) -> &'static str {
1475 match self {
1476 Self::Live => "live",
1477 Self::All => "all",
1478 }
1479 }
1480}
1481
1482#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1483pub struct ProcessStartGrant {
1484 pub session_scope: SessionScope,
1485 pub descriptor: ProcessHandleDescriptor,
1486}
1487
1488#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1489pub struct ProcessSessionDeleteReport {
1490 pub session_id: String,
1491 pub revoked_handle_count: usize,
1492 pub deleted_wake_count: usize,
1493 pub orphaned_process_ids: Vec<String>,
1494 pub preserved_process_ids: Vec<String>,
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499 use serde_json::json;
1500
1501 use super::*;
1502
1503 fn process_value(component: &str, pos: usize, name: &str) -> serde_json::Value {
1504 json!({
1505 "component": component,
1506 "pos": pos,
1507 "name": name,
1508 })
1509 }
1510
1511 fn engine_entry(
1512 process_id: &str,
1513 definition: serde_json::Value,
1514 process_name: &str,
1515 status: ProcessStatus,
1516 ) -> ProcessHandleGrantEntry {
1517 let mut record = ProcessRecord::from_registration(
1518 ProcessRegistration::new(
1519 process_id,
1520 ProcessInput::Engine {
1521 kind: "test-engine".to_string(),
1522 payload: json!({
1523 "definition": definition.clone(),
1524 "label": process_name,
1525 }),
1526 },
1527 RecoveryDisposition::Rerunnable,
1528 ProcessProvenance::host(),
1529 )
1530 .with_identity(
1531 ProcessIdentity::new("test-engine")
1532 .with_label(Some(process_name))
1533 .with_definition(Some(definition)),
1534 )
1535 .with_execution_env_ref(Some(ProcessExecutionEnvRef::new(format!(
1536 "process-env:test:{process_id}"
1537 )))),
1538 );
1539 record.status = status;
1540 (
1541 ProcessHandleGrant {
1542 session_id: "session".to_string(),
1543 process_id: process_id.to_string(),
1544 descriptor: ProcessHandleDescriptor::new(Some("test-engine"), Some(process_name)),
1545 },
1546 record,
1547 )
1548 }
1549
1550 #[test]
1551 fn process_list_filter_matches_waiting_facet() {
1552 let process_ref = process_value("target", 0, "target");
1553 let mut waiting_entry = engine_entry(
1554 "waiting",
1555 process_ref.clone(),
1556 "target",
1557 ProcessStatus::Running,
1558 );
1559 waiting_entry.1.wait = Some(WaitState {
1560 since_ms: 42,
1561 kind: WaitKind::Signal {
1562 name: "ready".to_string(),
1563 event_type: "signal.ready".to_string(),
1564 key: "process:waiting:signal.ready:1".to_string(),
1565 ordinal: 1,
1566 },
1567 });
1568 let idle_entry = engine_entry("idle", process_ref, "target", ProcessStatus::Running);
1569 let waiting_filter =
1570 ProcessListFilter::decode(&json!({ "waiting": true })).expect("decode waiting filter");
1571 let idle_filter =
1572 ProcessListFilter::decode(&json!({ "waiting": false })).expect("decode idle filter");
1573
1574 assert_eq!(waiting_filter.list_mode(), ProcessListMode::Live);
1575 assert!(waiting_filter.matches_entry(&waiting_entry));
1576 assert!(!waiting_filter.matches_entry(&idle_entry));
1577 assert!(!idle_filter.matches_entry(&waiting_entry));
1578 assert!(idle_filter.matches_entry(&idle_entry));
1579 assert!(
1580 ProcessListFilter::decode(&json!({ "waiting": "yes" }))
1581 .expect_err("invalid waiting filter")
1582 .contains("must be a boolean")
1583 );
1584 }
1585}