ai-dispatch 8.104.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, paths, 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 {
        if !path.is_empty() {
            return fs::read_to_string(path)
                .with_context(|| format!("Failed to read output file {path}"));
        }
    }
    let persisted = paths::task_dir(task.id.as_str()).join("result.md");
    if persisted.exists() {
        return fs::read_to_string(&persisted)
            .with_context(|| format!("Failed to read result file {}", persisted.display()));
    }
    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,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::paths::AidHomeGuard;
    use crate::types::{AgentKind, TaskId, TaskStatus, VerifyStatus};
    use chrono::Local;

    fn task(id: &str) -> Task {
        Task {
            id: TaskId(id.to_string()),
            agent: AgentKind::Codex,
            custom_agent_name: None,
            prompt: "prompt".to_string(),
            resolved_prompt: None,
            category: None,
            status: TaskStatus::Done,
            parent_task_id: None,
            workgroup_id: None,
            caller_kind: None,
            caller_session_id: None,
            agent_session_id: None,
            repo_path: None,
            worktree_path: None,
            worktree_branch: None,
            start_sha: None,
            log_path: None,
            output_path: None,
            tokens: None,
            prompt_tokens: None,
            duration_ms: None,
            model: None,
            cost_usd: None,
            exit_code: None,
            created_at: Local::now(),
            completed_at: None,
            verify: None,
            verify_status: VerifyStatus::Skipped,
            pending_reason: None,
            read_only: false,
            budget: false,
            audit_verdict: None,
            audit_report_path: None,
            delivery_assessment: None,
        }
    }

    #[test]
    fn read_output_uses_persisted_result_when_output_path_absent() {
        let temp = tempfile::tempdir().unwrap();
        let _aid_home = AidHomeGuard::set(temp.path());
        let result_path = paths::task_dir("t-export-result").join("result.md");
        std::fs::create_dir_all(result_path.parent().unwrap()).unwrap();
        std::fs::write(&result_path, "exported report").unwrap();

        let output = read_output(&task("t-export-result")).unwrap();

        assert_eq!(output, "exported report");
    }
}