swarm-engine-core 0.1.6

Core types and orchestration for SwarmEngine
Documentation
//! Trace Subscriber - ActionEvent のトレース出力
//!
//! ActionEvent を受け取って任意の出力先に書き出す。
//! Eval/UI 側で出力方法を選択できる。
//!
//! # 使用例
//!
//! ```ignore
//! use swarm_engine_core::events::{InMemoryTraceSubscriber, JsonlTraceSubscriber};
//!
//! // InMemory: 最後にまとめて出力
//! let trace = InMemoryTraceSubscriber::new();
//! // ... 実行後 ...
//! trace.dump_to_file("trace.jsonl")?;
//!
//! // Jsonl: リアルタイム出力
//! let trace = JsonlTraceSubscriber::new("trace.jsonl")?;
//! ```

use std::io::Write;
use std::path::Path;
use std::sync::{Arc, Mutex};

use super::action::ActionEvent;

/// ActionEvent をトレースするための trait
///
/// Eval/UI 側で出力方法を選択できる。
pub trait TraceSubscriber: Send + Sync {
    /// イベントを受信
    fn on_event(&self, event: &ActionEvent);

    /// 終了処理(フラッシュなど)
    fn finish(&self) {}
}

// ============================================================================
// NoOpTraceSubscriber - 何もしない
// ============================================================================

/// 何もしない TraceSubscriber
///
/// トレースが不要な場合に使用。
#[derive(Debug, Default)]
pub struct NoOpTraceSubscriber;

impl NoOpTraceSubscriber {
    pub fn new() -> Self {
        Self
    }
}

impl TraceSubscriber for NoOpTraceSubscriber {
    fn on_event(&self, _event: &ActionEvent) {
        // 何もしない
    }
}

// ============================================================================
// InMemoryTraceSubscriber - メモリに蓄積
// ============================================================================

/// メモリに蓄積する TraceSubscriber
///
/// 実行後にまとめてファイルに出力できる。
#[derive(Debug, Default)]
pub struct InMemoryTraceSubscriber {
    events: Mutex<Vec<TraceEvent>>,
}

impl InMemoryTraceSubscriber {
    pub fn new() -> Self {
        Self {
            events: Mutex::new(Vec::new()),
        }
    }

    /// 蓄積されたイベント数を取得
    pub fn len(&self) -> usize {
        self.events.lock().unwrap().len()
    }

    /// 空かどうか
    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// イベントをクリア
    pub fn clear(&self) {
        self.events.lock().unwrap().clear();
    }

    /// JSONL ファイルに出力
    pub fn dump_to_file(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
        let events = self.events.lock().unwrap();
        let file = std::fs::File::create(path)?;
        let mut writer = std::io::BufWriter::new(file);

        for event in events.iter() {
            let json = serde_json::to_string(event)
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
            writeln!(writer, "{}", json)?;
        }

        writer.flush()?;
        Ok(())
    }

    /// イベントを Vec として取得
    pub fn events(&self) -> Vec<TraceEvent> {
        self.events.lock().unwrap().clone()
    }
}

impl TraceSubscriber for InMemoryTraceSubscriber {
    fn on_event(&self, event: &ActionEvent) {
        let trace_event = TraceEvent::from(event);
        self.events.lock().unwrap().push(trace_event);
    }
}

// ============================================================================
// JsonlTraceSubscriber - リアルタイム JSONL 出力
// ============================================================================

/// JSONL ファイルにリアルタイム出力する TraceSubscriber
pub struct JsonlTraceSubscriber {
    writer: Mutex<std::io::BufWriter<std::fs::File>>,
}

impl JsonlTraceSubscriber {
    /// 新規作成
    pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
        let file = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(path)?;
        let writer = std::io::BufWriter::new(file);
        Ok(Self {
            writer: Mutex::new(writer),
        })
    }
}

impl TraceSubscriber for JsonlTraceSubscriber {
    fn on_event(&self, event: &ActionEvent) {
        let trace_event = TraceEvent::from(event);
        if let Ok(json) = serde_json::to_string(&trace_event) {
            if let Ok(mut writer) = self.writer.lock() {
                let _ = writeln!(writer, "{}", json);
            }
        }
    }

