Skip to main content

yoagent_state/
state.rs

1use crate::{
2    ActorRef, ArtifactRef, Decision, DecisionStatus, EvalResult, Event, EventId, EventStore,
3    ForkId, ForkSnapshot, Frame, Goal, GoalId, GoalStatus, Graph, GraphDiff, GraphSnapshot,
4    Hypothesis, Lineage, ModelCall, NodeId, Observation, PatchId, PatchStatus, ProjectSnapshot,
5    RunId, StateError, StateOp, StatePatch, Task, TaskId, TaskStatus, ToolCall, diff_graphs,
6    fork_events_at, project_event, replay,
7};
8use serde_json::json;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12#[derive(Debug)]
13pub struct YoAgentState<S: EventStore> {
14    store: Arc<S>,
15    graph: Arc<RwLock<Graph>>,
16    /// The open run, if any: its `RunId` and its `run.started` event id.
17    /// Shared across clones — at most one run may be open per state handle
18    /// and all its clones (enforced by `record_run_started` /
19    /// `record_run_finished`). In-memory only: `load` does not recover an
20    /// open run from the log, so a process restarted mid-run must start a
21    /// new run before recording chained events.
22    current_run: Arc<RwLock<Option<(RunId, EventId)>>>,
23}
24
25impl<S: EventStore> Clone for YoAgentState<S> {
26    fn clone(&self) -> Self {
27        Self {
28            store: self.store.clone(),
29            graph: self.graph.clone(),
30            current_run: self.current_run.clone(),
31        }
32    }
33}
34
35impl<S: EventStore> YoAgentState<S> {
36    pub async fn load(store: S) -> Result<Self, StateError> {
37        let events = store.scan().await?;
38        let graph = replay(&events)?;
39        Ok(Self {
40            store: Arc::new(store),
41            graph: Arc::new(RwLock::new(graph)),
42            current_run: Arc::new(RwLock::new(None)),
43        })
44    }
45
46    pub fn store(&self) -> Arc<S> {
47        self.store.clone()
48    }
49
50    /// Append one event. Events recorded without an explicit `causation_id`
51    /// while a run is open (between `record_run_started` and
52    /// `record_run_finished`) are chained to the run's start event. Provided
53    /// all activity happens inside runs within a single process lifetime,
54    /// this keeps the causation graph rooted at `*.created` / `*.started` —
55    /// events recorded with no open run become roots of whatever kind they
56    /// are, and the open-run marker is not recovered by `load`.
57    pub async fn record_event(&self, mut event: Event) -> Result<EventId, StateError> {
58        if event.causation_id.is_none() && event.kind != "run.started" {
59            if let Some((_, run_event)) = self.current_run.read().await.clone() {
60                event.causation_id = Some(run_event);
61            }
62        }
63        let ids = self.store.append(vec![event.clone()]).await?;
64        {
65            let mut graph = self.graph.write().await;
66            project_event(&mut graph, &event)?;
67        }
68        Ok(ids[0].clone())
69    }
70
71    pub async fn apply_ops(
72        &self,
73        actor: ActorRef,
74        ops: Vec<StateOp>,
75    ) -> Result<EventId, StateError> {
76        self.apply_ops_caused_by(actor, ops, None).await
77    }
78
79    /// Append a `state.ops_applied` event carrying the domain event that caused
80    /// it, per the GASP pairing rule: ops that materialize a domain event set
81    /// `causation_id` to that event's id, keeping the audit layer and the
82    /// folded graph mechanically linked.
83    pub async fn apply_ops_caused_by(
84        &self,
85        actor: ActorRef,
86        ops: Vec<StateOp>,
87        caused_by: Option<EventId>,
88    ) -> Result<EventId, StateError> {
89        let mut event = Event::new(actor, crate::STATE_OPS_APPLIED, serde_json::to_value(ops)?);
90        event.causation_id = caused_by;
91        self.record_event(event).await
92    }
93
94    pub async fn propose_patch(&self, patch: StatePatch) -> Result<PatchId, StateError> {
95        let patch_id = patch.id.clone();
96        let actor = patch.created_by.clone();
97        let caused_by = self
98            .record_event(Event::new(
99                actor.clone(),
100                "patch.proposed",
101                serde_json::to_value(&patch)?,
102            ))
103            .await?;
104
105        let patch_node_id = NodeId::new(patch.id.0.clone());
106        let mut ops = vec![StateOp::CreateNode {
107            id: patch_node_id.clone(),
108            kind: "patch".to_string(),
109            props: json!({
110                "title": patch.title,
111                "summary": patch.summary,
112                "status": patch.status,
113                "base_state_version": patch.base_state_version,
114                "preconditions": patch.preconditions,
115                "expected_effects": patch.expected_effects,
116                "base_project_ref": patch.base_project_ref,
117            }),
118        }];
119
120        for evidence in patch.evidence {
121            ops.push(StateOp::CreateRelation {
122                from: patch_node_id.clone(),
123                rel: "addresses".to_string(),
124                to: evidence,
125                props: json!({}),
126            });
127        }
128
129        for artifact in patch.artifacts {
130            ops.push(StateOp::AttachArtifact {
131                id: patch_node_id.clone(),
132                artifact,
133            });
134        }
135
136        ops.extend(patch.ops);
137        self.apply_ops_caused_by(actor, ops, Some(caused_by)).await?;
138        Ok(patch_id)
139    }
140
141    pub async fn update_patch_status(
142        &self,
143        patch_id: PatchId,
144        status: PatchStatus,
145        reason: Option<String>,
146    ) -> Result<EventId, StateError> {
147        let actor = ActorRef::system("yoagent-state");
148        let event = Event::new(
149            actor.clone(),
150            "patch.status_changed",
151            json!({
152                "patch_id": patch_id,
153                "status": status,
154                "reason": reason,
155            }),
156        );
157        let caused_by = self.record_event(event).await?;
158
159        self.apply_ops_caused_by(
160            actor,
161            vec![StateOp::UpdateNode {
162                id: NodeId::new(patch_id.0),
163                props: json!({
164                    "status": status,
165                    "status_reason": reason,
166                }),
167            }],
168            Some(caused_by),
169        )
170        .await
171    }
172
173    pub async fn attach_artifact(
174        &self,
175        node_id: NodeId,
176        artifact: ArtifactRef,
177    ) -> Result<EventId, StateError> {
178        let actor = ActorRef::system("yoagent-state");
179        let caused_by = self
180            .record_event(Event::new(
181                actor.clone(),
182                "artifact.attached",
183                json!({
184                    "node_id": node_id,
185                    "artifact": artifact,
186                }),
187            ))
188            .await?;
189
190        self.apply_ops_caused_by(
191            actor,
192            vec![StateOp::AttachArtifact {
193                id: node_id,
194                artifact,
195            }],
196            Some(caused_by),
197        )
198        .await
199    }
200
201    pub async fn graph(&self) -> GraphSnapshot {
202        self.graph.read().await.clone()
203    }
204
205    pub async fn get_node(&self, node_id: NodeId) -> Option<crate::Node> {
206        self.graph.read().await.get_node(&node_id).cloned()
207    }
208
209    pub async fn outgoing(&self, node_id: NodeId, rel: Option<&str>) -> Vec<crate::Relation> {
210        self.graph.read().await.outgoing(&node_id, rel)
211    }
212
213    pub async fn incoming(&self, node_id: NodeId, rel: Option<&str>) -> Vec<crate::Relation> {
214        self.graph.read().await.incoming(&node_id, rel)
215    }
216
217    pub async fn related(&self, node_id: NodeId) -> Vec<crate::Relation> {
218        self.graph.read().await.related(&node_id)
219    }
220
221    pub async fn patches_for_failure(&self, failure_id: NodeId) -> Vec<crate::Node> {
222        let graph = self.graph.read().await;
223        graph
224            .incoming(&failure_id, Some("addresses"))
225            .into_iter()
226            .filter_map(|rel| graph.get_node(&rel.from).cloned())
227            .filter(|node| node.kind == "patch")
228            .collect()
229    }
230
231    pub async fn evals_for_patch(&self, patch_id: PatchId) -> Vec<crate::Node> {
232        let patch_node_id = NodeId::new(patch_id.0);
233        let graph = self.graph.read().await;
234        graph
235            .outgoing(&patch_node_id, Some("validated_by"))
236            .into_iter()
237            .filter_map(|rel| graph.get_node(&rel.to).cloned())
238            .collect()
239    }
240
241    pub async fn lineage(&self, node_id: NodeId) -> Lineage {
242        let graph = self.graph.read().await;
243        Lineage::from_graph(&graph, &node_id)
244    }
245
246    pub async fn record_run_started(
247        &self,
248        actor: ActorRef,
249        run_id: RunId,
250        task: impl Into<String>,
251    ) -> Result<EventId, StateError> {
252        if let Some((open_run, _)) = self.current_run.read().await.as_ref() {
253            return Err(StateError::Validation(format!(
254                "run {open_run} is already open; finish it before starting {run_id}"
255            )));
256        }
257        let task = task.into();
258        let caused_by = self
259            .record_event(Event::new(
260                actor.clone(),
261                "run.started",
262                json!({ "run_id": run_id, "task": task }),
263            ))
264            .await?;
265        let result = self
266            .apply_ops_caused_by(
267                actor,
268                vec![StateOp::CreateNode {
269                    id: NodeId::new(run_id.0.clone()),
270                    kind: crate::KIND_RUN.to_string(),
271                    props: json!({ "task": task, "status": "started" }),
272                }],
273                Some(caused_by.clone()),
274            )
275            .await?;
276        // Only a fully-recorded run (domain event + ops pair) opens chaining;
277        // a failed ops append must not leave events chaining to a phantom run.
278        *self.current_run.write().await = Some((run_id, caused_by));
279        Ok(result)
280    }
281
282    pub async fn record_run_finished(
283        &self,
284        actor: ActorRef,
285        run_id: RunId,
286        outcome: impl Into<String>,
287    ) -> Result<EventId, StateError> {
288        match self.current_run.read().await.as_ref() {
289            Some((open_run, _)) if *open_run == run_id => {}
290            Some((open_run, _)) => {
291                return Err(StateError::Validation(format!(
292                    "cannot finish {run_id}: the open run is {open_run}"
293                )));
294            }
295            None => {
296                return Err(StateError::Validation(format!(
297                    "cannot finish {run_id}: no run is open"
298                )));
299            }
300        }
301        let event_id = self
302            .record_event(Event::new(
303                actor,
304                "run.finished",
305                json!({ "run_id": run_id, "outcome": outcome.into() }),
306            ))
307            .await?;
308        *self.current_run.write().await = None;
309        Ok(event_id)
310    }
311
312    pub async fn record_goal(&self, goal: Goal) -> Result<EventId, StateError> {
313        let actor = goal.owner.clone();
314        let caused_by = self
315            .record_event(Event::new(
316                actor.clone(),
317                "goal.created",
318                serde_json::to_value(&goal)?,
319            ))
320            .await?;
321        self.apply_ops_caused_by(
322            actor,
323            vec![StateOp::CreateNode {
324                id: NodeId::new(goal.id.0),
325                kind: crate::KIND_GOAL.to_string(),
326                props: json!({
327                    "title": goal.title,
328                    "summary": goal.summary,
329                    "status": goal.status,
330                    "owner": goal.owner,
331                    "metadata": goal.metadata,
332                }),
333            }],
334            Some(caused_by),
335        )
336        .await
337    }
338
339    pub async fn update_goal_status(
340        &self,
341        goal_id: GoalId,
342        status: GoalStatus,
343        reason: Option<String>,
344    ) -> Result<EventId, StateError> {
345        let actor = ActorRef::system("yoagent-state");
346        let caused_by = self
347            .record_event(Event::new(
348                actor.clone(),
349                "goal.status_changed",
350                json!({ "goal_id": goal_id, "status": status, "reason": reason }),
351            ))
352            .await?;
353        self.apply_ops_caused_by(
354            actor,
355            vec![StateOp::UpdateNode {
356                id: NodeId::new(goal_id.0),
357                props: json!({ "status": status, "status_reason": reason }),
358            }],
359            Some(caused_by),
360        )
361        .await
362    }
363
364    pub async fn record_task(&self, task: Task) -> Result<EventId, StateError> {
365        let actor = task.created_by.clone();
366        let caused_by = self
367            .record_event(Event::new(
368                actor.clone(),
369                "task.created",
370                serde_json::to_value(&task)?,
371            ))
372            .await?;
373        let task_id = NodeId::new(task.id.0);
374        let mut ops = vec![StateOp::CreateNode {
375            id: task_id.clone(),
376            kind: crate::KIND_TASK.to_string(),
377            props: json!({
378                "title": task.title,
379                "summary": task.summary,
380                "status": task.status,
381                "metadata": task.metadata,
382            }),
383        }];
384        if let Some(goal) = task.goal {
385            ops.push(StateOp::CreateRelation {
386                from: task_id,
387                rel: crate::REL_SERVES.to_string(),
388                to: NodeId::new(goal.0),
389                props: json!({}),
390            });
391        }
392        self.apply_ops_caused_by(actor, ops, Some(caused_by)).await
393    }
394
395    pub async fn update_task_status(
396        &self,
397        task_id: TaskId,
398        status: TaskStatus,
399        reason: Option<String>,
400    ) -> Result<EventId, StateError> {
401        let actor = ActorRef::system("yoagent-state");
402        let caused_by = self
403            .record_event(Event::new(
404                actor.clone(),
405                "task.status_changed",
406                json!({ "task_id": task_id, "status": status, "reason": reason }),
407            ))
408            .await?;
409        self.apply_ops_caused_by(
410            actor,
411            vec![StateOp::UpdateNode {
412                id: NodeId::new(task_id.0),
413                props: json!({ "status": status, "status_reason": reason }),
414            }],
415            Some(caused_by),
416        )
417        .await
418    }
419
420    pub async fn record_observation(
421        &self,
422        actor: ActorRef,
423        observation: Observation,
424    ) -> Result<EventId, StateError> {
425        let caused_by = self
426            .record_event(Event::new(
427                actor.clone(),
428                "observation.created",
429                serde_json::to_value(&observation)?,
430            ))
431            .await?;
432        let observation_id = NodeId::new(observation.id.0);
433        let mut ops = vec![StateOp::CreateNode {
434            id: observation_id.clone(),
435            kind: crate::KIND_OBSERVATION.to_string(),
436            props: json!({
437                "title": observation.title,
438                "summary": observation.summary,
439                "metadata": observation.metadata,
440            }),
441        }];
442        if let Some(run_id) = observation.observed_in {
443            ops.push(StateOp::CreateRelation {
444                from: observation_id,
445                rel: crate::REL_OBSERVES.to_string(),
446                to: NodeId::new(run_id.0),
447                props: json!({}),
448            });
449        }
450        self.apply_ops_caused_by(actor, ops, Some(caused_by)).await
451    }
452
453    pub async fn record_hypothesis(
454        &self,
455        actor: ActorRef,
456        hypothesis: Hypothesis,
457        explains: Option<NodeId>,
458    ) -> Result<EventId, StateError> {
459        let caused_by = self
460            .record_event(Event::new(
461                actor.clone(),
462                "hypothesis.created",
463                serde_json::to_value(&hypothesis)?,
464            ))
465            .await?;
466        let hypothesis_id = NodeId::new(hypothesis.id.0);
467        let mut ops = vec![StateOp::CreateNode {
468            id: hypothesis_id.clone(),
469            kind: crate::KIND_HYPOTHESIS.to_string(),
470            props: json!({
471                "title": hypothesis.title,
472                "summary": hypothesis.summary,
473                "confidence": hypothesis.confidence,
474                "metadata": hypothesis.metadata,
475            }),
476        }];
477        if let Some(target) = explains {
478            ops.push(StateOp::CreateRelation {
479                from: hypothesis_id,
480                rel: crate::REL_EXPLAINS.to_string(),
481                to: target,
482                props: json!({}),
483            });
484        }
485        self.apply_ops_caused_by(actor, ops, Some(caused_by)).await
486    }
487
488    pub async fn record_failure(
489        &self,
490        actor: ActorRef,
491        failure_id: NodeId,
492        title: impl Into<String>,
493        summary: impl Into<String>,
494    ) -> Result<EventId, StateError> {
495        let title = title.into();
496        let summary = summary.into();
497        let caused_by = self
498            .record_event(Event::new(
499                actor.clone(),
500                "failure.observed",
501                json!({
502                    "id": failure_id,
503                    "failure_id": failure_id,
504                    "title": title,
505                    "summary": summary,
506                }),
507            ))
508            .await?;
509        self.apply_ops_caused_by(
510            actor,
511            vec![StateOp::CreateNode {
512                id: failure_id,
513                kind: "failure".to_string(),
514                props: json!({ "title": title, "summary": summary }),
515            }],
516            Some(caused_by),
517        )
518        .await
519    }
520
521    pub async fn record_eval_result(
522        &self,
523        actor: ActorRef,
524        eval_id: NodeId,
525        patch_id: PatchId,
526        command: impl Into<String>,
527        passed: bool,
528    ) -> Result<EventId, StateError> {
529        let patch_node_id = NodeId::new(patch_id.0);
530        self.apply_ops(
531            actor,
532            vec![
533                StateOp::CreateNode {
534                    id: eval_id.clone(),
535                    kind: "eval".to_string(),
536                    props: json!({ "command": command.into(), "passed": passed }),
537                },
538                StateOp::CreateRelation {
539                    from: patch_node_id,
540                    rel: "validated_by".to_string(),
541                    to: eval_id,
542                    props: json!({ "passed": passed }),
543                },
544            ],
545        )
546        .await
547    }
548
549    pub async fn record_eval(
550        &self,
551        actor: ActorRef,
552        eval: EvalResult,
553        patch_id: Option<PatchId>,
554    ) -> Result<EventId, StateError> {
555        let caused_by = self
556            .record_event(Event::new(
557                actor.clone(),
558                "eval.finished",
559                serde_json::to_value(&eval)?,
560            ))
561            .await?;
562        let eval_node_id = NodeId::new(eval.id.0);
563        let mut ops = vec![StateOp::CreateNode {
564            id: eval_node_id.clone(),
565            kind: crate::KIND_EVAL.to_string(),
566            props: json!({
567                "command": eval.command,
568                "status": eval.status,
569                "score": eval.score,
570                "metadata": eval.metadata,
571            }),
572        }];
573        if let Some(patch_id) = patch_id {
574            ops.push(StateOp::CreateRelation {
575                from: NodeId::new(patch_id.0),
576                rel: crate::REL_VALIDATED_BY.to_string(),
577                to: eval_node_id,
578                props: json!({}),
579            });
580        }
581        self.apply_ops_caused_by(actor, ops, Some(caused_by)).await
582    }
583
584    pub async fn record_decision(
585        &self,
586        actor: ActorRef,
587        decision_id: NodeId,
588        patch_id: PatchId,
589        approved: bool,
590        reason: impl Into<String>,
591    ) -> Result<EventId, StateError> {
592        let patch_node_id = NodeId::new(patch_id.0);
593        let rel = if approved {
594            "approved_by"
595        } else {
596            "rejected_by"
597        };
598
599        self.apply_ops(
600            actor,
601            vec![
602                StateOp::CreateNode {
603                    id: decision_id.clone(),
604                    kind: "decision".to_string(),
605                    props: json!({ "approved": approved, "reason": reason.into() }),
606                },
607                StateOp::CreateRelation {
608                    from: patch_node_id,
609                    rel: rel.to_string(),
610                    to: decision_id,
611                    props: json!({}),
612                },
613            ],
614        )
615        .await
616    }
617
618    pub async fn record_decision_node(
619        &self,
620        actor: ActorRef,
621        decision: Decision,
622        target: Option<NodeId>,
623    ) -> Result<EventId, StateError> {
624        let caused_by = self
625            .record_event(Event::new(
626                actor.clone(),
627                "decision.created",
628                serde_json::to_value(&decision)?,
629            ))
630            .await?;
631        let decision_node_id = NodeId::new(decision.id.0);
632        let mut ops = vec![StateOp::CreateNode {
633            id: decision_node_id.clone(),
634            kind: crate::KIND_DECISION.to_string(),
635            props: json!({
636                "status": decision.status,
637                "reason": decision.reason,
638                "decided_by": decision.decided_by,
639                "metadata": decision.metadata,
640            }),
641        }];
642        if let Some(target) = target {
643            let rel = match decision.status {
644                DecisionStatus::Approved => crate::REL_APPROVED_BY,
645                DecisionStatus::Rejected => crate::REL_REJECTED_BY,
646                _ => crate::REL_REFERENCES,
647            };
648            ops.push(StateOp::CreateRelation {
649                from: target,
650                rel: rel.to_string(),
651                to: decision_node_id,
652                props: json!({}),
653            });
654        }
655        self.apply_ops_caused_by(actor, ops, Some(caused_by)).await
656    }
657
658    pub async fn record_project_snapshot(
659        &self,
660        actor: ActorRef,
661        snapshot: ProjectSnapshot,
662    ) -> Result<EventId, StateError> {
663        let caused_by = self
664            .record_event(Event::new(
665                actor.clone(),
666                "project.snapshot_recorded",
667                serde_json::to_value(&snapshot)?,
668            ))
669            .await?;
670        let mut ops = vec![StateOp::CreateNode {
671            id: snapshot.id.clone(),
672            kind: crate::KIND_PROJECT_SNAPSHOT.to_string(),
673            props: json!({
674                "project": snapshot.project,
675                "metadata": snapshot.metadata,
676            }),
677        }];
678        for artifact in snapshot.artifacts {
679            ops.push(StateOp::AttachArtifact {
680                id: snapshot.id.clone(),
681                artifact,
682            });
683        }
684        self.apply_ops_caused_by(actor, ops, Some(caused_by)).await
685    }
686
687    pub async fn record_model_call(
688        &self,
689        actor: ActorRef,
690        call: ModelCall,
691    ) -> Result<EventId, StateError> {
692        let caused_by = self
693            .record_event(Event::new(
694                actor.clone(),
695                "model.called",
696                serde_json::to_value(&call)?,
697            ))
698            .await?;
699        self.apply_ops_caused_by(
700            actor,
701            vec![
702                StateOp::CreateNode {
703                    id: call.id.clone(),
704                    kind: crate::KIND_MODEL_CALL.to_string(),
705                    props: json!({
706                        "run_id": call.run_id,
707                        "model": call.model,
708                        "prompt_summary": call.prompt_summary,
709                        "output_summary": call.output_summary,
710                        "metadata": call.metadata,
711                    }),
712                },
713                StateOp::CreateRelation {
714                    from: call.id,
715                    rel: crate::REL_PRODUCED_BY.to_string(),
716                    to: NodeId::new(call.run_id.0),
717                    props: json!({}),
718                },
719            ],
720            Some(caused_by),
721        )
722        .await
723    }
724
725    pub async fn record_tool_call(
726        &self,
727        actor: ActorRef,
728        call: ToolCall,
729    ) -> Result<EventId, StateError> {
730        let caused_by = self
731            .record_event(Event::new(
732                actor.clone(),
733                "tool.called",
734                serde_json::to_value(&call)?,
735            ))
736            .await?;
737        self.apply_ops_caused_by(
738            actor,
739            vec![
740                StateOp::CreateNode {
741                    id: call.id.clone(),
742                    kind: crate::KIND_TOOL_CALL.to_string(),
743                    props: json!({
744                        "run_id": call.run_id,
745                        "tool": call.tool,
746                        "input_summary": call.input_summary,
747                        "output_summary": call.output_summary,
748                        "success": call.success,
749                        "metadata": call.metadata,
750                    }),
751                },
752                StateOp::CreateRelation {
753                    from: call.id,
754                    rel: crate::REL_PRODUCED_BY.to_string(),
755                    to: NodeId::new(call.run_id.0),
756                    props: json!({}),
757                },
758            ],
759            Some(caused_by),
760        )
761        .await
762    }
763
764    pub async fn record_frame(&self, actor: ActorRef, frame: Frame) -> Result<EventId, StateError> {
765        let caused_by = self
766            .record_event(Event::new(
767                actor.clone(),
768                "frame.created",
769                serde_json::to_value(&frame)?,
770            ))
771            .await?;
772        let frame_node_id = NodeId::new(frame.id.0);
773        let mut ops = vec![StateOp::CreateNode {
774            id: frame_node_id.clone(),
775            kind: crate::KIND_FRAME.to_string(),
776            props: json!({
777                "title": frame.title,
778                "summary": frame.summary,
779                "metadata": frame.metadata,
780            }),
781        }];
782        if let Some(parent) = frame.parent {
783            ops.push(StateOp::CreateRelation {
784                from: frame_node_id,
785                rel: crate::REL_CONTAINED_IN_FRAME.to_string(),
786                to: NodeId::new(parent.0),
787                props: json!({}),
788            });
789        }
790        self.apply_ops_caused_by(actor, ops, Some(caused_by)).await
791    }
792
793    pub async fn link(
794        &self,
795        actor: ActorRef,
796        from: NodeId,
797        rel: impl Into<String>,
798        to: NodeId,
799    ) -> Result<EventId, StateError> {
800        self.apply_ops(
801            actor,
802            vec![StateOp::CreateRelation {
803                from,
804                rel: rel.into(),
805                to,
806                props: json!({}),
807            }],
808        )
809        .await
810    }
811
812    pub async fn fork_at_event(
813        &self,
814        fork_id: ForkId,
815        parent_event: Option<EventId>,
816    ) -> Result<ForkSnapshot, StateError> {
817        let events = self.store.scan().await?;
818        fork_events_at(&events, fork_id, parent_event)
819    }
820
821    pub async fn diff_with(&self, other: &Graph) -> GraphDiff {
822        let current = self.graph().await;
823        diff_graphs(&current, other)
824    }
825}