loopflow 0.12.28

Run steps and flows with coding agents
Documentation
//! `lf usage` — direct provider-authored usage from Home-local Run records.

use std::path::Path;

use anyhow::Result;
use time::OffsetDateTime;

use crate::controller::wave::journal::short_id;
use crate::lf::commands::work_catalog::WorkCatalog;
use crate::lf::commands::WorkFilter;
use crate::lf::output::{format_cost, format_int, truncate, Colors};
use crate::run_record::RunSnapshot;

const REPO_WIDTH: usize = 18;
const WORK_WIDTH: usize = 22;
const RUN_WIDTH: usize = 22;
const NUM_WIDTH: usize = 12;

/// Print recent direct usage evidence. JSON is the same ordered Run projection
/// used by `lf runs`; it does not invent interval completeness or provider
/// finality.
pub fn run(
    json: bool,
    days: u32,
    wave: Option<&str>,
    project: Option<&str>,
    task: Option<&str>,
) -> Result<()> {
    let since = since_days(days);
    let runs = collect_since_at(
        &crate::store::observability_home_dir(),
        since,
        WorkFilter {
            wave,
            project,
            task,
        },
        &WorkCatalog::load()?,
    )?;
    if json {
        println!("{}", serde_json::to_string(&runs)?);
        return Ok(());
    }
    print_report(&runs, days);
    Ok(())
}

fn since_days(days: u32) -> i64 {
    if days == 0 {
        0
    } else {
        OffsetDateTime::now_utc().unix_timestamp() - i64::from(days) * 86_400
    }
}

fn collect_since_at(
    home: &Path,
    since: i64,
    filter: WorkFilter<'_>,
    catalog: &WorkCatalog,
) -> Result<Vec<RunSnapshot>> {
    crate::lf::commands::runs::collect_runs_started_since_at(home, filter, since, catalog)
}

fn print_report(runs: &[RunSnapshot], days: u32) {
    let window = if days == 0 {
        "all time".to_string()
    } else {
        format!("last {days} days")
    };
    if runs.is_empty() {
        println!("No direct Run usage recorded ({window}).");
        return;
    }

    let colors = Colors::default();
    println!("{}DIRECT RUN USAGE ({window}){}", colors.bold, colors.reset);
    println!(
        "{bold}{time:<12}  {repo:<REPO_WIDTH$}  {work:<WORK_WIDTH$}  {run:<RUN_WIDTH$}  {input:>NUM_WIDTH$}  {output:>NUM_WIDTH$}  {cache:>NUM_WIDTH$}  {cost:>9}  {finality:>9}  {gaps:>5}  RUN{reset}",
        bold = colors.bold,
        reset = colors.reset,
        time = "TIME",
        repo = "REPO",
        work = "WORK",
        run = "RUN",
        input = "INPUT",
        output = "OUTPUT",
        cache = "CACHE READ",
        cost = "COST",
        finality = "FINAL",
        gaps = "GAPS",
    );
    for run in runs {
        println!(
            "{time:<12}  {repo:<REPO_WIDTH$}  {work:<WORK_WIDTH$}  {run:<RUN_WIDTH$}  {input:>NUM_WIDTH$}  {output:>NUM_WIDTH$}  {cache:>NUM_WIDTH$}  {cost:>9}  {finality:>9}  {gaps:>5}  {id}",
            time = format_time(run.started),
            repo = truncate(&display_repo(run.repo.as_deref()), REPO_WIDTH),
            work = truncate(&display_work(run), WORK_WIDTH),
            run = truncate(run.label(), RUN_WIDTH),
            input = format_optional(run.usage.input_tokens),
            output = format_optional(run.usage.output_tokens),
            cache = format_optional(run.usage.cache_read_tokens),
            cost = run
                .usage
                .cost_usd
                .map(format_cost)
                .unwrap_or_else(|| "-".to_string()),
            finality = format!("{}/{}", run.usage.final_streams, run.usage.streams),
            gaps = run.evidence_gaps,
            id = short_id(&run.id),
        );
    }
}

fn display_work(run: &RunSnapshot) -> String {
    for kind in ["task", "project", "wave"] {
        if let Some(subject) = run.subject(kind) {
            return format!("{kind}/{subject}");
        }
    }
    "-".to_string()
}

fn format_optional(value: Option<i64>) -> String {
    value
        .and_then(|value| u64::try_from(value).ok())
        .map(format_int)
        .unwrap_or_else(|| "-".to_string())
}

fn display_repo(repo: Option<&str>) -> String {
    repo.and_then(|value| Path::new(value).file_name())
        .and_then(|value| value.to_str())
        .unwrap_or("-")
        .to_string()
}

fn format_time(unix: i64) -> String {
    chrono::DateTime::from_timestamp(unix, 0)
        .map(|utc| {
            utc.with_timezone(&chrono::Local)
                .format("%b %-d %H:%M")
                .to_string()
        })
        .unwrap_or_else(|| unix.to_string())
}

#[cfg(test)]
mod tests {
    use super::{collect_since_at, display_work};
    use crate::engine::stream::StreamEvent;
    use crate::lf::commands::WorkFilter;
    use crate::run_record::{CaptureHandle, RunSpec, SubjectAttribution};

    #[test]
    fn usage_reads_direct_bundle_evidence_without_a_sql_ledger() {
        let home = tempfile::tempdir().unwrap();
        let capture = CaptureHandle::begin_at(
            home.path(),
            RunSpec {
                harness: "codex".to_string(),
                model: Some("gpt".to_string()),
                surface: "headless".to_string(),
                cwd: home.path().to_path_buf(),
                repo: Some(home.path().to_path_buf()),
                worktree: Some(home.path().to_path_buf()),
                skill: Some("implement".to_string()),
                subjects: vec![SubjectAttribution::declared("task:LOO-265".to_string())],
                flow: crate::run_record::RunFlowMembership::Independent,
            },
        )
        .unwrap();
        capture.record_stream_event(&StreamEvent::Usage {
            input_tokens: Some(12),
            output_tokens: None,
            cache_read_tokens: Some(4),
        });
        capture.finish("completed").unwrap();

        let runs = collect_since_at(
            home.path(),
            0,
            WorkFilter {
                wave: None,
                project: None,
                task: Some("LOO-265"),
            },
            &super::WorkCatalog::default(),
        )
        .unwrap();
        assert_eq!(runs.len(), 1);
        assert_eq!(display_work(&runs[0]), "task/LOO-265");
        assert_eq!(runs[0].usage.input_tokens, Some(12));
        assert_eq!(runs[0].usage.output_tokens, None);
        assert_eq!(runs[0].usage.cache_read_tokens, Some(4));
        assert_eq!(runs[0].usage.final_streams, 0);
        assert_eq!(runs[0].usage.gaps, 0);

        let excluded = collect_since_at(
            home.path(),
            0,
            WorkFilter {
                wave: None,
                project: None,
                task: Some("LOO-999"),
            },
            &super::WorkCatalog::default(),
        )
        .unwrap();
        assert!(excluded.is_empty());
    }
}