ai-dispatch 8.96.0

Multi-AI CLI team orchestrator
// Task export handlers for markdown/json and ShareGPT JSONL output.
// Exports: ExportArgs, ExportFormat, run().
// Deps: show::worktree_diff, export_sharegpt, Store, Task/TaskEvent.
use anyhow::{anyhow, Context, Result};
use serde::Serialize;
use std::{fs, sync::Arc};
use crate::{cmd::show::worktree_diff, store::Store, types::{Task, TaskEvent}};

#[path = "export_sharegpt.rs"]
mod export_sharegpt;

pub enum ExportFormat {
    Markdown,
    Json,
}
impl ExportFormat {
    pub fn parse(value: &str) -> Result<Self> {
        match value.to_lowercase().as_str() {
            "md" | "markdown" => Ok(Self::Markdown),
            "json" => Ok(Self::Json),
            other => Err(anyhow!("Unsupported export format '{other}'")),
        }
    }
}
pub struct ExportArgs {
    pub task_id: String,
    pub format: ExportFormat,
    pub sharegpt: bool,
    pub output: Option<String>,
}
pub async fn run(store: Arc<Store>, args: ExportArgs) -> Result<()> {
    if args.sharegpt {
        return export_sharegpt::export_sharegpt(store.as_ref(), &args.task_id, args.output.as_deref());
    }
    let task = load_task(&store, &args.task_id)?;
    let events = store.get_events(&args.task_id)?;
    let output = read_output(&task)?;
    let diff = worktree_diff(&task, &args.task_id)?
        .trim_start_matches('\n')
        .to_string();
    let timeline = format_event_timeline(&events);
    let body = match args.format {
        ExportFormat::Markdown => build_markdown(&task, &timeline, &output, &diff),
        ExportFormat::Json => build_json(&task, &timeline, &output, &diff)?,
    };
    if let Some(file) = args.output {
        fs::write(file, body)?;
    } else {
        print!("{body}");
    }
    Ok(())
}
fn build_markdown(task: &Task, timeline: &[String], output: &str, diff: &str) -> String {
    let header = format!(
        "# Task {}\n- Agent: {}\n- Status: {}\n- Duration: {}\n- Cost: {}\n- Created: {}\n\n## Prompt\n",
        task.id,
        task.agent_display_name(),
        task.status.as_str(),
        format_duration(task.duration_ms),
        format_cost(task.cost_usd),
        task.created_at.to_rfc3339(),
    );
    let mut out = header;
    out.push_str(&task.prompt);
    out.push_str("\n\n## Events\n");
    if timeline.is_empty() {
        out.push_str("(none)\n");
    } else {
        out.push_str(&timeline.join("\n"));
        out.push('\n');
    }
    out.push_str("\n## Output\n");
    if output.is_empty() {
        out.push_str("(none)\n");
    } else {
        out.push_str(output);
        out.push('\n');
    }
    out.push_str("\n## Diff\n```diff\n");
    out.push_str(diff);
    out.push_str("\n```\n");
    out
}
fn build_json(task: &Task, timeline: &[String], output: &str, diff: &str) -> Result<String> {
    let payload = ExportPayload {
        id: task.id.as_str(),
        agent: task.agent_display_name(),
        status: task.status.as_str(),
        duration: format_duration(task.duration_ms),
        cost: format_cost(task.cost_usd),
        created_at: task.created_at.to_rfc3339(),
        prompt: &task.prompt,
        events: timeline.to_vec(),
        output,
        diff,
    };
    serde_json::to_string_pretty(&payload).context("Failed to serialize export")
}
fn format_event_timeline(events: &[TaskEvent]) -> Vec<String> {
    events
        .iter()
        .map(|event| {
            format!(
                "- [{}] {}: {}",
                event.timestamp.format("%H:%M:%S"),
                event.event_kind.as_str(),
                event.detail,
            )
        })
        .collect()
}
fn format_duration(duration_ms: Option<i64>) -> String {
    if let Some(ms) = duration_ms {
        let secs = ms / 1000;
        if secs < 60 {
            format!("{}s", secs)
        } else {
            format!("{}m {:02}s", secs / 60, secs % 60)
        }
    } else {
        "n/a".to_string()
    }
}
fn format_cost(cost: Option<f64>) -> String {
    cost
        .map(|value| format!("${value:.2}"))
        .unwrap_or_else(|| "n/a".to_string())
}
fn read_output(task: &Task) -> Result<String> {
    if let Some(ref path) = task.output_path {
        fs::read_to_string(path).with_context(|| format!("Failed to read output file {path}"))
    } else {
        Ok(String::new())
    }
}
fn load_task(store: &Store, task_id: &str) -> Result<Task> {
    store
        .get_task(task_id)?
        .ok_or_else(|| anyhow!("Task '{task_id}' not found"))
}

#[derive(Serialize)]
struct ExportPayload<'a> {
    id: &'a str,
    agent: &'a str,
    status: &'a str,
    duration: String,
    cost: String,
    created_at: String,
    prompt: &'a str,
    events: Vec<String>,
    output: &'a str,
    diff: &'a str,
}