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