Skip to main content

areev_run/
journal.rs

1//! The journal writer/reader (governed-agents §5.1) — the driver half of the
2//! replay contract. Uses ONLY existing vocabulary: an intent is a Tool grain
3//! stamped `Pending` before dispatch; its result **supersedes** it,
4//! re-stating `run_id`, the `mg:step_action` link, and every key field —
5//! supersession flips the intent's link rows off the current set, so a
6//! result that forgot to re-state would erase the node's execution record
7//! (the store-level trap pinned by
8//! `superseding_an_execution_record_needs_the_link_restated`).
9//!
10//! Checkpoints are State grains: `context_data` carries the serialized
11//! scheduler state + the superstep's decision record (inner State keys are
12//! never compacted, so they round-trip verbatim), chained by `derived_from`.
13//!
14//! Reads are cursor-paginated (`run_grains`) and keyed by
15//! `(task_path, node, attempt, effect_seq, kind)` — never by op-log order.
16
17use 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
26/// Journal grains carry these extra fields so the key is reconstructible
27/// from content alone (§5.1 mandatory content fields).
28const 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        // An abstract node's model turns journal under the reserved
66        // `mg:llm` name (the effect kind rides in `effect_kind`); a
67        // subgraph effect journals under `mg:subgraph` with the child plan
68        // as its spec.
69        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/// Write the intent grain (Pending) — BEFORE dispatch, always.
90#[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/// Write the result as a SUPERSESSION of the intent, re-stating identity,
111/// link and run correlation (the §5.1 blocker-fix rule).
112#[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            // Storage accounting rides the grain so replayed spend equals
143            // live spend — §6.7's "budgets are pure functions of the
144            // journal" is only true if the accounting inputs ARE journaled.
145            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/// Write a checkpoint State grain, chained by `derived_from`.
167#[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/// One journal entry: the intent and, when settled, its result.
199#[derive(Debug, Clone)]
200pub struct JournalEntry {
201    pub key: JournalKey,
202    pub intent: Hash,
203    pub result: Option<(Hash, EffectOutcome)>,
204    /// The result grain's journaled clock (`created_at`) — what a verify
205    /// replay feeds as the reading before `ResponseSettled`/late arrivals.
206    pub result_clock_ms: u64,
207    pub superstep: u64,
208}
209
210/// One loaded checkpoint.
211#[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/// The loaded journal: entries keyed §5.1-style, checkpoints ascending.
220#[derive(Debug, Default)]
221pub struct JournalView {
222    pub entries: BTreeMap<JournalKey, JournalEntry>,
223    pub checkpoints: Vec<CheckpointRow>,
224}
225
226impl JournalView {
227    /// Every dangling intent at or after `from_superstep` — Pending with no
228    /// result, Host-side only (§5.3: Client asks are never re-dispatched).
229    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
237/// Load the complete journal for a run — cursor-paginated, never the capped
238/// `run_trace` (a resume reading a truncated journal re-executes effects it
239/// can no longer see).
240pub 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    // Checkpoints: State grains stamped `checkpoint: true`.
264    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    // Journal entries: Tool grains carrying the key fields.
296    let (Some(node), Some(task_path)) = (g.get_str(F_NODE), g.get_str(F_TASK_PATH)) else {
297        return Ok(()); // A run grain that is not a journal record (manifest link etc.)
298    };
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        // The Tool grain's `content` field holds the JSON-serialized result
356        // value — and deserializes as `tool_content` (compact key `cnt`
357        // expands to that, deliberately distinct from Event's uncompacted
358        // `content`). Reading the wrong name here made every replayed
359        // result Null, which the RUN-E009 field diff caught as "context:
360        // stored has the merge, replayed doesn't".
361        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
376/// The deterministic storage-accounting figure for an outcome: the byte
377/// length of the payload the journal keeps (computed by the driver, stored
378/// on the grain, read back identically on replay).
379pub 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}