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