Skip to main content

runifold_core/
context.rs

1use std::time::Instant;
2use std::{collections::BTreeMap, sync::Arc};
3
4use serde_json::Value;
5
6use crate::{
7    BudgetReservation, BudgetTracker, CancellationToken, CapabilitySet, EventId, Journal,
8    JournalError, RunEvent, RunEventKind, RunId, RunRecorder,
9};
10use thiserror::Error;
11
12/// A reservation from another run tree cannot fund a child run.
13#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
14#[error("budget reservation does not belong to this run tree")]
15pub struct BudgetReservationMismatch;
16
17/// Namespaced runtime metadata.
18pub type Metadata = BTreeMap<String, Value>;
19
20/// The authority, lifetime, and accounting scope of one run.
21#[derive(Clone, Debug)]
22pub struct RunContext {
23    run_id: RunId,
24    parent_run_id: Option<RunId>,
25    root_run_id: RunId,
26    caused_by: Option<EventId>,
27    deadline: Option<Instant>,
28    cancellation: CancellationToken,
29    budget: BudgetTracker,
30    capabilities: CapabilitySet,
31    metadata: Metadata,
32    recorder: Option<RunRecorder>,
33}
34
35impl RunContext {
36    /// Creates a root run context.
37    pub fn root(budget: BudgetTracker, capabilities: CapabilitySet) -> Self {
38        let run_id = RunId::new();
39        Self {
40            run_id,
41            parent_run_id: None,
42            root_run_id: run_id,
43            caused_by: None,
44            deadline: None,
45            cancellation: CancellationToken::new(),
46            budget,
47            capabilities,
48            metadata: Metadata::new(),
49            recorder: None,
50        }
51    }
52
53    /// Returns this run's identity.
54    pub const fn run_id(&self) -> RunId {
55        self.run_id
56    }
57
58    /// Returns this run's parent identity.
59    pub const fn parent_run_id(&self) -> Option<RunId> {
60        self.parent_run_id
61    }
62
63    /// Returns the root run identity.
64    pub const fn root_run_id(&self) -> RunId {
65        self.root_run_id
66    }
67
68    /// Returns the event that caused this run to start, when known.
69    pub const fn caused_by(&self) -> Option<EventId> {
70        self.caused_by
71    }
72
73    /// Returns the effective deadline.
74    pub const fn deadline(&self) -> Option<Instant> {
75        self.deadline
76    }
77
78    /// Returns the hierarchical cancellation token.
79    pub const fn cancellation(&self) -> &CancellationToken {
80        &self.cancellation
81    }
82
83    /// Returns the shared run-tree budget tracker.
84    pub const fn budget(&self) -> &BudgetTracker {
85        &self.budget
86    }
87
88    /// Returns capabilities explicitly granted to this run.
89    pub const fn capabilities(&self) -> &CapabilitySet {
90        &self.capabilities
91    }
92
93    /// Returns runtime metadata.
94    pub const fn metadata(&self) -> &Metadata {
95        &self.metadata
96    }
97
98    /// Mutably returns runtime metadata.
99    pub const fn metadata_mut(&mut self) -> &mut Metadata {
100        &mut self.metadata
101    }
102
103    /// Returns the configured event recorder, when observability is enabled.
104    pub const fn recorder(&self) -> Option<&RunRecorder> {
105        self.recorder.as_ref()
106    }
107
108    /// Enables structured event recording for this run and future children.
109    #[must_use]
110    pub fn with_journal(mut self, journal: Arc<dyn Journal>) -> Self {
111        self.recorder = Some(RunRecorder::new(journal, self.run_id, self.parent_run_id));
112        self
113    }
114
115    /// Records one event when observability is enabled.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`JournalError`] when the configured journal rejects the event.
120    pub fn record(
121        &self,
122        kind: RunEventKind,
123        caused_by: Option<EventId>,
124    ) -> Result<Option<RunEvent>, JournalError> {
125        self.recorder
126            .as_ref()
127            .map(|recorder| recorder.record(kind, caused_by))
128            .transpose()
129    }
130
131    /// Sets a deadline, clamped to any existing earlier deadline.
132    #[must_use]
133    pub fn with_deadline(mut self, deadline: Instant) -> Self {
134        self.deadline = Some(
135            self.deadline
136                .map_or(deadline, |current| current.min(deadline)),
137        );
138        self
139    }
140
141    /// Sets the event that caused this run to start.
142    #[must_use]
143    pub const fn with_cause(mut self, event_id: EventId) -> Self {
144        self.caused_by = Some(event_id);
145        self
146    }
147
148    /// Creates a child with an explicit capability set.
149    ///
150    /// The child shares the root budget tracker, receives a descendant
151    /// cancellation token, and does not inherit metadata or capabilities.
152    #[must_use]
153    pub fn child(&self, capabilities: CapabilitySet) -> Self {
154        self.child_with_budget(capabilities, self.budget.clone())
155    }
156
157    /// Creates a child funded by one scoped reservation from this run tree.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`BudgetReservationMismatch`] when the reservation was created
162    /// by another run tree.
163    pub fn child_reserved(
164        &self,
165        capabilities: CapabilitySet,
166        reservation: &BudgetReservation,
167    ) -> Result<Self, BudgetReservationMismatch> {
168        if !reservation.belongs_to(&self.budget) {
169            return Err(BudgetReservationMismatch);
170        }
171        Ok(self.child_with_budget(capabilities, reservation.tracker()))
172    }
173
174    fn child_with_budget(&self, capabilities: CapabilitySet, budget: BudgetTracker) -> Self {
175        let run_id = RunId::new();
176        Self {
177            run_id,
178            parent_run_id: Some(self.run_id),
179            root_run_id: self.root_run_id,
180            caused_by: None,
181            deadline: self.deadline,
182            cancellation: self.cancellation.child_token(),
183            budget,
184            capabilities,
185            metadata: Metadata::new(),
186            recorder: self
187                .recorder
188                .as_ref()
189                .map(|recorder| recorder.child(run_id, self.run_id)),
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use std::sync::Arc;
197
198    use super::{BudgetReservationMismatch, RunContext};
199    use crate::{
200        Budget, BudgetTracker, CapabilityDescriptor, CapabilityId, CapabilityKind, CapabilitySet,
201        EffectClass, InMemoryJournal, LifecycleEvent, RiskLevel, RunEventKind, Usage,
202    };
203
204    #[test]
205    fn child_preserves_lineage_but_not_ambient_authority() {
206        let mut parent_capabilities = CapabilitySet::new();
207        parent_capabilities.grant(CapabilityDescriptor {
208            id: CapabilityId::new(),
209            name: "write-file".into(),
210            version: "1".into(),
211            kind: CapabilityKind::Tool,
212            input_schema: serde_json::json!({}),
213            output_schema: serde_json::json!({}),
214            effect: EffectClass::NonIdempotentWrite,
215            risk: RiskLevel::High,
216            metadata: std::collections::BTreeMap::default(),
217        });
218
219        let parent = RunContext::root(
220            BudgetTracker::new(Budget {
221                tokens: Some(10),
222                ..Budget::default()
223            }),
224            parent_capabilities,
225        );
226        let child = parent.child(CapabilitySet::new());
227
228        assert_eq!(child.parent_run_id(), Some(parent.run_id()));
229        assert_eq!(child.root_run_id(), parent.root_run_id());
230        assert!(child.capabilities().is_empty());
231
232        child
233            .budget()
234            .try_consume(Usage {
235                tokens: 4,
236                ..Usage::default()
237            })
238            .unwrap();
239        assert_eq!(parent.budget().usage().tokens, 4);
240
241        parent.cancellation().cancel();
242        assert!(child.cancellation().is_cancelled());
243    }
244
245    #[test]
246    fn child_recorders_share_a_journal_but_keep_per_run_sequences() {
247        let journal = InMemoryJournal::new();
248        let parent = RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new())
249            .with_journal(Arc::new(journal.clone()));
250        let child = parent.child(CapabilitySet::new());
251
252        parent
253            .record(RunEventKind::Lifecycle(LifecycleEvent::Started), None)
254            .unwrap();
255        child
256            .record(RunEventKind::Lifecycle(LifecycleEvent::Started), None)
257            .unwrap();
258        parent
259            .record(
260                RunEventKind::Lifecycle(LifecycleEvent::Completed {
261                    output: serde_json::json!({}),
262                }),
263                None,
264            )
265            .unwrap();
266
267        let events = journal.events();
268        assert_eq!(events.len(), 3);
269        assert_eq!(events[0].meta.sequence, 0);
270        assert_eq!(events[1].meta.sequence, 0);
271        assert_eq!(events[2].meta.sequence, 1);
272        assert_eq!(events[1].meta.parent_run_id, Some(parent.run_id()));
273    }
274
275    #[test]
276    fn child_rejects_a_reservation_from_another_run_tree() {
277        let first = RunContext::root(
278            BudgetTracker::new(Budget {
279                turns: Some(1),
280                ..Budget::default()
281            }),
282            CapabilitySet::new(),
283        );
284        let second = RunContext::root(
285            BudgetTracker::new(Budget {
286                turns: Some(1),
287                ..Budget::default()
288            }),
289            CapabilitySet::new(),
290        );
291        let reservation = first
292            .budget()
293            .try_reserve(Usage {
294                turns: 1,
295                ..Usage::default()
296            })
297            .unwrap();
298
299        let error = second
300            .child_reserved(CapabilitySet::new(), &reservation)
301            .unwrap_err();
302
303        assert_eq!(error, BudgetReservationMismatch);
304    }
305}