beam-core 0.3.0

Shared core types and workflow/session logic for beam
Documentation
use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::{
    BeamPaths, RunChatBinding, WorkflowDefinition, read_run_snapshot,
    workflow_snapshot::{RunSnapshotDTO, RunStatus},
};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColdWorkflowRun {
    pub run_id: String,
    pub def: WorkflowDefinition,
    pub snapshot: RunSnapshotDTO,
    pub binding: RunChatBinding,
}

#[derive(Debug, Clone, Default)]
pub struct ColdScanStats {
    pub discovered: usize,
    pub skipped: Vec<String>,
}

pub async fn scan_cold_workflow_runs(
    paths: &BeamPaths,
    owner_lark_app_id: &str,
) -> Result<(Vec<ColdWorkflowRun>, ColdScanStats)> {
    let runs_dir = paths.workflow_runs_dir();
    if !runs_dir.exists() {
        return Ok((vec![], ColdScanStats::default()));
    }

    let mut runs = Vec::new();
    let mut stats = ColdScanStats::default();
    let dir_entries = std::fs::read_dir(&runs_dir).context("failed to read workflow runs dir")?;

    for entry in dir_entries {
        let entry = entry.context("failed to read dir entry")?;
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        let run_id = entry.file_name().to_string_lossy().to_string();
        let run_dir = runs_dir.join(&run_id);

        match scan_single_run(&run_dir, &run_id, owner_lark_app_id).await {
            Ok(Some(run)) => {
                stats.discovered += 1;
                runs.push(run);
            }
            Ok(None) => {}
            Err(reason) => {
                stats.skipped.push(format!("{}: {}", run_id, reason));
            }
        }
    }

    Ok((runs, stats))
}

async fn scan_single_run(
    run_dir: &Path,
    run_id: &str,
    owner_lark_app_id: &str,
) -> Result<Option<ColdWorkflowRun>> {
    let binding_path = run_dir.join("chat-binding.json");
    if !binding_path.exists() {
        return Ok(None);
    }

    let binding: RunChatBinding = serde_json::from_str(
        &std::fs::read_to_string(&binding_path)
            .with_context(|| "failed to read chat-binding.json")?,
    )
    .with_context(|| "invalid chat-binding.json")?;

    if binding.lark_app_id != owner_lark_app_id {
        return Ok(None);
    }

    let def_path = run_dir.join("workflow.json");
    if !def_path.exists() {
        anyhow::bail!("missing workflow.json");
    }

    let raw_def =
        std::fs::read_to_string(&def_path).with_context(|| "failed to read workflow.json")?;
    let def: WorkflowDefinition =
        serde_json::from_str(&raw_def).with_context(|| "invalid workflow.json")?;

    let Some(snapshot) = read_run_snapshot(run_dir)
        .await
        .with_context(|| "failed to read snapshot")?
    else {
        anyhow::bail!("empty event log");
    };

    let terminal = matches!(
        snapshot.run.status,
        RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
    );
    if terminal {
        return Ok(None);
    }

    Ok(Some(ColdWorkflowRun {
        run_id: run_id.to_string(),
        def,
        snapshot,
        binding,
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::BeamPaths;
    use std::{fs, path::PathBuf};

    fn temp_data_root() -> PathBuf {
        std::env::temp_dir().join(format!("beam-coldscan-{}", uuid::Uuid::new_v4()))
    }

    #[tokio::test]
    async fn scan_empty_runs_dir_returns_nothing() {
        let root = temp_data_root();
        let paths = BeamPaths::from_root(&root);
        fs::create_dir_all(paths.workflow_runs_dir()).unwrap();

        let (runs, stats) = scan_cold_workflow_runs(&paths, "app-1").await.unwrap();
        assert!(runs.is_empty());
        assert_eq!(stats.discovered, 0);

        let _ = fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn scan_silently_skips_runs_without_chat_binding() {
        let root = temp_data_root();
        let paths = BeamPaths::from_root(&root);
        let run_dir = paths.workflow_run_dir("run-1");
        fs::create_dir_all(&run_dir).unwrap();

        let (runs, _stats) = scan_cold_workflow_runs(&paths, "app-1").await.unwrap();
        assert!(runs.is_empty());

        let _ = fs::remove_dir_all(root);
    }
}