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#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
14#[error("budget reservation does not belong to this run tree")]
15pub struct BudgetReservationMismatch;
16
17pub type Metadata = BTreeMap<String, Value>;
19
20#[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 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 pub const fn run_id(&self) -> RunId {
55 self.run_id
56 }
57
58 pub const fn parent_run_id(&self) -> Option<RunId> {
60 self.parent_run_id
61 }
62
63 pub const fn root_run_id(&self) -> RunId {
65 self.root_run_id
66 }
67
68 pub const fn caused_by(&self) -> Option<EventId> {
70 self.caused_by
71 }
72
73 pub const fn deadline(&self) -> Option<Instant> {
75 self.deadline
76 }
77
78 pub const fn cancellation(&self) -> &CancellationToken {
80 &self.cancellation
81 }
82
83 pub const fn budget(&self) -> &BudgetTracker {
85 &self.budget
86 }
87
88 pub const fn capabilities(&self) -> &CapabilitySet {
90 &self.capabilities
91 }
92
93 pub const fn metadata(&self) -> &Metadata {
95 &self.metadata
96 }
97
98 pub const fn metadata_mut(&mut self) -> &mut Metadata {
100 &mut self.metadata
101 }
102
103 pub const fn recorder(&self) -> Option<&RunRecorder> {
105 self.recorder.as_ref()
106 }
107
108 #[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 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 #[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 #[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 #[must_use]
153 pub fn child(&self, capabilities: CapabilitySet) -> Self {
154 self.child_with_budget(capabilities, self.budget.clone())
155 }
156
157 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}