systemprompt-cli 0.43.0

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
//! Task-execution tables: task summary, steps, AI requests, MCP tool calls,
//! artifacts, and the interleaved trace view.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use systemprompt_logging::{
    AiRequestInfo, ExecutionStep, McpToolExecution, TaskArtifact, TaskInfo, TraceEvent,
};
use tabled::settings::Style;
use tabled::{Table, Tabled};

use super::{dash, millis, truncate_cell};

#[derive(Tabled)]
struct TaskInfoRow {
    #[tabled(rename = "Task ID")]
    task: String,
    #[tabled(rename = "Agent")]
    agent_name: String,
    #[tabled(rename = "Status")]
    status: String,
    #[tabled(rename = "Started")]
    started_at: String,
    #[tabled(rename = "Duration")]
    duration: String,
}

#[must_use]
pub fn task_info_table(task_info: &TaskInfo) -> String {
    let rows = vec![TaskInfoRow {
        task: task_info.task_id.as_str().chars().take(8).collect(),
        agent_name: task_info.agent_name.clone().unwrap_or_else(dash),
        status: task_info.status.clone(),
        started_at: task_info
            .started_at
            .map_or_else(dash, |t| t.format("%H:%M:%S").to_string()),
        duration: millis(task_info.execution_time_ms),
    }];
    Table::new(rows).with(Style::rounded()).to_string()
}

#[derive(Tabled)]
struct StepRow {
    #[tabled(rename = "#")]
    step_number: usize,
    #[tabled(rename = "Type")]
    step_type: String,
    #[tabled(rename = "Title")]
    title: String,
    #[tabled(rename = "Status")]
    status: String,
    #[tabled(rename = "Duration")]
    duration: String,
}

#[must_use]
pub fn execution_steps_table(steps: &[ExecutionStep]) -> String {
    let rows: Vec<StepRow> = steps
        .iter()
        .enumerate()
        .map(|(i, s)| StepRow {
            step_number: i + 1,
            step_type: s.step_type.clone().unwrap_or_else(|| "unknown".to_owned()),
            title: truncate_cell(s.title.as_deref().unwrap_or_default(), 40),
            status: s.status.clone(),
            duration: millis(s.duration_ms),
        })
        .collect();
    Table::new(rows).with(Style::rounded()).to_string()
}

#[derive(Tabled)]
struct AiRequestRow {
    #[tabled(rename = "Model")]
    model: String,
    #[tabled(rename = "Max")]
    max_tokens: String,
    #[tabled(rename = "Tokens")]
    tokens: String,
    #[tabled(rename = "Cost")]
    cost: String,
    #[tabled(rename = "Latency")]
    latency: String,
}

#[must_use]
pub fn ai_requests_table(requests: &[AiRequestInfo]) -> String {
    let rows: Vec<AiRequestRow> = requests
        .iter()
        .map(|r| AiRequestRow {
            model: format!(
                "{}/{}",
                r.provider.as_deref().unwrap_or("-"),
                r.model.as_deref().unwrap_or("-")
            ),
            max_tokens: r.max_tokens.map_or_else(dash, |t| t.to_string()),
            tokens: format!(
                "{} (in:{}, out:{})",
                r.input_tokens.unwrap_or(0) + r.output_tokens.unwrap_or(0),
                r.input_tokens.unwrap_or(0),
                r.output_tokens.unwrap_or(0)
            ),
            #[expect(
                clippy::cast_precision_loss,
                reason = "display-only dollar conversion of microdollar totals"
            )]
            cost: format!("${:.4}", r.cost_microdollars as f64 / 1_000_000.0),
            latency: millis(r.latency_ms),
        })
        .collect();
    Table::new(rows).with(Style::rounded()).to_string()
}

#[derive(Tabled)]
struct ToolCallRow {
    #[tabled(rename = "Tool")]
    tool_name: String,
    #[tabled(rename = "Server")]
    server: String,
    #[tabled(rename = "Status")]
    status: String,
    #[tabled(rename = "Duration")]
    duration: String,
}