    fn finish(&self) {
        if let Ok(mut writer) = self.writer.lock() {
            let _ = writer.flush();
        }
    }
}

// ============================================================================
// TraceEvent - シリアライズ用構造体
// ============================================================================

/// トレース用イベント(シリアライズ用)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TraceEvent {
    /// Tick
    pub tick: u64,
    /// Worker ID
    pub worker_id: usize,
    /// アクション名
    pub action: String,
    /// ターゲット
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,
    /// 成功/失敗
    pub success: bool,
    /// エラーメッセージ
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// 出力
    #[serde(skip_serializing_if = "Option::is_none")]
    pub output: Option<String>,
    /// 実行時間(ms)
    pub duration_ms: u64,
    /// 選択ロジック
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selection_logic: Option<String>,
    /// 前回のアクション
    #[serde(skip_serializing_if = "Option::is_none")]
    pub previous_action: Option<String>,
    /// Guidance からの指示か
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub from_guidance: bool,
}

impl From<&ActionEvent> for TraceEvent {
    fn from(e: &ActionEvent) -> Self {
        Self {
            tick: e.tick,
            worker_id: e.worker_id.0,
            action: e.action.clone(),
            target: e.target.clone(),
            success: e.result.success,
            error: e.result.error.clone(),
            output: e.result.output.clone(),
            duration_ms: e.duration.as_millis() as u64,
            selection_logic: e.context.selection_logic.clone(),
            previous_action: e.context.previous_action.clone(),
            from_guidance: e.context.from_guidance,
        }
    }
}

// ============================================================================
// Arc wrapper for shared usage
// ============================================================================

impl<T: TraceSubscriber> TraceSubscriber for Arc<T> {
    fn on_event(&self, event: &ActionEvent) {
        (**self).on_event(event);
    }

    fn finish(&self) {
        (**self).finish();
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use super::*;
    use crate::events::action::{ActionEventBuilder, ActionEventResult};
    use crate::types::WorkerId;

    fn make_event(tick: u64, action: &str, success: bool) -> ActionEvent {
        let result = if success {
            ActionEventResult::success_with_output("ok")
        } else {
            ActionEventResult::failure("error")
        };
        ActionEventBuilder::new(tick, WorkerId(0), action)
            .result(result)
            .duration(Duration::from_millis(50))
            .build()
    }

    #[test]
    fn test_noop_subscriber() {
        let sub = NoOpTraceSubscriber::new();
        sub.on_event(&make_event(1, "Test", true));
        // 何も起きない
    }

    #[test]
    fn test_in_memory_subscriber() {
        let sub = InMemoryTraceSubscriber::new();
        assert!(sub.is_empty());

        sub.on_event(&make_event(1, "CheckStatus", true));
        sub.on_event(&make_event(2, "ReadLogs", false));

        assert_eq!(sub.len(), 2);

        let events = sub.events();
        assert_eq!(events[0].tick, 1);
        assert_eq!(events[0].action, "CheckStatus");
        assert!(events[0].success);
        assert_eq!(events[1].tick, 2);
        assert_eq!(events[1].action, "ReadLogs");
        assert!(!events[1].success);
    }

    #[test]
    fn test_jsonl_subscriber() {
        let temp_dir = std::env::temp_dir();
        let path = temp_dir.join(format!("test_trace_{}.jsonl", std::process::id()));

        {
            let sub = JsonlTraceSubscriber::new(&path).unwrap();
            sub.on_event(&make_event(1, "CheckStatus", true));
            sub.on_event(&make_event(2, "ReadLogs", false));
            sub.finish();
        }

        let content = std::fs::read_to_string(&path).unwrap();
        let lines: Vec<&str> = content.lines().collect();
        assert_eq!(lines.len(), 2);

        let first: TraceEvent = serde_json::from_str(lines[0]).unwrap();
        assert_eq!(first.tick, 1);
        assert_eq!(first.action, "CheckStatus");

        std::fs::remove_file(&path).ok();
    }
}