kaizen-cli 0.1.35

Distributable agent observability: real-time-tailable sessions, agile-style retros, and repo-level improvement (Cursor, Claude Code, Codex). SQLite, redact before any sync you enable.
Documentation
// SPDX-License-Identifier: AGPL-3.0-or-later
//! Typed outbound JSON matching `POST /v1/events` (single-event shape used in outbox rows).

use crate::core::event::{Event, EventKind, EventSource, SessionRecord};
use blake3::Hasher;
use serde::{Deserialize, Serialize};
use std::path::Path;

const BLAKE3_PREFIX: &str = "blake3:";

/// Full batch body for `POST /v1/events`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventsBatchBody {
    pub team_id: String,
    pub workspace_hash: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_name: Option<String>,
    pub events: Vec<OutboundEvent>,
}

/// One event in the ingest API shape (after redaction).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutboundEvent {
    pub session_id_hash: String,
    pub event_seq: u64,
    pub ts_ms: u64,
    pub agent: String,
    pub model: String,
    pub kind: String,
    pub source: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_call_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tokens_in: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tokens_out: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reasoning_tokens: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cost_usd_e6: Option<i64>,
    pub payload: serde_json::Value,
}

pub fn hash_with_salt(team_salt: &[u8; 32], material: &[u8]) -> String {
    let mut h = Hasher::new();
    h.update(team_salt);
    h.update(material);
    format!("{BLAKE3_PREFIX}{}", hex::encode(h.finalize().as_bytes()))
}

pub fn workspace_hash(team_salt: &[u8; 32], workspace_abs: &Path) -> String {
    let normalized = workspace_abs.to_string_lossy();
    hash_with_salt(team_salt, normalized.as_bytes())
}

pub fn outbound_event_from_row(
    e: &Event,
    session: &SessionRecord,
    team_salt: &[u8; 32],
) -> OutboundEvent {
    OutboundEvent {
        session_id_hash: hash_with_salt(team_salt, e.session_id.as_bytes()),
        event_seq: e.seq,
        ts_ms: e.ts_ms,
        agent: session.agent.clone(),
        model: session
            .model
            .clone()
            .unwrap_or_else(|| "unknown".to_string()),
        kind: kind_api(&e.kind),
        source: source_api(&e.source),
        tool: e.tool.clone(),
        tool_call_id: e.tool_call_id.clone(),
        tokens_in: e.tokens_in,
        tokens_out: e.tokens_out,
        reasoning_tokens: e.reasoning_tokens,
        cost_usd_e6: e.cost_usd_e6,
        payload: e.payload.clone(),
    }
}

fn kind_api(k: &EventKind) -> String {
    match k {
        EventKind::ToolCall => "tool_call",
        EventKind::ToolResult => "tool_result",
        EventKind::Message => "message",
        EventKind::Error => "error",
        EventKind::Cost => "cost",
        EventKind::Hook => "hook",
        EventKind::Lifecycle => "lifecycle",
    }
    .to_string()
}

fn source_api(s: &EventSource) -> String {
    match s {
        EventSource::Tail => "tail",
        EventSource::Hook => "hook",
        EventSource::Proxy => "proxy",
    }
    .to_string()
}

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

    #[test]
    fn workspace_hash_stable_for_same_salt_and_path() {
        let salt = [7u8; 32];
        let p = Path::new("/tmp/ws");
        let a = workspace_hash(&salt, p);
        let b = workspace_hash(&salt, p);
        assert_eq!(a, b);
        assert!(a.starts_with(BLAKE3_PREFIX));
    }

    #[test]
    fn outbound_maps_kind_snake_case() {
        let salt = [0u8; 32];
        let session = SessionRecord {
            id: "sid".into(),
            agent: "cursor".into(),
            model: Some("m1".into()),
            workspace: "/w".into(),
            started_at_ms: 0,
            ended_at_ms: None,
            status: crate::core::event::SessionStatus::Running,
            trace_path: "".into(),
            start_commit: None,
            end_commit: None,
            branch: None,
            dirty_start: None,
            dirty_end: None,
            repo_binding_source: None,
            prompt_fingerprint: None,
            parent_session_id: None,
            agent_version: None,
            os: None,
            arch: None,
            repo_file_count: None,
            repo_total_loc: None,
        };
        let ev = Event {
            session_id: "sid".into(),
            seq: 3,
            ts_ms: 99,
            ts_exact: false,
            kind: EventKind::ToolCall,
            source: EventSource::Hook,
            tool: Some("Edit".into()),
            tool_call_id: Some("call_1".into()),
            tokens_in: None,
            tokens_out: None,
            reasoning_tokens: None,
            cost_usd_e6: None,
            stop_reason: None,
            latency_ms: None,
            ttft_ms: None,
            retry_count: None,
            context_used_tokens: None,
            context_max_tokens: None,
            cache_creation_tokens: None,
            cache_read_tokens: None,
            system_prompt_tokens: None,
            payload: json!({}),
        };
        let o = outbound_event_from_row(&ev, &session, &salt);
        assert_eq!(o.kind, "tool_call");
        assert_eq!(o.source, "hook");
        assert_eq!(o.event_seq, 3);
    }
}