1use areev_core::error::{AreevError, Hash, Result};
18use areev_core::types::{
19 ExecutionStatus, ExecutorKind, FailureCause, Grain, State, Tool,
20};
21use areev_run_core::{DecisionRecord, EffectKind, EffectOutcome, FailCause, JournalKey, NodeExecutor};
22use areev_store::Areev;
23use serde_json::{json, Value};
24use std::collections::BTreeMap;
25
26const F_TASK_PATH: &str = "task_path";
29const F_NODE: &str = "node";
30const F_ATTEMPT: &str = "attempt";
31const F_EFFECT_SEQ: &str = "effect_seq";
32const F_SUPERSTEP: &str = "superstep";
33const F_EFFECT_KIND: &str = "effect_kind";
34const F_USAGE_IN: &str = "usage_input_tokens";
35const F_USAGE_OUT: &str = "usage_output_tokens";
36const F_USD_MICROS: &str = "usage_usd_micros";
37const F_JOURNAL_BYTES: &str = "usage_journal_bytes";
38
39fn key_extras(t: &mut Tool, key: &JournalKey, superstep: u64, run_id: &str) {
40 let ex = &mut t.common.extra_fields;
41 ex.insert("run_id".into(), json!(run_id));
42 ex.insert(F_TASK_PATH.into(), json!(key.task_path));
43 ex.insert(F_NODE.into(), json!(key.node));
44 ex.insert(F_ATTEMPT.into(), json!(key.attempt));
45 ex.insert(F_EFFECT_SEQ.into(), json!(key.effect_seq));
46 ex.insert(F_SUPERSTEP.into(), json!(superstep));
47 ex.insert(F_EFFECT_KIND.into(), json!(key.kind.as_str()));
48}
49
50fn base_tool(
51 key: &JournalKey,
52 executor: &NodeExecutor,
53 ns: &str,
54 plan_hash: &Hash,
55 clock_ms: u64,
56 principal: &str,
57) -> Tool {
58 let (tool_hash, tool_name, ekind) = match executor {
59 NodeExecutor::Host { tool_hash, tool_name } => {
60 (tool_hash.clone(), tool_name.clone(), ExecutorKind::Host)
61 }
62 NodeExecutor::Client { tool_hash, tool_name } => {
63 (tool_hash.clone(), tool_name.clone(), ExecutorKind::Client)
64 }
65 NodeExecutor::Abstract { .. } => {
70 (String::new(), "mg:llm".to_string(), ExecutorKind::Host)
71 }
72 NodeExecutor::Subgraph { workflow_hash } => {
73 (workflow_hash.clone(), "mg:subgraph".to_string(), ExecutorKind::Host)
74 }
75 };
76 let mut t = Tool::new(&tool_name)
77 .tool_call_id(&key.tool_call_id())
78 .executor_kind(ekind)
79 .created_at(clock_ms as i64)
80 .namespace(ns)
81 .step_action(&plan_hash.to_hex(), &key.node);
82 if !tool_hash.is_empty() {
83 t = t.spec_hash(&tool_hash);
84 }
85 t.common.author_did = Some(principal.to_string());
86 t
87}
88
89#[allow(clippy::too_many_arguments)]
91pub fn write_intent(
92 m: &mut Areev,
93 ns: &str,
94 run_id: &str,
95 plan_hash: &Hash,
96 key: &JournalKey,
97 executor: &NodeExecutor,
98 input: &Value,
99 superstep: u64,
100 clock_ms: u64,
101 principal: &str,
102) -> Result<Hash> {
103 let mut t = base_tool(key, executor, ns, plan_hash, clock_ms, principal);
104 t.status = Some(ExecutionStatus::Pending);
105 t.input = Some(input.clone());
106 key_extras(&mut t, key, superstep, run_id);
107 m.add(&t)
108}
109
110#[allow(clippy::too_many_arguments)]
113pub fn write_result(
114 m: &mut Areev,
115 ns: &str,
116 run_id: &str,
117 plan_hash: &Hash,
118 intent: &Hash,
119 key: &JournalKey,
120 executor: &NodeExecutor,
121 outcome: &EffectOutcome,
122 superstep: u64,
123 clock_ms: u64,
124 principal: &str,
125) -> Result<Hash> {
126 let mut t = base_tool(key, executor, ns, plan_hash, clock_ms, principal);
127 key_extras(&mut t, key, superstep, run_id);
128 match outcome {
129 EffectOutcome::Completed {
130 result,
131 input_tokens,
132 output_tokens,
133 usd_micros,
134 journal_bytes,
135 } => {
136 t.status = Some(ExecutionStatus::Completed);
137 t.content = Some(result.to_string());
138 let ex = &mut t.common.extra_fields;
139 ex.insert(F_USAGE_IN.into(), json!(input_tokens));
140 ex.insert(F_USAGE_OUT.into(), json!(output_tokens));
141 ex.insert(F_USD_MICROS.into(), json!(usd_micros));
142 ex.insert(F_JOURNAL_BYTES.into(), json!(journal_bytes));
146 }
147 EffectOutcome::Failed { cause, detail, .. } => {
148 t.status = Some(ExecutionStatus::Failed);
149 t.is_error = Some(true);
150 t.common
151 .extra_fields
152 .insert(F_JOURNAL_BYTES.into(), json!(outcome_journal_bytes(outcome)));
153 t.failure_cause = Some(match cause {
154 FailCause::Timeout => FailureCause::Timeout,
155 FailCause::ExecutorError => FailureCause::ExecutorError,
156 FailCause::SchemaValidationFailed => FailureCause::SchemaValidationFailed,
157 FailCause::UserAborted => FailureCause::UserAborted,
158 FailCause::Unknown => FailureCause::Unknown,
159 });
160 t.failure_detail = Some(detail.clone());
161 }
162 }
163 m.supersede(intent, &mut t)
164}
165
166#[allow(clippy::too_many_arguments)]
168pub fn write_checkpoint(
169 m: &mut Areev,
170 ns: &str,
171 run_id: &str,
172 scheduler_state_json: &Value,
173 record: &DecisionRecord,
174 clock_ms: u64,
175 prev: Option<&Hash>,
176 principal: &str,
177) -> Result<Hash> {
178 let mut st = State::new(json!({
179 "scheduler": scheduler_state_json,
180 "decisions": record,
181 }))
182 .created_at(clock_ms as i64)
183 .namespace(ns);
184 st.common.author_did = Some(principal.to_string());
185 st.common.extra_fields.insert("run_id".into(), json!(run_id));
186 st.common
187 .extra_fields
188 .insert(F_SUPERSTEP.into(), json!(record.superstep));
189 st.common
190 .extra_fields
191 .insert("checkpoint".into(), json!(true));
192 if let Some(p) = prev {
193 st.common.derived_from = Some(p.to_hex());
194 }
195 m.add(&st)
196}
197
198#[derive(Debug, Clone)]
200pub struct JournalEntry {
201 pub key: JournalKey,
202 pub intent: Hash,
203 pub result: Option<(Hash, EffectOutcome)>,
204 pub result_clock_ms: u64,
207 pub superstep: u64,
208}
209
210#[derive(Debug, Clone)]
212pub struct CheckpointRow {
213 pub superstep: u64,
214 pub hash: Hash,
215 pub scheduler: Value,
216 pub decisions: DecisionRecord,
217}
218
219#[derive(Debug, Default)]
221pub struct JournalView {
222 pub entries: BTreeMap<JournalKey, JournalEntry>,
223 pub checkpoints: Vec<CheckpointRow>,
224}
225
226impl JournalView {
227 pub fn dangling(&self, from_superstep: u64) -> Vec<&JournalEntry> {
230 self.entries
231 .values()
232 .filter(|e| e.result.is_none() && e.superstep >= from_superstep)
233 .collect()
234 }
235}
236
237pub fn load(m: &mut Areev, ns: &str, run_id: &str) -> Result<JournalView> {
241 let mut view = JournalView::default();
242 let mut cursor = 0i64;
243 loop {
244 let page = m.run_grains(ns, run_id, cursor, 512)?;
245 let exhausted = page.len() < 512;
246 for (seq, g) in page {
247 cursor = seq;
248 ingest(&mut view, run_id, &g)?;
249 }
250 if exhausted {
251 break;
252 }
253 }
254 view.checkpoints.sort_by_key(|c| c.superstep);
255 Ok(view)
256}
257
258fn ingest(
259 view: &mut JournalView,
260 run_id: &str,
261 g: &areev_core::format::deserialize::DeserializedGrain,
262) -> Result<()> {
263 if g.get_bool("checkpoint") == Some(true) {
265 let ss = g.get_u64(F_SUPERSTEP).unwrap_or(0);
266 let ctx = g
267 .fields
268 .get("context")
269 .or_else(|| g.fields.get("context_data"))
270 .ok_or_else(|| {
271 AreevError::Validation(format!(
272 "checkpoint {} carries no context",
273 g.hash.to_hex()
274 ))
275 })?;
276 let sched = ctx.get("scheduler").cloned().ok_or_else(|| {
277 AreevError::Validation(format!(
278 "checkpoint {} carries no scheduler state",
279 g.hash.to_hex()
280 ))
281 })?;
282 let decisions: DecisionRecord = ctx
283 .get("decisions")
284 .cloned()
285 .and_then(|d| serde_json::from_value(d).ok())
286 .unwrap_or_default();
287 view.checkpoints.push(CheckpointRow {
288 superstep: ss,
289 hash: g.hash,
290 scheduler: sched,
291 decisions,
292 });
293 return Ok(());
294 }
295 let (Some(node), Some(task_path)) = (g.get_str(F_NODE), g.get_str(F_TASK_PATH)) else {
297 return Ok(()); };
299 let kind = match g.get_str(F_EFFECT_KIND) {
300 Some("llm") => EffectKind::Llm,
301 _ => EffectKind::Tool,
302 };
303 let key = JournalKey {
304 run_id: run_id.to_string(),
305 task_path: task_path.to_string(),
306 node: node.to_string(),
307 attempt: g.get_u64(F_ATTEMPT).unwrap_or(0) as u32,
308 effect_seq: g.get_u64(F_EFFECT_SEQ).unwrap_or(0) as u32,
309 kind,
310 };
311 let superstep = g.get_u64(F_SUPERSTEP).unwrap_or(0);
312 let status = g.get_str("status").unwrap_or("completed");
313 let entry = view.entries.entry(key.clone()).or_insert(JournalEntry {
314 key,
315 intent: g.hash,
316 result: None,
317 result_clock_ms: 0,
318 superstep,
319 });
320 match status {
321 "pending" => {
322 entry.intent = g.hash;
323 entry.superstep = superstep;
324 }
325 _ => {
326 entry.result = Some((g.hash, outcome_from_grain(g, status)));
327 entry.result_clock_ms = g.get_i64("created_at").unwrap_or(0).max(0) as u64;
328 }
329 }
330 Ok(())
331}
332
333fn outcome_from_grain(
334 g: &areev_core::format::deserialize::DeserializedGrain,
335 status: &str,
336) -> EffectOutcome {
337 if status == "failed" {
338 let cause = match g.get_str("failure_cause") {
339 Some("timeout") => FailCause::Timeout,
340 Some("executor_error") => FailCause::ExecutorError,
341 Some("schema_validation_failed") => FailCause::SchemaValidationFailed,
342 Some("user_aborted") => FailCause::UserAborted,
343 _ => FailCause::Unknown,
344 };
345 EffectOutcome::Failed {
346 cause,
347 detail: g
348 .get_str("failure_detail")
349 .or_else(|| g.get_str("error"))
350 .unwrap_or("")
351 .to_string(),
352 journal_bytes: g.get_u64(F_JOURNAL_BYTES).unwrap_or(0),
353 }
354 } else {
355 let result = g
362 .get_str("tool_content")
363 .or_else(|| g.get_str("content"))
364 .and_then(|c| serde_json::from_str(c).ok())
365 .unwrap_or(Value::Null);
366 EffectOutcome::Completed {
367 result,
368 journal_bytes: g.get_u64(F_JOURNAL_BYTES).unwrap_or(0),
369 input_tokens: g.get_u64(F_USAGE_IN).unwrap_or(0),
370 output_tokens: g.get_u64(F_USAGE_OUT).unwrap_or(0),
371 usd_micros: g.get_u64(F_USD_MICROS).unwrap_or(0),
372 }
373 }
374}
375
376pub fn outcome_journal_bytes(outcome: &EffectOutcome) -> u64 {
380 match outcome {
381 EffectOutcome::Completed { result, .. } => result.to_string().len() as u64,
382 EffectOutcome::Failed { detail, .. } => detail.len() as u64,
383 }
384}