ostraka-core 1.5.0

Domain types for Ostraka: tasks, gates, verdicts and run records.
Documentation
//! The run record: what happened, in order, replayable afterwards.
//!
//! An action that leaves no trace did not happen. Every event an adapter emits
//! is normalized into the shapes below and appended to the run's log, with the
//! vendor's own payload preserved verbatim alongside it.

use crate::gate::{Approval, CheckRecord};
use crate::identity::ActorId;
use serde::{Deserialize, Serialize};

/// A normalized event from a running agent.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Event {
    Message {
        text: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        raw: Option<serde_json::Value>,
    },
    ToolUse {
        name: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        raw: Option<serde_json::Value>,
    },
    Error {
        message: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        raw: Option<serde_json::Value>,
    },
    Finished {
        exit_code: Option<i32>,
        files_touched: Vec<String>,
    },
}

/// How a run ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
    /// Checks passed and an independent reviewer approved.
    Approved,
    /// A required check failed, or the reviewer rejected.
    Rejected,
    /// The agent or the runtime failed before a verdict existed.
    Failed,
}

impl Outcome {
    /// The word for this outcome, everywhere a person reads one.
    ///
    /// The same spelling the record serializes to, and a test holds the two
    /// together. `ostraka runs` used to print `refused` for what the JSON,
    /// `replay` and `bench` all called `rejected` — and "refused" reads as a run
    /// that was turned away before it started, which is not what happened to a
    /// run whose checks failed or whose reviewer said no. One function is how a
    /// label stops having more than one spelling.
    pub fn as_str(self) -> &'static str {
        match self {
            Outcome::Approved => "approved",
            Outcome::Rejected => "rejected",
            Outcome::Failed => "failed",
        }
    }
}

impl std::fmt::Display for Outcome {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// What one adapter invocation cost, as the vendor itself reported it.
///
/// Not measured here and not estimated: every field is absent unless the vendor
/// said the number. Vendors disagree about what they report — a single total, a
/// split, or a rounded figure like `7.5k` — so the shape is optional throughout
/// and `approximate` marks the ones that were rounded before we saw them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
    pub adapter: String,
    pub role: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub input: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub total: Option<u64>,
    /// The vendor rounded before reporting. Summing these gives an estimate.
    #[serde(default)]
    pub approximate: bool,
}

impl TokenUsage {
    /// Everything the vendor accounted for, however it chose to split it.
    pub fn counted(&self) -> u64 {
        self.total
            .unwrap_or_else(|| self.input.unwrap_or(0) + self.output.unwrap_or(0))
    }
}

/// The durable record of one task, start to finish.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
    pub run_id: String,
    pub task_id: String,
    /// What was asked. Defaulted so records written before this field existed
    /// still read back — a run id is opaque, and a list of runs nobody can
    /// identify is a list nobody uses.
    #[serde(default)]
    pub prompt: String,
    pub author: ActorId,
    pub adapter: String,
    /// Which repository the change was made in.
    ///
    /// Defaulted, because a run recorded before a workspace could hold more
    /// than one was made in the only one there was, and a record that will not
    /// read back is a record that is gone.
    #[serde(default)]
    pub repository: String,
    pub started_at: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub finished_at: Option<String>,
    #[serde(default)]
    pub checks: Vec<CheckRecord>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval: Option<Approval>,
    /// What each adapter invocation cost. Empty for runs recorded before this
    /// existed, and for vendors that report nothing.
    #[serde(default)]
    pub usage: Vec<TokenUsage>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outcome: Option<Outcome>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_outcome_reads_the_way_it_serializes() {
        // The label a person sees and the value a script parses are one word.
        // If they ever differ, `ostraka runs` and `ostraka runs --json`
        // describe the same run in two vocabularies again.
        for outcome in [Outcome::Approved, Outcome::Rejected, Outcome::Failed] {
            let json = serde_json::to_string(&outcome).expect("serializes");
            assert_eq!(json, format!("\"{}\"", outcome.as_str()), "{outcome:?}");
            assert_eq!(outcome.to_string(), outcome.as_str());
        }
    }

    #[test]
    fn events_round_trip_through_jsonl() {
        let event = Event::Finished {
            exit_code: Some(0),
            files_touched: vec!["src/lib.rs".to_string()],
        };
        let line = serde_json::to_string(&event).expect("serializes");
        let back: Event = serde_json::from_str(&line).expect("deserializes");
        match back {
            Event::Finished { exit_code, .. } => assert_eq!(exit_code, Some(0)),
            other => panic!("wrong variant: {other:?}"),
        }
    }
}