Skip to main content

graphrefly/
process.rs

1//! Graph-visible process orchestration bundle (D136 / B84).
2//!
3//! A `ProcessBundle` is facts plus a reducer: command DATA facts enter a
4//! graph-owned runtime node, and state/event/effect-request/status/error/audit/
5//! cursor projections are ordinary graph nodes with declared deps. It is not a
6//! workflow engine, effect runner, storage restore path, or hidden process manager.
7
8pub mod messaging;
9pub mod work_queue;
10
11use std::cell::{Cell, RefCell};
12use std::collections::HashSet;
13use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
14use std::rc::Rc;
15
16use serde::{de::DeserializeOwned, Serialize};
17use serde_json::Value;
18
19use crate::ctx::Ctx;
20use crate::graph::{Graph, GraphNodeOpts};
21use crate::identity::compound_tuple_key;
22use crate::node::{Core, Node};
23use crate::operators::Operator;
24
25#[derive(Debug, Clone, PartialEq)]
26/// `ProcessCommand` data container.
27pub struct ProcessCommand<T = crate::protocol::AnyValue> {
28    /// `id` field for id.
29    pub id: String,
30    /// `command_type` field for command type.
31    pub command_type: String,
32    /// `payload` field for payload.
33    pub payload: T,
34    /// `process_id` field for process id.
35    pub process_id: Option<String>,
36    /// `correlation_id` field for correlation id.
37    pub correlation_id: Option<String>,
38    /// `causation_id` field for causation id.
39    pub causation_id: Option<String>,
40}
41
42impl<T> ProcessCommand<T> {
43    /// Creates or computes `new`.
44    pub fn new(id: impl Into<String>, command_type: impl Into<String>, payload: T) -> Self {
45        Self {
46            id: id.into(),
47            command_type: command_type.into(),
48            payload,
49            process_id: None,
50            correlation_id: None,
51            causation_id: None,
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq)]
57/// `ProcessEventDraft` data container.
58pub struct ProcessEventDraft<T = crate::protocol::AnyValue> {
59    /// `id` field for id.
60    pub id: Option<String>,
61    /// `event_type` field for event type.
62    pub event_type: String,
63    /// `payload` field for payload.
64    pub payload: T,
65    /// `process_id` field for process id.
66    pub process_id: Option<String>,
67    /// `correlation_id` field for correlation id.
68    pub correlation_id: Option<String>,
69    /// `causation_id` field for causation id.
70    pub causation_id: Option<String>,
71}
72
73impl<T> ProcessEventDraft<T> {
74    /// Creates or computes `new`.
75    pub fn new(event_type: impl Into<String>, payload: T) -> Self {
76        Self {
77            id: None,
78            event_type: event_type.into(),
79            payload,
80            process_id: None,
81            correlation_id: None,
82            causation_id: None,
83        }
84    }
85}
86
87#[derive(Debug, Clone, PartialEq)]
88/// `ProcessEvent` data container.
89pub struct ProcessEvent<T = crate::protocol::AnyValue> {
90    /// `id` field for id.
91    pub id: String,
92    /// `event_type` field for event type.
93    pub event_type: String,
94    /// `seq` field for seq.
95    pub seq: u64,
96    /// `cursor` field for cursor.
97    pub cursor: u64,
98    /// `command_id` field for command id.
99    pub command_id: String,
100    /// `command_type` field for command type.
101    pub command_type: String,
102    /// `payload` field for payload.
103    pub payload: T,
104    /// `timestamp_ms` field for timestamp ms.
105    pub timestamp_ms: u64,
106    /// `process_id` field for process id.
107    pub process_id: Option<String>,
108    /// `correlation_id` field for correlation id.
109    pub correlation_id: Option<String>,
110    /// `causation_id` field for causation id.
111    pub causation_id: Option<String>,
112}
113
114#[derive(Debug, Clone, PartialEq)]
115/// `ProcessEffectRequestDraft` data container.
116pub struct ProcessEffectRequestDraft<T = crate::protocol::AnyValue> {
117    /// `id` field for id.
118    pub id: Option<String>,
119    /// `effect_type` field for effect type.
120    pub effect_type: String,
121    /// `payload` field for payload.
122    pub payload: T,
123    /// `process_id` field for process id.
124    pub process_id: Option<String>,
125    /// `correlation_id` field for correlation id.
126    pub correlation_id: Option<String>,
127    /// `causation_id` field for causation id.
128    pub causation_id: Option<String>,
129}
130
131impl<T> ProcessEffectRequestDraft<T> {
132    /// Creates or computes `new`.
133    pub fn new(effect_type: impl Into<String>, payload: T) -> Self {
134        Self {
135            id: None,
136            effect_type: effect_type.into(),
137            payload,
138            process_id: None,
139            correlation_id: None,
140            causation_id: None,
141        }
142    }
143}
144
145#[derive(Debug, Clone, PartialEq)]
146/// `ProcessEffectRequest` data container.
147pub struct ProcessEffectRequest<T = crate::protocol::AnyValue> {
148    /// `id` field for id.
149    pub id: String,
150    /// `effect_type` field for effect type.
151    pub effect_type: String,
152    /// `seq` field for seq.
153    pub seq: u64,
154    /// `cursor` field for cursor.
155    pub cursor: u64,
156    /// `command_id` field for command id.
157    pub command_id: String,
158    /// `command_type` field for command type.
159    pub command_type: String,
160    /// `payload` field for payload.
161    pub payload: T,
162    /// `timestamp_ms` field for timestamp ms.
163    pub timestamp_ms: u64,
164    /// `process_id` field for process id.
165    pub process_id: Option<String>,
166    /// `correlation_id` field for correlation id.
167    pub correlation_id: Option<String>,
168    /// `causation_id` field for causation id.
169    pub causation_id: Option<String>,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173/// `ProcessEffectOutcomeKind` variants.
174pub enum ProcessEffectOutcomeKind {
175    /// `Result` variant.
176    Result,
177    /// `Failure` variant.
178    Failure,
179    /// `Cancel` variant.
180    Cancel,
181    /// `Timeout` variant.
182    Timeout,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186/// `ProcessEffectCommandType` variants.
187pub enum ProcessEffectCommandType {
188    /// `Result` variant.
189    Result,
190    /// `Failure` variant.
191    Failure,
192    /// `Cancel` variant.
193    Cancel,
194    /// `Timeout` variant.
195    Timeout,
196}
197
198impl ProcessEffectCommandType {
199    /// Updates or reads `as_str`.
200    pub fn as_str(self) -> &'static str {
201        match self {
202            Self::Result => "effect.result",
203            Self::Failure => "effect.failure",
204            Self::Cancel => "effect.cancel",
205            Self::Timeout => "effect.timeout",
206        }
207    }
208}
209
210#[derive(Debug, Clone, PartialEq)]
211/// `ProcessEffectOutcome` data container.
212pub struct ProcessEffectOutcome<TResult = crate::protocol::AnyValue> {
213    /// `kind` field for kind.
214    pub kind: ProcessEffectOutcomeKind,
215    /// `effect_id` field for effect id.
216    pub effect_id: String,
217    /// `effect_type` field for effect type.
218    pub effect_type: String,
219    /// `value` field for value.
220    pub value: Option<TResult>,
221    /// `error` field for error.
222    pub error: Option<String>,
223    /// `reason` field for reason.
224    pub reason: Option<String>,
225    /// `command_id` field for command id.
226    pub command_id: Option<String>,
227    /// `process_id` field for process id.
228    pub process_id: Option<String>,
229    /// `correlation_id` field for correlation id.
230    pub correlation_id: Option<String>,
231    /// `causation_id` field for causation id.
232    pub causation_id: Option<String>,
233}
234
235impl<TResult> ProcessEffectOutcome<TResult> {
236    /// Creates or computes `result`.
237    pub fn result(
238        effect_id: impl Into<String>,
239        effect_type: impl Into<String>,
240        value: TResult,
241    ) -> Self {
242        Self {
243            kind: ProcessEffectOutcomeKind::Result,
244            effect_id: effect_id.into(),
245            effect_type: effect_type.into(),
246            value: Some(value),
247            error: None,
248            reason: None,
249            command_id: None,
250            process_id: None,
251            correlation_id: None,
252            causation_id: None,
253        }
254    }
255
256    /// Creates or computes `failure`.
257    pub fn failure(
258        effect_id: impl Into<String>,
259        effect_type: impl Into<String>,
260        error: impl Into<String>,
261    ) -> Self {
262        Self {
263            kind: ProcessEffectOutcomeKind::Failure,
264            effect_id: effect_id.into(),
265            effect_type: effect_type.into(),
266            value: None,
267            error: Some(error.into()),
268            reason: None,
269            command_id: None,
270            process_id: None,
271            correlation_id: None,
272            causation_id: None,
273        }
274    }
275
276    /// Creates or computes `cancel`.
277    pub fn cancel(effect_id: impl Into<String>, effect_type: impl Into<String>) -> Self {
278        Self {
279            kind: ProcessEffectOutcomeKind::Cancel,
280            effect_id: effect_id.into(),
281            effect_type: effect_type.into(),
282            value: None,
283            error: None,
284            reason: None,
285            command_id: None,
286            process_id: None,
287            correlation_id: None,
288            causation_id: None,
289        }
290    }
291
292    /// Creates or computes `timeout`.
293    pub fn timeout(
294        effect_id: impl Into<String>,
295        effect_type: impl Into<String>,
296        error: impl Into<String>,
297    ) -> Self {
298        Self {
299            kind: ProcessEffectOutcomeKind::Timeout,
300            effect_id: effect_id.into(),
301            effect_type: effect_type.into(),
302            value: None,
303            error: Some(error.into()),
304            reason: None,
305            command_id: None,
306            process_id: None,
307            correlation_id: None,
308            causation_id: None,
309        }
310    }
311
312    /// Updates or reads `with_command_id`.
313    pub fn with_command_id(mut self, command_id: impl Into<String>) -> Self {
314        self.command_id = Some(command_id.into());
315        self
316    }
317
318    /// Updates or reads `with_process_id`.
319    pub fn with_process_id(mut self, process_id: impl Into<String>) -> Self {
320        self.process_id = Some(process_id.into());
321        self
322    }
323
324    /// Updates or reads `with_correlation_id`.
325    pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
326        self.correlation_id = Some(correlation_id.into());
327        self
328    }
329
330    /// Updates or reads `with_causation_id`.
331    pub fn with_causation_id(mut self, causation_id: impl Into<String>) -> Self {
332        self.causation_id = Some(causation_id.into());
333        self
334    }
335
336    /// Updates or reads `with_reason`.
337    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
338        self.reason = Some(reason.into());
339        self
340    }
341}
342
343#[derive(Debug, Clone, PartialEq)]
344/// `ProcessEffectCommandPayload` data container.
345pub struct ProcessEffectCommandPayload<TResult = crate::protocol::AnyValue> {
346    /// `kind` field for kind.
347    pub kind: ProcessEffectOutcomeKind,
348    /// `effect_id` field for effect id.
349    pub effect_id: String,
350    /// `effect_type` field for effect type.
351    pub effect_type: String,
352    /// `value` field for value.
353    pub value: Option<TResult>,
354    /// `error` field for error.
355    pub error: Option<String>,
356    /// `reason` field for reason.
357    pub reason: Option<String>,
358    /// `process_id` field for process id.
359    pub process_id: Option<String>,
360    /// `correlation_id` field for correlation id.
361    pub correlation_id: Option<String>,
362    /// `causation_id` field for causation id.
363    pub causation_id: Option<String>,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367/// `ProcessEffectRunnerStatusState` variants.
368pub enum ProcessEffectRunnerStatusState {
369    /// `Requested` variant.
370    Requested,
371    /// `Commanded` variant.
372    Commanded,
373    /// `Rejected` variant.
374    Rejected,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
378/// `ProcessEffectRunnerStatus` data container.
379pub struct ProcessEffectRunnerStatus {
380    /// `state` field for state.
381    pub state: ProcessEffectRunnerStatusState,
382    /// `effect_id` field for effect id.
383    pub effect_id: Option<String>,
384    /// `effect_type` field for effect type.
385    pub effect_type: Option<String>,
386    /// `command_id` field for command id.
387    pub command_id: Option<String>,
388    /// `command_type` field for command type.
389    pub command_type: Option<ProcessEffectCommandType>,
390    /// `requested` field for requested.
391    pub requested: u64,
392    /// `commanded` field for commanded.
393    pub commanded: u64,
394    /// `rejected` field for rejected.
395    pub rejected: u64,
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399/// `ProcessEffectRunnerErrorCode` variants.
400pub enum ProcessEffectRunnerErrorCode {
401    /// `MalformedOutcome` variant.
402    MalformedOutcome,
403}
404
405#[derive(Debug, Clone, PartialEq)]
406/// `ProcessEffectRunnerError` data container.
407pub struct ProcessEffectRunnerError<TResult = crate::protocol::AnyValue> {
408    /// `code` field for code.
409    pub code: ProcessEffectRunnerErrorCode,
410    /// `message` field for message.
411    pub message: String,
412    /// `outcome` field for outcome.
413    pub outcome: Option<ProcessEffectOutcome<TResult>>,
414    /// `effect_id` field for effect id.
415    pub effect_id: Option<String>,
416    /// `effect_type` field for effect type.
417    pub effect_type: Option<String>,
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421/// `ProcessErrorCode` variants.
422pub enum ProcessErrorCode {
423    /// `MalformedCommand` variant.
424    MalformedCommand,
425    /// `ReducerThrew` variant.
426    ReducerThrew,
427    /// `ClockThrew` variant.
428    ClockThrew,
429    /// `MalformedState` variant.
430    MalformedState,
431    /// `MalformedEvent` variant.
432    MalformedEvent,
433    /// `MalformedEffect` variant.
434    MalformedEffect,
435}
436
437#[derive(Debug, Clone, PartialEq)]
438/// `ProcessError` data container.
439pub struct ProcessError<TCommand = crate::protocol::AnyValue> {
440    /// `code` field for code.
441    pub code: ProcessErrorCode,
442    /// `message` field for message.
443    pub message: String,
444    /// `command` field for command.
445    pub command: Option<ProcessCommand<TCommand>>,
446    /// `cursor` field for cursor.
447    pub cursor: ProcessCursor,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq)]
451/// `ProcessCursor` data container.
452pub struct ProcessCursor {
453    /// `event_seq` field for event seq.
454    pub event_seq: u64,
455    /// `effect_seq` field for effect seq.
456    pub effect_seq: u64,
457    /// `command_count` field for command count.
458    pub command_count: u64,
459    /// `error_count` field for error count.
460    pub error_count: u64,
461    /// `audit_seq` field for audit seq.
462    pub audit_seq: u64,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
466/// `ProcessStatus` data container.
467pub struct ProcessStatus {
468    /// `state` field for state.
469    pub state: ProcessStatusState,
470    /// `command_id` field for command id.
471    pub command_id: Option<String>,
472    /// `command_type` field for command type.
473    pub command_type: Option<String>,
474    /// `event_count` field for event count.
475    pub event_count: usize,
476    /// `effect_count` field for effect count.
477    pub effect_count: usize,
478    /// `error_code` field for error code.
479    pub error_code: Option<ProcessErrorCode>,
480    /// `cursor` field for cursor.
481    pub cursor: ProcessCursor,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
485/// `ProcessStatusState` variants.
486pub enum ProcessStatusState {
487    /// `Accepted` variant.
488    Accepted,
489    /// `Rejected` variant.
490    Rejected,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq)]
494/// `ProcessAuditRecord` data container.
495pub struct ProcessAuditRecord {
496    /// `seq` field for seq.
497    pub seq: u64,
498    /// `command_id` field for command id.
499    pub command_id: Option<String>,
500    /// `command_type` field for command type.
501    pub command_type: Option<String>,
502    /// `outcome` field for outcome.
503    pub outcome: ProcessAuditOutcome,
504    /// `event_ids` field for event ids.
505    pub event_ids: Vec<String>,
506    /// `event_types` field for event types.
507    pub event_types: Vec<String>,
508    /// `effect_ids` field for effect ids.
509    pub effect_ids: Vec<String>,
510    /// `effect_types` field for effect types.
511    pub effect_types: Vec<String>,
512    /// `error_code` field for error code.
513    pub error_code: Option<ProcessErrorCode>,
514    /// `error_message` field for error message.
515    pub error_message: Option<String>,
516    /// `cursor` field for cursor.
517    pub cursor: ProcessCursor,
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521/// `ProcessAuditOutcome` variants.
522pub enum ProcessAuditOutcome {
523    /// `Success` variant.
524    Success,
525    /// `Failure` variant.
526    Failure,
527}
528
529#[derive(Debug, Clone, PartialEq)]
530/// `ProcessRuntimeFact` variants.
531pub enum ProcessRuntimeFact<
532    TState = crate::protocol::AnyValue,
533    TEvent = crate::protocol::AnyValue,
534    TEffect = crate::protocol::AnyValue,
535    TCommand = crate::protocol::AnyValue,
536> {
537    /// `State` variant.
538    State {
539        /// `state` field for state.
540        state: TState,
541        /// `cursor` field for cursor.
542        cursor: ProcessCursor,
543    },
544    /// `Event` variant.
545    Event(ProcessEvent<TEvent>),
546    /// `EffectRequest` variant.
547    EffectRequest(ProcessEffectRequest<TEffect>),
548    /// `Status` variant.
549    Status(ProcessStatus),
550    /// `Error` variant.
551    Error(ProcessError<TCommand>),
552    /// `Audit` variant.
553    Audit(ProcessAuditRecord),
554    /// `Cursor` variant.
555    Cursor(ProcessCursor),
556}
557
558#[derive(Debug, Clone, PartialEq)]
559/// `ProcessReduction` data container.
560pub struct ProcessReduction<TState, TEvent, TEffect> {
561    /// `state` field for state.
562    pub state: TState,
563    /// `events` field for events.
564    pub events: Vec<ProcessEventDraft<TEvent>>,
565    /// `effects` field for effects.
566    pub effects: Vec<ProcessEffectRequestDraft<TEffect>>,
567}
568
569impl<TState, TEvent, TEffect> ProcessReduction<TState, TEvent, TEffect> {
570    /// Creates or computes `new`.
571    pub fn new(state: TState) -> Self {
572        Self {
573            state,
574            events: Vec::new(),
575            effects: Vec::new(),
576        }
577    }
578
579    /// Updates or reads `with_events`.
580    pub fn with_events(mut self, events: Vec<ProcessEventDraft<TEvent>>) -> Self {
581        self.events = events;
582        self
583    }
584
585    /// Updates or reads `with_effects`.
586    pub fn with_effects(mut self, effects: Vec<ProcessEffectRequestDraft<TEffect>>) -> Self {
587        self.effects = effects;
588        self
589    }
590}
591
592/// `ProcessReducerFn` type alias.
593pub type ProcessReducerFn<TCommand, TState, TEvent, TEffect> =
594    dyn Fn(&ProcessCommand<TCommand>, TState) -> ProcessReduction<TState, TEvent, TEffect>;
595
596/// `ProcessReducer` type alias.
597pub type ProcessReducer<TCommand, TState, TEvent, TEffect> =
598    Rc<ProcessReducerFn<TCommand, TState, TEvent, TEffect>>;
599
600#[derive(Clone)]
601/// `ProcessBundleOptions` data container.
602pub struct ProcessBundleOptions<TCommand, TState, TEvent, TEffect> {
603    /// `name` field for name.
604    pub name: String,
605    /// `initial_state` field for initial state.
606    pub initial_state: TState,
607    /// `reduce` field for reduce.
608    pub reduce: ProcessReducer<TCommand, TState, TEvent, TEffect>,
609    /// `now` field for now.
610    pub now: Rc<dyn Fn() -> u64>,
611}
612
613impl<TCommand, TState, TEvent, TEffect> ProcessBundleOptions<TCommand, TState, TEvent, TEffect> {
614    /// Creates or computes `new`.
615    pub fn new(
616        initial_state: TState,
617        reduce: impl Fn(&ProcessCommand<TCommand>, TState) -> ProcessReduction<TState, TEvent, TEffect>
618            + 'static,
619    ) -> Self {
620        Self {
621            name: "process".to_owned(),
622            initial_state,
623            reduce: Rc::new(reduce),
624            now: Rc::new(|| 0),
625        }
626    }
627
628    /// Updates or reads `named`.
629    pub fn named(mut self, name: impl Into<String>) -> Self {
630        self.name = name.into();
631        self
632    }
633
634    /// Updates or reads `with_now`.
635    pub fn with_now(mut self, now: impl Fn() -> u64 + 'static) -> Self {
636        self.now = Rc::new(now);
637        self
638    }
639}
640
641#[derive(Clone)]
642/// `ProcessBundle` data container.
643pub struct ProcessBundle<
644    TCommand = crate::protocol::AnyValue,
645    TState = crate::protocol::AnyValue,
646    TEvent = crate::protocol::AnyValue,
647    TEffect = crate::protocol::AnyValue,
648> {
649    /// `command` field for command.
650    pub command: Node<ProcessCommand<TCommand>>,
651    /// `state` field for state.
652    pub state: Node<TState>,
653    /// `events` field for events.
654    pub events: Node<ProcessEvent<TEvent>>,
655    /// `audit` field for audit.
656    pub audit: Node<ProcessAuditRecord>,
657    /// `effect_request` field for effect request.
658    pub effect_request: Node<ProcessEffectRequest<TEffect>>,
659    /// `status` field for status.
660    pub status: Node<ProcessStatus>,
661    /// `error` field for error.
662    pub error: Node<ProcessError<TCommand>>,
663    /// `cursor` field for cursor.
664    pub cursor: Node<ProcessCursor>,
665    command_sources: Rc<RefCell<Vec<Core>>>,
666    command_id: String,
667    _retains: Rc<Vec<ProcessRetain>>,
668}
669
670impl<TCommand: Clone + 'static, TState, TEvent, TEffect>
671    ProcessBundle<TCommand, TState, TEvent, TEffect>
672{
673    /// Updates or reads `dispatch`.
674    pub fn dispatch(&self, command: ProcessCommand<TCommand>) -> ProcessCommand<TCommand> {
675        self.command.set(command.clone());
676        command
677    }
678}
679
680struct ProcessRetain {
681    release: std::cell::RefCell<Option<Box<dyn FnOnce()>>>,
682}
683
684impl ProcessRetain {
685    fn new(release: Box<dyn FnOnce()>) -> Self {
686        Self {
687            release: std::cell::RefCell::new(Some(release)),
688        }
689    }
690}
691
692impl Drop for ProcessRetain {
693    fn drop(&mut self) {
694        if let Some(release) = self.release.borrow_mut().take() {
695            release();
696        }
697    }
698}
699
700/// `ProcessEffectRunnerOptions` data container.
701pub struct ProcessEffectRunnerOptions<TCommand, TResult = crate::protocol::AnyValue> {
702    /// `name` field for name.
703    pub name: String,
704    /// `outcomes` field for outcomes.
705    pub outcomes: Vec<Node<ProcessEffectOutcome<TResult>>>,
706    command_payload: Rc<dyn Fn(ProcessEffectCommandPayload<TResult>) -> TCommand>,
707}
708
709impl<TResult: Clone + 'static>
710    ProcessEffectRunnerOptions<ProcessEffectCommandPayload<TResult>, TResult>
711{
712    /// Creates or computes `new`.
713    pub fn new(outcomes: Vec<Node<ProcessEffectOutcome<TResult>>>) -> Self {
714        Self {
715            name: "processEffectRunner".to_owned(),
716            outcomes,
717            command_payload: Rc::new(|payload| payload),
718        }
719    }
720}
721
722impl<TCommand, TResult> ProcessEffectRunnerOptions<TCommand, TResult> {
723    /// Updates or reads `named`.
724    pub fn named(mut self, name: impl Into<String>) -> Self {
725        self.name = name.into();
726        self
727    }
728
729    /// Updates or reads `map_command_payload`.
730    pub fn map_command_payload<TNext>(
731        self,
732        map: impl Fn(ProcessEffectCommandPayload<TResult>) -> TNext + 'static,
733    ) -> ProcessEffectRunnerOptions<TNext, TResult> {
734        ProcessEffectRunnerOptions {
735            name: self.name,
736            outcomes: self.outcomes,
737            command_payload: Rc::new(map),
738        }
739    }
740}
741
742#[derive(Debug, Clone, PartialEq)]
743enum ProcessEffectRunnerFact<TCommand, TResult = crate::protocol::AnyValue> {
744    Outcome(ProcessEffectOutcome<TResult>),
745    Command(
746        ProcessCommand<TCommand>,
747        ProcessEffectCommandPayload<TResult>,
748    ),
749    Error(ProcessEffectRunnerError<TResult>),
750}
751
752/// `ProcessEffectRunnerBundle` data container.
753pub struct ProcessEffectRunnerBundle<
754    TCommand = ProcessEffectCommandPayload<crate::protocol::AnyValue>,
755    TEffect = crate::protocol::AnyValue,
756    TResult = crate::protocol::AnyValue,
757> {
758    /// `requests` field for requests.
759    pub requests: Node<ProcessEffectRequest<TEffect>>,
760    /// `outcomes` field for outcomes.
761    pub outcomes: Node<ProcessEffectOutcome<TResult>>,
762    /// `commands` field for commands.
763    pub commands: Node<ProcessCommand<TCommand>>,
764    /// `status` field for status.
765    pub status: Node<ProcessEffectRunnerStatus>,
766    /// `errors` field for errors.
767    pub errors: Node<ProcessEffectRunnerError<TResult>>,
768    runtime: Node<ProcessEffectRunnerFact<TCommand, TResult>>,
769    graph: Graph,
770    process_command: Node<ProcessCommand<TCommand>>,
771    process_command_id: String,
772    name: String,
773    command_sources: Rc<RefCell<Vec<Core>>>,
774    released: Cell<bool>,
775    retains: RefCell<Vec<ProcessRetain>>,
776}
777
778impl<TCommand: Clone + 'static, TEffect: Clone + 'static, TResult: Clone + 'static>
779    ProcessEffectRunnerBundle<TCommand, TEffect, TResult>
780{
781    /// D156/D157-style release: detach runner.commands from process.command, then release helper nodes.
782    pub fn release(&self) {
783        if self.released.get() {
784            return;
785        }
786        self.preflight_release();
787        detach_process_command_source(
788            &self.process_command,
789            &self.command_sources,
790            self.commands.erased(),
791        );
792        let active_retains = self.retains.replace(Vec::new());
793        drop(active_retains);
794        self.released.set(true);
795        self.graph.release_nodes(
796            &[
797                self.requests.erased(),
798                self.outcomes.erased(),
799                self.runtime.erased(),
800                self.commands.erased(),
801                self.status.erased(),
802                self.errors.erased(),
803            ],
804            "process_effect_runner release",
805        );
806    }
807
808    fn preflight_release(&self) {
809        let release_cores = [
810            self.requests.erased(),
811            self.outcomes.erased(),
812            self.runtime.erased(),
813            self.commands.erased(),
814            self.status.erased(),
815            self.errors.erased(),
816        ];
817        let release_ids = self.release_node_ids();
818        let release_id_set = release_ids.iter().cloned().collect::<HashSet<_>>();
819        for edge in self.graph.describe().edges {
820            if !release_id_set.contains(&edge.from) || release_id_set.contains(&edge.to) {
821                continue;
822            }
823            if edge.from == format!("{}/commands", self.name) && edge.to == self.process_command_id
824            {
825                continue;
826            }
827            panic!(
828                "process_effect_runner: cannot release '{}'; '{}' still depends on '{}' (D122)",
829                self.name, edge.to, edge.from
830            );
831        }
832        for (index, core) in release_cores.iter().enumerate() {
833            assert!(
834                core.runtime_is_quiescent_for_release(),
835                "process_effect_runner: cannot release '{}'; '{}' is not runtime-quiescent (D124)",
836                self.name,
837                release_ids[index]
838            );
839            let internal_subscribers = release_cores
840                .iter()
841                .filter(|dependent| dependent.is_active())
842                .flat_map(Core::deps)
843                .filter(|dep| dep.ptr_eq(core))
844                .count();
845            let retain_subscriber = 1;
846            let process_command_subscriber =
847                usize::from(release_ids[index] == format!("{}/commands", self.name));
848            assert!(
849                core.subscriber_count()
850                    <= internal_subscribers + retain_subscriber + process_command_subscriber,
851                "process_effect_runner: cannot release '{}'; '{}' still has live subscribers (D124)",
852                self.name,
853                release_ids[index]
854            );
855        }
856    }
857
858    fn release_node_ids(&self) -> Vec<String> {
859        vec![
860            format!("{}/requests", self.name),
861            format!("{}/outcomes", self.name),
862            format!("{}/runtime", self.name),
863            format!("{}/commands", self.name),
864            format!("{}/status", self.name),
865            format!("{}/errors", self.name),
866        ]
867    }
868}
869
870/// Creates or computes `process_bundle`.
871pub fn process_bundle<
872    TCommand: Clone + 'static,
873    TState: Clone + Serialize + DeserializeOwned + 'static,
874    TEvent: Clone + 'static,
875    TEffect: Clone + 'static,
876>(
877    graph: &Graph,
878    opts: ProcessBundleOptions<TCommand, TState, TEvent, TEffect>,
879) -> ProcessBundle<TCommand, TState, TEvent, TEffect> {
880    assert!(
881        !opts.name.is_empty(),
882        "process_bundle: name must be non-empty"
883    );
884    let name = opts.name;
885    let reduce = opts.reduce;
886    let now = opts.now;
887    let command_sources = Rc::new(RefCell::new(Vec::new()));
888    let initial_state_json = state_to_json(&opts.initial_state)
889        .unwrap_or_else(|message| panic!("process_bundle: {message}"));
890
891    let command_id = format!("{name}/command");
892    let command = graph.init_node::<ProcessCommand<TCommand>>(
893        Operator::with_opts(
894            "processCommand",
895            no_terminal_opts(),
896            process_command_source_body::<TCommand>(0),
897        ),
898        Vec::new(),
899        meta_opts(command_id.clone(), "command"),
900    );
901
902    let runtime = graph.init_node::<ProcessRuntimeFact<TState, TEvent, TEffect, TCommand>>(
903        Operator::with_opts("processRuntime", no_terminal_opts(), {
904            move |ctx: &Ctx| {
905                ctx.state_persist(true);
906                let mut state = match ctx.state_get::<Value>() {
907                    Some(value) => match RuntimeState::from_json(value.as_ref()) {
908                        Ok(state) => state,
909                        Err(message) => {
910                            let mut recovery =
911                                RuntimeState::cursor_recovery(initial_state_json.clone());
912                            for command in ctx.batch::<ProcessCommand<TCommand>>(0) {
913                                for fact in failure::<TCommand, TState, TEvent, TEffect>(
914                                    &mut recovery,
915                                    Some((*command).clone()),
916                                    ProcessErrorCode::MalformedState,
917                                    message.clone(),
918                                ) {
919                                    ctx.emit(fact);
920                                }
921                            }
922                            return;
923                        }
924                    },
925                    None => RuntimeState::new(initial_state_json.clone()),
926                };
927                for command in ctx.batch::<ProcessCommand<TCommand>>(0) {
928                    for fact in reduce_process_command_fact(
929                        &mut state,
930                        (*command).clone(),
931                        reduce.as_ref(),
932                        now.as_ref(),
933                    ) {
934                        ctx.emit(fact);
935                    }
936                    ctx.state_set(state.to_json());
937                }
938            }
939        }),
940        vec![command.erased()],
941        meta_opts(format!("{name}/runtime"), "runtime"),
942    );
943
944    let state = runtime_projection::<TCommand, TState, TEvent, TEffect, TState>(
945        graph,
946        &runtime,
947        &format!("{name}/state"),
948        "processState",
949        |fact| match fact {
950            ProcessRuntimeFact::State { state, .. } => Some(state.clone()),
951            _ => None,
952        },
953    );
954    let events = runtime_projection::<TCommand, TState, TEvent, TEffect, ProcessEvent<TEvent>>(
955        graph,
956        &runtime,
957        &format!("{name}/events"),
958        "processEvents",
959        |fact| match fact {
960            ProcessRuntimeFact::Event(event) => Some(event.clone()),
961            _ => None,
962        },
963    );
964    let audit = runtime_projection::<TCommand, TState, TEvent, TEffect, ProcessAuditRecord>(
965        graph,
966        &runtime,
967        &format!("{name}/audit"),
968        "processAudit",
969        |fact| match fact {
970            ProcessRuntimeFact::Audit(audit) => Some(audit.clone()),
971            _ => None,
972        },
973    );
974    let effect_request =
975        runtime_projection::<TCommand, TState, TEvent, TEffect, ProcessEffectRequest<TEffect>>(
976            graph,
977            &runtime,
978            &format!("{name}/effect_request"),
979            "processEffectRequest",
980            |fact| match fact {
981                ProcessRuntimeFact::EffectRequest(effect) => Some(effect.clone()),
982                _ => None,
983            },
984        );
985    let status = runtime_projection::<TCommand, TState, TEvent, TEffect, ProcessStatus>(
986        graph,
987        &runtime,
988        &format!("{name}/status"),
989        "processStatus",
990        |fact| match fact {
991            ProcessRuntimeFact::Status(status) => Some(status.clone()),
992            _ => None,
993        },
994    );
995    let error = runtime_projection::<TCommand, TState, TEvent, TEffect, ProcessError<TCommand>>(
996        graph,
997        &runtime,
998        &format!("{name}/error"),
999        "processError",
1000        |fact| match fact {
1001            ProcessRuntimeFact::Error(error) => Some(error.clone()),
1002            _ => None,
1003        },
1004    );
1005    let cursor = runtime_projection::<TCommand, TState, TEvent, TEffect, ProcessCursor>(
1006        graph,
1007        &runtime,
1008        &format!("{name}/cursor"),
1009        "processCursor",
1010        |fact| match fact {
1011            ProcessRuntimeFact::Cursor(cursor) => Some(cursor.clone()),
1012            _ => None,
1013        },
1014    );
1015
1016    let retains = Rc::new(vec![
1017        ProcessRetain::new(graph.retain(&runtime, &format!("{name}.process.runtime"))),
1018        ProcessRetain::new(graph.retain(&state, &format!("{name}.process.state"))),
1019        ProcessRetain::new(graph.retain(&events, &format!("{name}.process.events"))),
1020        ProcessRetain::new(graph.retain(&audit, &format!("{name}.process.audit"))),
1021        ProcessRetain::new(
1022            graph.retain(&effect_request, &format!("{name}.process.effect_request")),
1023        ),
1024        ProcessRetain::new(graph.retain(&status, &format!("{name}.process.status"))),
1025        ProcessRetain::new(graph.retain(&error, &format!("{name}.process.error"))),
1026        ProcessRetain::new(graph.retain(&cursor, &format!("{name}.process.cursor"))),
1027    ]);
1028
1029    ProcessBundle {
1030        command,
1031        state,
1032        events,
1033        audit,
1034        effect_request,
1035        status,
1036        error,
1037        cursor,
1038        command_sources,
1039        command_id,
1040        _retains: retains,
1041    }
1042}
1043
1044/// Creates or computes `process_effect_runner`.
1045pub fn process_effect_runner<TCommand, TState, TEvent, TEffect, TResult>(
1046    graph: &Graph,
1047    process: &ProcessBundle<TCommand, TState, TEvent, TEffect>,
1048    opts: ProcessEffectRunnerOptions<TCommand, TResult>,
1049) -> ProcessEffectRunnerBundle<TCommand, TEffect, TResult>
1050where
1051    TCommand: Clone + 'static,
1052    TEffect: Clone + 'static,
1053    TResult: Clone + 'static,
1054{
1055    assert!(
1056        !opts.outcomes.is_empty(),
1057        "process_effect_runner: outcomes must contain at least one node"
1058    );
1059    let name = opts.name;
1060    let command_payload = opts.command_payload;
1061
1062    let requests = graph.init_node::<ProcessEffectRequest<TEffect>>(
1063        Operator::with_opts("processEffectRunnerRequests", no_terminal_opts(), |ctx| {
1064            for request in ctx.batch::<ProcessEffectRequest<TEffect>>(0) {
1065                ctx.emit((*request).clone());
1066            }
1067        }),
1068        vec![process.effect_request.erased()],
1069        effect_runner_meta_opts(format!("{name}/requests"), "requests"),
1070    );
1071
1072    let outcome_deps = opts.outcomes.iter().map(Node::erased).collect::<Vec<_>>();
1073    let runtime = graph.init_node::<ProcessEffectRunnerFact<TCommand, TResult>>(
1074        Operator::with_opts("processEffectRunner", no_terminal_opts(), {
1075            let outcome_count = outcome_deps.len();
1076            move |ctx: &Ctx| {
1077                for index in 0..outcome_count {
1078                    for outcome in ctx.batch::<ProcessEffectOutcome<TResult>>(index) {
1079                        match process_effect_outcome_command(
1080                            outcome.as_ref(),
1081                            command_payload.as_ref(),
1082                        ) {
1083                            Ok((command, payload)) => {
1084                                ctx.emit(ProcessEffectRunnerFact::<TCommand, TResult>::Outcome(
1085                                    (*outcome).clone(),
1086                                ));
1087                                ctx.emit(ProcessEffectRunnerFact::Command(command, payload));
1088                            }
1089                            Err(error) => {
1090                                ctx.emit(ProcessEffectRunnerFact::<TCommand, TResult>::Error(
1091                                    *error,
1092                                ));
1093                            }
1094                        }
1095                    }
1096                }
1097            }
1098        }),
1099        outcome_deps,
1100        effect_runner_meta_opts(format!("{name}/runtime"), "runtime"),
1101    );
1102
1103    let outcomes = effect_runner_projection::<TCommand, TResult, ProcessEffectOutcome<TResult>>(
1104        graph,
1105        &runtime,
1106        &format!("{name}/outcomes"),
1107        "processEffectRunnerOutcomes",
1108        |fact| match fact {
1109            ProcessEffectRunnerFact::Outcome(outcome) => Some(outcome.clone()),
1110            _ => None,
1111        },
1112    );
1113    let commands = effect_runner_projection::<TCommand, TResult, ProcessCommand<TCommand>>(
1114        graph,
1115        &runtime,
1116        &format!("{name}/commands"),
1117        "processEffectRunnerCommands",
1118        |fact| match fact {
1119            ProcessEffectRunnerFact::Command(command, _) => Some(command.clone()),
1120            _ => None,
1121        },
1122    );
1123    let errors = effect_runner_projection::<TCommand, TResult, ProcessEffectRunnerError<TResult>>(
1124        graph,
1125        &runtime,
1126        &format!("{name}/errors"),
1127        "processEffectRunnerErrors",
1128        |fact| match fact {
1129            ProcessEffectRunnerFact::Error(error) => Some(error.clone()),
1130            _ => None,
1131        },
1132    );
1133    let status = graph.init_node::<ProcessEffectRunnerStatus>(
1134        Operator::with_opts("processEffectRunnerStatus", no_terminal_opts(), |ctx| {
1135            let mut counters = ctx
1136                .state_get::<ProcessEffectRunnerCounters>()
1137                .map_or_else(ProcessEffectRunnerCounters::default, |state| {
1138                    (*state).clone()
1139                });
1140            for request in ctx.batch::<ProcessEffectRequest<TEffect>>(0) {
1141                counters.requested += 1;
1142                ctx.emit(ProcessEffectRunnerStatus {
1143                    state: ProcessEffectRunnerStatusState::Requested,
1144                    effect_id: Some(request.id.clone()),
1145                    effect_type: Some(request.effect_type.clone()),
1146                    command_id: None,
1147                    command_type: None,
1148                    requested: counters.requested,
1149                    commanded: counters.commanded,
1150                    rejected: counters.rejected,
1151                });
1152            }
1153            for fact in ctx.batch::<ProcessEffectRunnerFact<TCommand, TResult>>(1) {
1154                match fact.as_ref() {
1155                    ProcessEffectRunnerFact::Command(command, payload) => {
1156                        counters.commanded += 1;
1157                        ctx.emit(ProcessEffectRunnerStatus {
1158                            state: ProcessEffectRunnerStatusState::Commanded,
1159                            effect_id: Some(payload.effect_id.clone()),
1160                            effect_type: Some(payload.effect_type.clone()),
1161                            command_id: Some(command.id.clone()),
1162                            command_type: Some(command_type_for_outcome(payload.kind)),
1163                            requested: counters.requested,
1164                            commanded: counters.commanded,
1165                            rejected: counters.rejected,
1166                        });
1167                    }
1168                    ProcessEffectRunnerFact::Error(error) => {
1169                        counters.rejected += 1;
1170                        ctx.emit(ProcessEffectRunnerStatus {
1171                            state: ProcessEffectRunnerStatusState::Rejected,
1172                            effect_id: error.effect_id.clone(),
1173                            effect_type: error.effect_type.clone(),
1174                            command_id: None,
1175                            command_type: None,
1176                            requested: counters.requested,
1177                            commanded: counters.commanded,
1178                            rejected: counters.rejected,
1179                        });
1180                    }
1181                    ProcessEffectRunnerFact::Outcome(_) => {}
1182                }
1183            }
1184            ctx.state_set(counters);
1185        }),
1186        vec![requests.erased(), runtime.erased()],
1187        effect_runner_meta_opts(format!("{name}/status"), "status"),
1188    );
1189
1190    let attach = catch_unwind(AssertUnwindSafe(|| {
1191        attach_process_command_source_parts(
1192            &process.command,
1193            &process.command_sources,
1194            commands.erased(),
1195        );
1196    }));
1197    if let Err(panic) = attach {
1198        graph.release_nodes(
1199            &[
1200                requests.erased(),
1201                outcomes.erased(),
1202                runtime.erased(),
1203                commands.erased(),
1204                status.erased(),
1205                errors.erased(),
1206            ],
1207            "process_effect_runner failed command wiring",
1208        );
1209        resume_unwind(panic);
1210    }
1211    let retains = retain_effect_runner_nodes(
1212        graph,
1213        EffectRunnerNodeRefs {
1214            requests: &requests,
1215            outcomes: &outcomes,
1216            runtime: &runtime,
1217            commands: &commands,
1218            status: &status,
1219            errors: &errors,
1220        },
1221        &name,
1222    );
1223
1224    ProcessEffectRunnerBundle {
1225        requests,
1226        outcomes,
1227        commands,
1228        status,
1229        errors,
1230        runtime,
1231        graph: graph.clone(),
1232        process_command: process.command.clone(),
1233        process_command_id: process.command_id.clone(),
1234        name,
1235        command_sources: process.command_sources.clone(),
1236        released: Cell::new(false),
1237        retains: RefCell::new(retains),
1238    }
1239}
1240
1241#[derive(Clone, Default)]
1242struct ProcessEffectRunnerCounters {
1243    requested: u64,
1244    commanded: u64,
1245    rejected: u64,
1246}
1247
1248type ProcessEffectRunnerCommandResult<TCommand, TResult> = Result<
1249    (
1250        ProcessCommand<TCommand>,
1251        ProcessEffectCommandPayload<TResult>,
1252    ),
1253    Box<ProcessEffectRunnerError<TResult>>,
1254>;
1255
1256fn process_effect_outcome_command<TCommand, TResult: Clone>(
1257    outcome: &ProcessEffectOutcome<TResult>,
1258    map_payload: &dyn Fn(ProcessEffectCommandPayload<TResult>) -> TCommand,
1259) -> ProcessEffectRunnerCommandResult<TCommand, TResult> {
1260    validate_process_effect_outcome(outcome)?;
1261    let command_type = command_type_for_outcome(outcome.kind);
1262    let payload = ProcessEffectCommandPayload {
1263        kind: outcome.kind,
1264        effect_id: outcome.effect_id.clone(),
1265        effect_type: outcome.effect_type.clone(),
1266        value: outcome.value.clone(),
1267        error: outcome.error.clone(),
1268        reason: outcome.reason.clone(),
1269        process_id: outcome.process_id.clone(),
1270        correlation_id: outcome.correlation_id.clone(),
1271        causation_id: outcome.causation_id.clone(),
1272    };
1273    let command = ProcessCommand {
1274        id: outcome.command_id.clone().unwrap_or_else(|| {
1275            compound_tuple_key(
1276                "process-effect-command",
1277                &[&outcome.effect_id, command_type.as_str()],
1278            )
1279        }),
1280        command_type: command_type.as_str().to_owned(),
1281        payload: map_payload(payload.clone()),
1282        process_id: outcome.process_id.clone(),
1283        correlation_id: outcome.correlation_id.clone(),
1284        causation_id: outcome.causation_id.clone(),
1285    };
1286    Ok((command, payload))
1287}
1288
1289fn validate_process_effect_outcome<TResult>(
1290    outcome: &ProcessEffectOutcome<TResult>,
1291) -> Result<(), Box<ProcessEffectRunnerError<TResult>>>
1292where
1293    TResult: Clone,
1294{
1295    let reject = |message: String| {
1296        Box::new(ProcessEffectRunnerError {
1297            code: ProcessEffectRunnerErrorCode::MalformedOutcome,
1298            message,
1299            outcome: Some(outcome.clone()),
1300            effect_id: (!outcome.effect_id.is_empty()).then(|| outcome.effect_id.clone()),
1301            effect_type: (!outcome.effect_type.is_empty()).then(|| outcome.effect_type.clone()),
1302        })
1303    };
1304    if outcome.effect_id.is_empty() {
1305        return Err(reject(
1306            "process_effect_runner: outcome effect_id must be a non-empty string".to_owned(),
1307        ));
1308    }
1309    if outcome.effect_type.is_empty() {
1310        return Err(reject(
1311            "process_effect_runner: outcome effect_type must be a non-empty string".to_owned(),
1312        ));
1313    }
1314    if matches!(outcome.command_id.as_deref(), Some("")) {
1315        return Err(reject(
1316            "process_effect_runner: outcome command_id must be a non-empty string".to_owned(),
1317        ));
1318    }
1319    match outcome.kind {
1320        ProcessEffectOutcomeKind::Result if outcome.value.is_none() => Err(reject(
1321            "process_effect_runner: result outcome must carry value".to_owned(),
1322        )),
1323        ProcessEffectOutcomeKind::Failure if outcome.error.is_none() => Err(reject(
1324            "process_effect_runner: failure outcome must carry error".to_owned(),
1325        )),
1326        ProcessEffectOutcomeKind::Timeout if outcome.error.is_none() => Err(reject(
1327            "process_effect_runner: timeout outcome must carry error".to_owned(),
1328        )),
1329        ProcessEffectOutcomeKind::Result if outcome.error.is_some() || outcome.reason.is_some() => {
1330            Err(reject(
1331                "process_effect_runner: result outcome must not carry error or reason".to_owned(),
1332            ))
1333        }
1334        ProcessEffectOutcomeKind::Failure
1335            if outcome.value.is_some() || outcome.reason.is_some() =>
1336        {
1337            Err(reject(
1338                "process_effect_runner: failure outcome must not carry value or reason".to_owned(),
1339            ))
1340        }
1341        ProcessEffectOutcomeKind::Cancel if outcome.value.is_some() || outcome.error.is_some() => {
1342            Err(reject(
1343                "process_effect_runner: cancel outcome must not carry value or error".to_owned(),
1344            ))
1345        }
1346        ProcessEffectOutcomeKind::Timeout
1347            if outcome.value.is_some() || outcome.reason.is_some() =>
1348        {
1349            Err(reject(
1350                "process_effect_runner: timeout outcome must not carry value or reason".to_owned(),
1351            ))
1352        }
1353        _ => Ok(()),
1354    }
1355}
1356
1357fn command_type_for_outcome(kind: ProcessEffectOutcomeKind) -> ProcessEffectCommandType {
1358    match kind {
1359        ProcessEffectOutcomeKind::Result => ProcessEffectCommandType::Result,
1360        ProcessEffectOutcomeKind::Failure => ProcessEffectCommandType::Failure,
1361        ProcessEffectOutcomeKind::Cancel => ProcessEffectCommandType::Cancel,
1362        ProcessEffectOutcomeKind::Timeout => ProcessEffectCommandType::Timeout,
1363    }
1364}
1365
1366fn effect_runner_projection<TCommand, TResult, TOut>(
1367    graph: &Graph,
1368    runtime: &Node<ProcessEffectRunnerFact<TCommand, TResult>>,
1369    name: &str,
1370    factory: &'static str,
1371    select: impl Fn(&ProcessEffectRunnerFact<TCommand, TResult>) -> Option<TOut> + 'static,
1372) -> Node<TOut>
1373where
1374    TCommand: Clone + 'static,
1375    TResult: Clone + 'static,
1376    TOut: Clone + 'static,
1377{
1378    graph.init_node::<TOut>(
1379        Operator::with_opts(factory, no_terminal_opts(), move |ctx: &Ctx| {
1380            for fact in ctx.batch::<ProcessEffectRunnerFact<TCommand, TResult>>(0) {
1381                if let Some(selected) = select(fact.as_ref()) {
1382                    ctx.emit(selected);
1383                }
1384            }
1385        }),
1386        vec![runtime.erased()],
1387        effect_runner_meta_opts(name.to_owned(), "projection"),
1388    )
1389}
1390
1391struct EffectRunnerNodeRefs<'a, TCommand, TEffect, TResult> {
1392    requests: &'a Node<ProcessEffectRequest<TEffect>>,
1393    outcomes: &'a Node<ProcessEffectOutcome<TResult>>,
1394    runtime: &'a Node<ProcessEffectRunnerFact<TCommand, TResult>>,
1395    commands: &'a Node<ProcessCommand<TCommand>>,
1396    status: &'a Node<ProcessEffectRunnerStatus>,
1397    errors: &'a Node<ProcessEffectRunnerError<TResult>>,
1398}
1399
1400fn retain_effect_runner_nodes<TCommand, TEffect, TResult>(
1401    graph: &Graph,
1402    nodes: EffectRunnerNodeRefs<'_, TCommand, TEffect, TResult>,
1403    name: &str,
1404) -> Vec<ProcessRetain>
1405where
1406    TCommand: 'static,
1407    TEffect: 'static,
1408    TResult: 'static,
1409{
1410    vec![
1411        ProcessRetain::new(graph.retain(nodes.requests, &format!("{name}.effect_runner.requests"))),
1412        ProcessRetain::new(graph.retain(nodes.outcomes, &format!("{name}.effect_runner.outcomes"))),
1413        ProcessRetain::new(graph.retain(nodes.runtime, &format!("{name}.effect_runner.runtime"))),
1414        ProcessRetain::new(graph.retain(nodes.commands, &format!("{name}.effect_runner.commands"))),
1415        ProcessRetain::new(graph.retain(nodes.status, &format!("{name}.effect_runner.status"))),
1416        ProcessRetain::new(graph.retain(nodes.errors, &format!("{name}.effect_runner.errors"))),
1417    ]
1418}
1419
1420fn attach_process_command_source_parts<TCommand>(
1421    command: &Node<ProcessCommand<TCommand>>,
1422    sources: &Rc<RefCell<Vec<Core>>>,
1423    source: Core,
1424) where
1425    TCommand: Clone + 'static,
1426{
1427    let previous = sources.borrow().clone();
1428    {
1429        let mut current = sources.borrow_mut();
1430        if !current.iter().any(|candidate| candidate.ptr_eq(&source)) {
1431            current.push(source.clone());
1432        }
1433    }
1434    let command_sources = sources.borrow().clone();
1435    let source_count = command_sources.len();
1436    let rewire = catch_unwind(AssertUnwindSafe(|| {
1437        command.replace_deps(
1438            command_sources,
1439            process_command_source_body::<TCommand>(source_count),
1440        );
1441    }));
1442    if let Err(panic) = rewire {
1443        *sources.borrow_mut() = previous.clone();
1444        command.replace_deps(
1445            previous,
1446            process_command_source_body::<TCommand>(sources.borrow().len()),
1447        );
1448        resume_unwind(panic);
1449    }
1450}
1451
1452fn detach_process_command_source<TCommand>(
1453    command: &Node<ProcessCommand<TCommand>>,
1454    sources: &Rc<RefCell<Vec<Core>>>,
1455    source: Core,
1456) where
1457    TCommand: Clone + 'static,
1458{
1459    let previous = sources.borrow().clone();
1460    if !previous.iter().any(|candidate| candidate.ptr_eq(&source)) {
1461        return;
1462    }
1463    let next = previous
1464        .iter()
1465        .filter(|candidate| !candidate.ptr_eq(&source))
1466        .cloned()
1467        .collect::<Vec<_>>();
1468    *sources.borrow_mut() = next.clone();
1469    let rewire = catch_unwind(AssertUnwindSafe(|| {
1470        command.replace_deps(
1471            next,
1472            process_command_source_body::<TCommand>(sources.borrow().len()),
1473        );
1474    }));
1475    if let Err(panic) = rewire {
1476        *sources.borrow_mut() = previous.clone();
1477        command.replace_deps(
1478            previous,
1479            process_command_source_body::<TCommand>(sources.borrow().len()),
1480        );
1481        resume_unwind(panic);
1482    }
1483}
1484
1485fn process_command_source_body<TCommand: Clone + 'static>(
1486    source_count: usize,
1487) -> impl Fn(&Ctx) + 'static {
1488    move |ctx: &Ctx| {
1489        for index in 0..source_count {
1490            for command in ctx.batch::<ProcessCommand<TCommand>>(index) {
1491                ctx.emit((*command).clone());
1492            }
1493        }
1494    }
1495}
1496
1497#[derive(Debug)]
1498struct RuntimeState {
1499    event_seq: u64,
1500    effect_seq: u64,
1501    command_count: u64,
1502    error_count: u64,
1503    audit_seq: u64,
1504    seen_event_ids: Vec<String>,
1505    seen_effect_ids: Vec<String>,
1506    state: Value,
1507}
1508
1509impl RuntimeState {
1510    fn new(state: Value) -> Self {
1511        Self {
1512            event_seq: 0,
1513            effect_seq: 0,
1514            command_count: 0,
1515            error_count: 0,
1516            audit_seq: 0,
1517            seen_event_ids: Vec::new(),
1518            seen_effect_ids: Vec::new(),
1519            state,
1520        }
1521    }
1522
1523    fn from_json(value: &Value) -> Result<Self, String> {
1524        Ok(Self {
1525            event_seq: json_required_u64(value, "eventSeq")?,
1526            effect_seq: json_required_u64(value, "effectSeq")?,
1527            command_count: json_required_u64(value, "commandCount")?,
1528            error_count: json_required_u64(value, "errorCount")?,
1529            audit_seq: json_required_u64(value, "auditSeq")?,
1530            seen_event_ids: json_required_string_array(value, "seenEventIds")?,
1531            seen_effect_ids: json_required_string_array(value, "seenEffectIds")?,
1532            state: value
1533                .get("state")
1534                .cloned()
1535                .ok_or_else(|| "process_bundle: runtime state missing 'state'".to_owned())?,
1536        })
1537    }
1538
1539    fn cursor_recovery(state: Value) -> Self {
1540        Self::new(state)
1541    }
1542
1543    fn to_json(&self) -> Value {
1544        serde_json::json!({
1545            "eventSeq": self.event_seq,
1546            "effectSeq": self.effect_seq,
1547            "commandCount": self.command_count,
1548            "errorCount": self.error_count,
1549            "auditSeq": self.audit_seq,
1550            "seenEventIds": self.seen_event_ids,
1551            "seenEffectIds": self.seen_effect_ids,
1552            "state": self.state,
1553        })
1554    }
1555}
1556
1557fn reduce_process_command_fact<
1558    TCommand: Clone + 'static,
1559    TState: Clone + Serialize + DeserializeOwned + 'static,
1560    TEvent: Clone + 'static,
1561    TEffect: Clone + 'static,
1562>(
1563    state: &mut RuntimeState,
1564    command: ProcessCommand<TCommand>,
1565    reduce: &ProcessReducerFn<TCommand, TState, TEvent, TEffect>,
1566    now: &dyn Fn() -> u64,
1567) -> Vec<ProcessRuntimeFact<TState, TEvent, TEffect, TCommand>> {
1568    state.command_count += 1;
1569    if command.id.is_empty() {
1570        return failure(
1571            state,
1572            Some(command),
1573            ProcessErrorCode::MalformedCommand,
1574            "process_bundle: command id must be non-empty".to_owned(),
1575        );
1576    }
1577    if command.command_type.is_empty() {
1578        return failure(
1579            state,
1580            Some(command),
1581            ProcessErrorCode::MalformedCommand,
1582            "process_bundle: command type must be non-empty".to_owned(),
1583        );
1584    }
1585    let reducer_state = match state_from_json::<TState>(&state.state) {
1586        Ok(state) => state,
1587        Err(message) => {
1588            return failure(
1589                state,
1590                Some(command),
1591                ProcessErrorCode::MalformedState,
1592                message,
1593            );
1594        }
1595    };
1596    let reduction = match catch_unwind(AssertUnwindSafe(|| reduce(&command, reducer_state))) {
1597        Ok(reduction) => reduction,
1598        Err(panic) => {
1599            if is_graph_runtime_panic(&panic) {
1600                resume_unwind(panic);
1601            }
1602            return failure(
1603                state,
1604                Some(command),
1605                ProcessErrorCode::ReducerThrew,
1606                panic_message(&panic),
1607            );
1608        }
1609    };
1610    let next_state_json = match state_to_json(&reduction.state) {
1611        Ok(state) => state,
1612        Err(message) => {
1613            return failure(
1614                state,
1615                Some(command),
1616                ProcessErrorCode::MalformedState,
1617                message,
1618            );
1619        }
1620    };
1621    let visible_state = match state_from_json::<TState>(&next_state_json) {
1622        Ok(state) => state,
1623        Err(message) => {
1624            return failure(
1625                state,
1626                Some(command),
1627                ProcessErrorCode::MalformedState,
1628                message,
1629            );
1630        }
1631    };
1632    let prepared_events = match prepare_events(&command, &reduction.events, state) {
1633        Ok(events) => events,
1634        Err(message) => {
1635            return failure(
1636                state,
1637                Some(command),
1638                ProcessErrorCode::MalformedEvent,
1639                message,
1640            );
1641        }
1642    };
1643    let prepared_effects = match prepare_effects(&command, &reduction.effects, state) {
1644        Ok(effects) => effects,
1645        Err(message) => {
1646            return failure(
1647                state,
1648                Some(command),
1649                ProcessErrorCode::MalformedEffect,
1650                message,
1651            );
1652        }
1653    };
1654    let timestamp_ms = match process_timestamp(now) {
1655        Ok(timestamp_ms) => timestamp_ms,
1656        Err(message) => {
1657            return failure(state, Some(command), ProcessErrorCode::ClockThrew, message);
1658        }
1659    };
1660
1661    state.state = next_state_json;
1662    let mut facts = vec![ProcessRuntimeFact::State {
1663        state: visible_state,
1664        cursor: cursor_of(state),
1665    }];
1666
1667    let mut events = Vec::new();
1668    for draft in prepared_events {
1669        state.event_seq += 1;
1670        state.seen_event_ids.push(draft.id.clone());
1671        let event = ProcessEvent {
1672            id: draft.id,
1673            event_type: draft.event_type,
1674            seq: state.event_seq,
1675            cursor: state.event_seq,
1676            command_id: command.id.clone(),
1677            command_type: command.command_type.clone(),
1678            payload: draft.payload,
1679            timestamp_ms,
1680            process_id: draft.process_id,
1681            correlation_id: draft.correlation_id,
1682            causation_id: draft.causation_id,
1683        };
1684        facts.push(ProcessRuntimeFact::Event(event.clone()));
1685        events.push(event);
1686    }
1687
1688    let mut effects = Vec::new();
1689    for draft in prepared_effects {
1690        state.effect_seq += 1;
1691        state.seen_effect_ids.push(draft.id.clone());
1692        let effect = ProcessEffectRequest {
1693            id: draft.id,
1694            effect_type: draft.effect_type,
1695            seq: state.effect_seq,
1696            cursor: state.effect_seq,
1697            command_id: command.id.clone(),
1698            command_type: command.command_type.clone(),
1699            payload: draft.payload,
1700            timestamp_ms,
1701            process_id: draft.process_id,
1702            correlation_id: draft.correlation_id,
1703            causation_id: draft.causation_id,
1704        };
1705        facts.push(ProcessRuntimeFact::EffectRequest(effect.clone()));
1706        effects.push(effect);
1707    }
1708
1709    facts.push(ProcessRuntimeFact::Status(ProcessStatus {
1710        state: ProcessStatusState::Accepted,
1711        command_id: Some(command.id.clone()),
1712        command_type: Some(command.command_type.clone()),
1713        event_count: events.len(),
1714        effect_count: effects.len(),
1715        error_code: None,
1716        cursor: cursor_of(state),
1717    }));
1718    facts.push(ProcessRuntimeFact::Audit(audit_record::<
1719        TCommand,
1720        TEvent,
1721        TEffect,
1722    >(
1723        state,
1724        Some(&command),
1725        ProcessAuditOutcome::Success,
1726        &events,
1727        &effects,
1728        None,
1729        None,
1730    )));
1731    facts.push(ProcessRuntimeFact::Cursor(cursor_of(state)));
1732    facts
1733}
1734
1735#[derive(Clone)]
1736struct PreparedEvent<T> {
1737    id: String,
1738    event_type: String,
1739    payload: T,
1740    process_id: Option<String>,
1741    correlation_id: Option<String>,
1742    causation_id: Option<String>,
1743}
1744
1745#[derive(Clone)]
1746struct PreparedEffect<T> {
1747    id: String,
1748    effect_type: String,
1749    payload: T,
1750    process_id: Option<String>,
1751    correlation_id: Option<String>,
1752    causation_id: Option<String>,
1753}
1754
1755fn prepare_events<TCommand, TEvent: Clone>(
1756    command: &ProcessCommand<TCommand>,
1757    drafts: &[ProcessEventDraft<TEvent>],
1758    state: &RuntimeState,
1759) -> Result<Vec<PreparedEvent<TEvent>>, String> {
1760    let mut seen = Vec::<String>::new();
1761    let mut prepared = Vec::new();
1762    for (index, draft) in drafts.iter().enumerate() {
1763        if draft.event_type.is_empty() {
1764            return Err("process_bundle: event draft must have a non-empty type".to_owned());
1765        }
1766        let id = draft
1767            .id
1768            .as_ref()
1769            .filter(|id| !id.is_empty())
1770            .cloned()
1771            .unwrap_or_else(|| {
1772                compound_tuple_key(
1773                    "process-event",
1774                    &[
1775                        &command.id,
1776                        &(state.event_seq + index as u64 + 1).to_string(),
1777                    ],
1778                )
1779            });
1780        if seen.contains(&id) || state.seen_event_ids.contains(&id) {
1781            return Err(format!("process_bundle: duplicate event '{id}'"));
1782        }
1783        seen.push(id.clone());
1784        prepared.push(PreparedEvent {
1785            id,
1786            event_type: draft.event_type.clone(),
1787            payload: draft.payload.clone(),
1788            process_id: draft.process_id.clone(),
1789            correlation_id: draft.correlation_id.clone(),
1790            causation_id: draft.causation_id.clone(),
1791        });
1792    }
1793    Ok(prepared)
1794}
1795
1796fn prepare_effects<TCommand, TEffect: Clone>(
1797    command: &ProcessCommand<TCommand>,
1798    drafts: &[ProcessEffectRequestDraft<TEffect>],
1799    state: &RuntimeState,
1800) -> Result<Vec<PreparedEffect<TEffect>>, String> {
1801    let mut seen = Vec::<String>::new();
1802    let mut prepared = Vec::new();
1803    for (index, draft) in drafts.iter().enumerate() {
1804        if draft.effect_type.is_empty() {
1805            return Err("process_bundle: effect draft must have a non-empty type".to_owned());
1806        }
1807        let id = draft
1808            .id
1809            .as_ref()
1810            .filter(|id| !id.is_empty())
1811            .cloned()
1812            .unwrap_or_else(|| {
1813                compound_tuple_key(
1814                    "process-effect",
1815                    &[
1816                        &command.id,
1817                        &(state.effect_seq + index as u64 + 1).to_string(),
1818                    ],
1819                )
1820            });
1821        if seen.contains(&id) || state.seen_effect_ids.contains(&id) {
1822            return Err(format!("process_bundle: duplicate effect '{id}'"));
1823        }
1824        seen.push(id.clone());
1825        prepared.push(PreparedEffect {
1826            id,
1827            effect_type: draft.effect_type.clone(),
1828            payload: draft.payload.clone(),
1829            process_id: draft.process_id.clone(),
1830            correlation_id: draft.correlation_id.clone(),
1831            causation_id: draft.causation_id.clone(),
1832        });
1833    }
1834    Ok(prepared)
1835}
1836
1837fn failure<
1838    TCommand: Clone + 'static,
1839    TState: Clone + 'static,
1840    TEvent: Clone + 'static,
1841    TEffect: Clone + 'static,
1842>(
1843    state: &mut RuntimeState,
1844    command: Option<ProcessCommand<TCommand>>,
1845    code: ProcessErrorCode,
1846    message: String,
1847) -> Vec<ProcessRuntimeFact<TState, TEvent, TEffect, TCommand>> {
1848    state.error_count += 1;
1849    let cursor = cursor_of(state);
1850    vec![
1851        ProcessRuntimeFact::Error(ProcessError {
1852            code,
1853            message: message.clone(),
1854            command: command.clone(),
1855            cursor: cursor.clone(),
1856        }),
1857        ProcessRuntimeFact::Status(ProcessStatus {
1858            state: ProcessStatusState::Rejected,
1859            command_id: command.as_ref().map(|command| command.id.clone()),
1860            command_type: command.as_ref().map(|command| command.command_type.clone()),
1861            event_count: 0,
1862            effect_count: 0,
1863            error_code: Some(code),
1864            cursor: cursor.clone(),
1865        }),
1866        ProcessRuntimeFact::Audit(audit_record::<TCommand, TEvent, TEffect>(
1867            state,
1868            command.as_ref(),
1869            ProcessAuditOutcome::Failure,
1870            &[],
1871            &[],
1872            Some(code),
1873            Some(message),
1874        )),
1875        ProcessRuntimeFact::Cursor(cursor_of(state)),
1876    ]
1877}
1878
1879fn audit_record<TCommand, TEvent, TEffect>(
1880    state: &mut RuntimeState,
1881    command: Option<&ProcessCommand<TCommand>>,
1882    outcome: ProcessAuditOutcome,
1883    events: &[ProcessEvent<TEvent>],
1884    effects: &[ProcessEffectRequest<TEffect>],
1885    error_code: Option<ProcessErrorCode>,
1886    error_message: Option<String>,
1887) -> ProcessAuditRecord {
1888    state.audit_seq += 1;
1889    ProcessAuditRecord {
1890        seq: state.audit_seq,
1891        command_id: command.map(|command| command.id.clone()),
1892        command_type: command.map(|command| command.command_type.clone()),
1893        outcome,
1894        event_ids: events.iter().map(|event| event.id.clone()).collect(),
1895        event_types: events
1896            .iter()
1897            .map(|event| event.event_type.clone())
1898            .collect(),
1899        effect_ids: effects.iter().map(|effect| effect.id.clone()).collect(),
1900        effect_types: effects
1901            .iter()
1902            .map(|effect| effect.effect_type.clone())
1903            .collect(),
1904        error_code,
1905        error_message,
1906        cursor: cursor_of(state),
1907    }
1908}
1909
1910fn runtime_projection<
1911    TCommand: Clone + 'static,
1912    TState: Clone + 'static,
1913    TEvent: Clone + 'static,
1914    TEffect: Clone + 'static,
1915    TOut: Clone + 'static,
1916>(
1917    graph: &Graph,
1918    runtime: &Node<ProcessRuntimeFact<TState, TEvent, TEffect, TCommand>>,
1919    name: &str,
1920    factory: &'static str,
1921    select: impl Fn(&ProcessRuntimeFact<TState, TEvent, TEffect, TCommand>) -> Option<TOut> + 'static,
1922) -> Node<TOut> {
1923    graph.init_node::<TOut>(
1924        Operator::with_opts(factory, no_terminal_opts(), move |ctx: &Ctx| {
1925            for fact in ctx.batch::<ProcessRuntimeFact<TState, TEvent, TEffect, TCommand>>(0) {
1926                if let Some(selected) = select(fact.as_ref()) {
1927                    ctx.emit(selected);
1928                }
1929            }
1930        }),
1931        vec![runtime.erased()],
1932        GraphNodeOpts::named(name),
1933    )
1934}
1935
1936fn no_terminal_opts() -> crate::node::NodeOpts {
1937    crate::node::NodeOpts {
1938        partial: true,
1939        complete_when_deps_complete: false,
1940        error_when_deps_error: false,
1941        ..Default::default()
1942    }
1943}
1944
1945fn meta_opts(name: String, role: &'static str) -> GraphNodeOpts {
1946    let mut opts = GraphNodeOpts::named(name);
1947    opts.meta.insert("process".to_owned(), role.to_owned());
1948    opts.meta.insert("d".to_owned(), "D136".to_owned());
1949    opts
1950}
1951
1952fn effect_runner_meta_opts(name: String, role: &'static str) -> GraphNodeOpts {
1953    let mut opts = GraphNodeOpts::named(name);
1954    opts.meta
1955        .insert("process".to_owned(), format!("effect-runner:{role}"));
1956    opts.meta.insert("d".to_owned(), "D156".to_owned());
1957    opts
1958}
1959
1960fn cursor_of(state: &RuntimeState) -> ProcessCursor {
1961    ProcessCursor {
1962        event_seq: state.event_seq,
1963        effect_seq: state.effect_seq,
1964        command_count: state.command_count,
1965        error_count: state.error_count,
1966        audit_seq: state.audit_seq,
1967    }
1968}
1969
1970fn state_to_json<TState: Serialize>(state: &TState) -> Result<Value, String> {
1971    serde_json::to_value(state).map_err(|error| format!("state must serialize to JSON ({error})"))
1972}
1973
1974fn state_from_json<TState: DeserializeOwned>(state: &Value) -> Result<TState, String> {
1975    serde_json::from_value(state.clone())
1976        .map_err(|error| format!("state must deserialize from JSON ({error})"))
1977}
1978
1979fn json_required_u64(value: &Value, key: &str) -> Result<u64, String> {
1980    value.get(key).and_then(Value::as_u64).ok_or_else(|| {
1981        format!("process_bundle: runtime state field '{key}' must be an unsigned integer")
1982    })
1983}
1984
1985fn json_required_string_array(value: &Value, key: &str) -> Result<Vec<String>, String> {
1986    let Some(items) = value.get(key).and_then(Value::as_array) else {
1987        return Err(format!(
1988            "process_bundle: runtime state field '{key}' must be an array of strings"
1989        ));
1990    };
1991    let mut out = Vec::new();
1992    for item in items {
1993        let Some(item) = item.as_str() else {
1994            return Err(format!(
1995                "process_bundle: runtime state field '{key}' must be an array of strings"
1996            ));
1997        };
1998        out.push(item.to_owned());
1999    }
2000    Ok(out)
2001}
2002
2003fn process_timestamp(now: &dyn Fn() -> u64) -> Result<u64, String> {
2004    catch_unwind(AssertUnwindSafe(now))
2005        .map_err(|panic| format!("process_bundle: now() threw: {}", panic_message(&panic)))
2006}
2007
2008fn panic_message(panic: &Box<dyn std::any::Any + Send>) -> String {
2009    if let Some(message) = panic.downcast_ref::<&str>() {
2010        return (*message).to_owned();
2011    }
2012    if let Some(message) = panic.downcast_ref::<String>() {
2013        return message.clone();
2014    }
2015    "panic".to_owned()
2016}
2017
2018fn is_graph_runtime_panic(panic: &Box<dyn std::any::Any + Send>) -> bool {
2019    let message = panic_message(panic);
2020    message.contains("R-reentrancy")
2021        || message.contains("R-rewire")
2022        || message.contains("R-graph-domain")
2023        || message.contains("D22")
2024        || message.contains("D37")
2025        || message.contains("feedback cycle")
2026        || message.contains("different graph")
2027        || message.contains("cross-graph")
2028        || message.contains("wire bridge")
2029        || message.contains("mid-fn topology mutation")
2030        || message.contains("reentrant dep mutation")
2031}
2032
2033#[cfg(test)]
2034mod tests {
2035    use super::*;
2036    use serde_json::json;
2037
2038    #[test]
2039    fn runtime_state_rejects_corrupt_checkpoint_fields() {
2040        let valid = json!({
2041            "eventSeq": 1,
2042            "effectSeq": 2,
2043            "commandCount": 3,
2044            "errorCount": 0,
2045            "auditSeq": 3,
2046            "seenEventIds": ["event-1"],
2047            "seenEffectIds": ["effect-1"],
2048            "state": { "total": 4 },
2049        });
2050        assert!(RuntimeState::from_json(&valid).is_ok());
2051
2052        let bad_counter = json!({
2053            "eventSeq": "1",
2054            "effectSeq": 2,
2055            "commandCount": 3,
2056            "errorCount": 0,
2057            "auditSeq": 3,
2058            "seenEventIds": ["event-1"],
2059            "seenEffectIds": ["effect-1"],
2060            "state": { "total": 4 },
2061        });
2062        let err = RuntimeState::from_json(&bad_counter).expect_err("bad counter fails");
2063        assert!(err.contains("eventSeq"));
2064
2065        let bad_seen_ids = json!({
2066            "eventSeq": 1,
2067            "effectSeq": 2,
2068            "commandCount": 3,
2069            "errorCount": 0,
2070            "auditSeq": 3,
2071            "seenEventIds": ["event-1", 7],
2072            "seenEffectIds": ["effect-1"],
2073            "state": { "total": 4 },
2074        });
2075        let err = RuntimeState::from_json(&bad_seen_ids).expect_err("bad id array fails");
2076        assert!(err.contains("seenEventIds"));
2077
2078        let missing_state = json!({
2079            "eventSeq": 1,
2080            "effectSeq": 2,
2081            "commandCount": 3,
2082            "errorCount": 0,
2083            "auditSeq": 3,
2084            "seenEventIds": ["event-1"],
2085            "seenEffectIds": ["effect-1"],
2086        });
2087        let err = RuntimeState::from_json(&missing_state).expect_err("missing state fails");
2088        assert!(err.contains("missing 'state'"));
2089    }
2090}