use serde_json::Value;
use super::{Record, RecordKind};
use crate::core::{EffectDescriptor, EffectKey, Phase, Seq, StepId};
#[derive(Debug, Clone, Copy)]
pub struct Call<'a> {
started: &'a Record,
outcome: Option<&'a Record>,
}
impl<'a> Call<'a> {
#[must_use]
pub fn key(&self) -> EffectKey {
self.started
.effect_key()
.expect("a call is built only from a start that carries a key")
}
#[must_use]
pub fn step(&self) -> Option<StepId> {
self.started.body.step
}
#[must_use]
pub fn phase(&self) -> Phase {
self.started.body.phase
}
#[must_use]
pub fn seq(&self) -> Seq {
self.started.seq()
}
#[must_use]
pub fn descriptor(&self) -> &'a EffectDescriptor {
match self.started.kind() {
RecordKind::EffectStarted { descriptor, .. } => descriptor,
_ => unreachable!("a call is built only from an EffectStarted record"),
}
}
#[must_use]
pub const fn started(&self) -> &'a Record {
self.started
}
#[must_use]
pub const fn outcome(&self) -> Option<&'a Record> {
self.outcome
}
#[must_use]
pub fn output(&self) -> Option<&'a Value> {
match self.outcome?.kind() {
RecordKind::EffectDone { output, .. } => Some(output),
RecordKind::EffectReconciled { output, .. } => output.as_ref(),
_ => None,
}
}
#[must_use]
pub fn tool(&self) -> Option<crate::tools::ToolId> {
crate::tools::ToolId::called(self.descriptor())
}
}
#[must_use]
pub fn calls(records: &[Record]) -> Vec<Call<'_>> {
let mut found: Vec<Call<'_>> = Vec::new();
let mut at: std::collections::HashMap<EffectKey, usize> = std::collections::HashMap::new();
for record in records {
let Some(key) = record.effect_key() else {
continue;
};
match record.kind() {
RecordKind::EffectStarted { .. } => {
at.entry(key).or_insert_with(|| {
found.push(Call {
started: record,
outcome: None,
});
found.len() - 1
});
}
RecordKind::EffectDone { .. }
| RecordKind::EffectFailed { .. }
| RecordKind::EffectReconciled { .. } => {
if let Some(&i) = at.get(&key) {
found[i].outcome = Some(record);
}
}
_ => {}
}
}
found
}
#[cfg(test)]
mod tests {
use super::calls;
use crate::core::{Digest, EffectDescriptor, EffectKey, Recovery, RunId, Spend, StepId};
use crate::journal::{Append, Record, RecordKind};
use serde_json::json;
fn key(n: u8) -> EffectKey {
EffectKey::from_hex(&Digest::of(&[n]).to_hex()).unwrap()
}
fn history(entries: Vec<(EffectKey, RecordKind)>) -> Vec<Record> {
let run = RunId::generate();
let mut prev = Digest::ZERO;
let mut out = Vec::new();
for (i, (k, kind)) in entries.into_iter().enumerate() {
let a = Append::new(run, kind).step(StepId(0)).effect(k);
let r = Record::seal(a.into_body(i as u64 + 1, 1), prev).unwrap();
prev = r.hash;
out.push(r);
}
out
}
fn tool_call(tool: &str) -> RecordKind {
RecordKind::EffectStarted {
descriptor: EffectDescriptor::new(
"tool.call",
json!({"server": "crm", "tool": tool, "arguments": {}}),
),
recovery: Recovery::Retry,
mutates: false,
attempt: 1,
backoff_ms: 0,
outbound_label: None,
outbound_bytes: None,
content_rules: None,
credential: None,
}
}
fn done(output: serde_json::Value) -> RecordKind {
RecordKind::EffectDone {
declared: crate::core::DeclaredOutput::untrusted(),
output,
source: None,
by: None,
spend: Spend::default(),
content: None,
elapsed_ms: None,
}
}
#[test]
fn two_calls_to_one_tool_stay_two_calls() {
let records = history(vec![
(key(1), tool_call("lookup")),
(key(2), tool_call("lookup")),
(key(1), done(json!({"customer": "A"}))),
(key(2), done(json!({"customer": "B"}))),
]);
let found = calls(&records);
assert_eq!(found.len(), 2, "two calls collapsed into {}", found.len());
assert_eq!(found[0].output(), Some(&json!({"customer": "A"})));
assert_eq!(found[1].output(), Some(&json!({"customer": "B"})));
let tool = found[0].tool().expect("a tool call names its tool");
assert_eq!(
(tool.server.as_str(), tool.tool.as_str()),
("crm", "lookup")
);
assert_eq!(found[0].key(), key(1));
assert_eq!(found[1].seq(), 2);
}
#[test]
fn an_unsettled_call_is_listed_without_an_outcome() {
let records = history(vec![
(key(1), tool_call("send")),
(key(2), tool_call("lookup")),
(key(2), done(json!(1))),
]);
let found = calls(&records);
assert_eq!(found.len(), 2, "the unsettled call was dropped");
assert!(found[0].outcome().is_none());
assert!(found[0].output().is_none());
assert_eq!(found[1].output(), Some(&json!(1)));
}
#[test]
fn a_retry_and_a_compensation_are_calls_of_their_own() {
use crate::core::Phase;
let run = RunId::generate();
let mut prev = Digest::ZERO;
let mut records = Vec::new();
for (i, (k, phase, kind)) in [
(key(1), Phase::Forward, tool_call("send")),
(key(2), Phase::Forward, tool_call("send")),
(key(2), Phase::Forward, done(json!("sent"))),
(key(3), Phase::Compensating, tool_call("recall")),
(key(3), Phase::Compensating, done(json!("recalled"))),
]
.into_iter()
.enumerate()
{
let a = Append::new(run, kind)
.step(StepId(0))
.phase(phase)
.effect(k);
let r = Record::seal(a.into_body(i as u64 + 1, 1), prev).unwrap();
prev = r.hash;
records.push(r);
}
let found = calls(&records);
assert_eq!(found.len(), 3);
assert!(
found[0].outcome().is_none(),
"the first attempt never settled"
);
assert_eq!(found[1].output(), Some(&json!("sent")));
assert_eq!(found[2].phase(), Phase::Compensating);
assert_eq!(found[2].output(), Some(&json!("recalled")));
}
#[test]
fn only_a_tool_call_names_a_tool() {
let mut kind = tool_call("x");
if let RecordKind::EffectStarted { descriptor, .. } = &mut kind {
descriptor.kind = "model.complete".into();
}
let records = history(vec![(key(1), kind)]);
assert!(calls(&records)[0].tool().is_none());
}
}