#[must_use]
pub fn mcp_tool_calls_table(executions: &[McpToolExecution]) -> String {
    let rows: Vec<ToolCallRow> = executions
        .iter()
        .map(|e| ToolCallRow {
            tool_name: e.tool_name.clone(),
            server: e.server_name.clone(),
            status: e.status.clone(),
            duration: millis(e.execution_time_ms),
        })
        .collect();
    Table::new(rows).with(Style::rounded()).to_string()
}

#[derive(Tabled)]
struct TaskArtifactRow {
    #[tabled(rename = "ID")]
    artifact: String,
    #[tabled(rename = "Type")]
    artifact_type: String,
    #[tabled(rename = "Name")]
    name: String,
    #[tabled(rename = "Source")]
    source: String,
    #[tabled(rename = "Tool")]
    tool_name: String,
}

#[must_use]
pub fn task_artifacts_table(artifacts: &[TaskArtifact]) -> String {
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    let rows: Vec<TaskArtifactRow> = artifacts
        .iter()
        .filter(|a| seen.insert(a.artifact_id.to_string()))
        .map(|a| TaskArtifactRow {
            artifact: truncate_cell(a.artifact_id.as_str(), 12),
            artifact_type: a.artifact_type.clone(),
            name: a.name.as_ref().map_or_else(dash, |s| truncate_cell(s, 30)),
            source: a.source.clone().unwrap_or_else(dash),
            tool_name: a.tool_name.clone().unwrap_or_else(dash),
        })
        .collect();
    Table::new(&rows).with(Style::rounded()).to_string()
}

#[derive(Tabled)]
struct TraceRow {
    #[tabled(rename = "Time")]
    time: String,
    #[tabled(rename = "Delta")]
    delta: String,
    #[tabled(rename = "Type")]
    event_type: String,
    #[tabled(rename = "Details")]
    details: String,
    #[tabled(rename = "Latency")]
    latency: String,
}

#[must_use]
pub fn format_metadata_value(key: &str, value: &serde_json::Value) -> String {
    let raw = || format!("{value}").trim_matches('"').to_owned();
    match key {
        "cost_microdollars" => value.as_i64().map_or_else(raw, |microdollars| {
            #[expect(
                clippy::cast_precision_loss,
                reason = "display-only dollar conversion of microdollar totals"
            )]
            let dollars = microdollars as f64 / 1_000_000.0;
            format!("${dollars:.6}")
        }),
        "latency_ms" | "execution_time_ms" => {
            value.as_i64().map_or_else(raw, |ms| format!("{ms}ms"))
        },
        "tokens_used" => value.as_i64().map_or_else(raw, |tokens| tokens.to_string()),
        _ => raw(),
    }
}

#[must_use]
pub fn extract_latency_from_metadata(metadata: Option<&str>, event_type: &str) -> String {
    if let Some(meta) = metadata
        && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(meta)
    {
        let key = match event_type {
            "AI" => Some("latency_ms"),
            "MCP" => Some("execution_time_ms"),
            _ => None,
        };
        if let Some(key) = key
            && let Some(ms) = parsed.get(key).and_then(serde_json::Value::as_i64)
        {
            return format!("{ms}ms");
        }
    }
    dash()
}

#[must_use]
pub fn trace_events_table(events: &[TraceEvent]) -> String {
    let mut prev_timestamp: Option<chrono::DateTime<chrono::Utc>> = None;
    let rows: Vec<TraceRow> = events
        .iter()
        .map(|e| {
            let delta = prev_timestamp.map_or_else(
                || "+0ms".to_owned(),
                |prev| {
                    let delta_ms = e.timestamp.signed_duration_since(prev).num_milliseconds();
                    format!("+{delta_ms}ms")
                },
            );
            prev_timestamp = Some(e.timestamp);
            TraceRow {
                time: e.timestamp.format("%H:%M:%S%.3f").to_string(),
                delta,
                event_type: e.event_type.clone(),
                details: truncate_cell(&e.details, 100),
                latency: extract_latency_from_metadata(e.metadata.as_deref(), &e.event_type),
            }
        })
        .collect();

    if rows.is_empty() {
        String::new()
    } else {
        Table::new(rows).with(Style::modern()).to_string()
    }
}