From d086811c2f431614c3a5188fdcfc683dedec0d02 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 14 Mar 2026 18:19:21 -0400 Subject: [PATCH] Add `fabro inspect` to show detailed JSON data for a workflow run Outputs run_id, run_dir, status, manifest, conclusion, checkpoint, and sandbox as a JSON array (null for missing files). Resolves runs by ID prefix or workflow name, matching existing `fabro logs` semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/main.rs | 6 + lib/crates/fabro-workflows/src/cli/inspect.rs | 206 ++++++++++++++++++ lib/crates/fabro-workflows/src/cli/mod.rs | 1 + 3 files changed, 213 insertions(+) create mode 100644 lib/crates/fabro-workflows/src/cli/inspect.rs diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 7d36e9a7a..2411ead71 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -83,6 +83,8 @@ enum Command { Diff(fabro_workflows::cli::diff::DiffArgs), /// View the event log of a workflow run Logs(fabro_workflows::cli::logs::LogsArgs), + /// Show detailed information about a workflow run + Inspect(fabro_workflows::cli::inspect::InspectArgs), /// List and test LLM models Model { #[command(subcommand)] @@ -377,6 +379,7 @@ async fn main_inner() -> (String, Result<()>) { Command::Ssh(_) => "ssh", Command::Diff(_) => "diff", Command::Logs(_) => "logs", + Command::Inspect(_) => "inspect", Command::Model { command } => match command { Some(fabro_llm::cli::ModelsCommand::List { .. }) => "model list", Some(fabro_llm::cli::ModelsCommand::Test { .. }) => "model test", @@ -631,6 +634,9 @@ async fn main_inner() -> (String, Result<()>) { let styles = fabro_util::terminal::Styles::detect_stdout(); fabro_workflows::cli::logs::logs_command(args, &styles)?; } + Command::Inspect(args) => { + fabro_workflows::cli::inspect::inspect_command(&args)?; + } Command::Model { command } => { let server = { #[cfg(feature = "server")] diff --git a/lib/crates/fabro-workflows/src/cli/inspect.rs b/lib/crates/fabro-workflows/src/cli/inspect.rs new file mode 100644 index 000000000..9af2da4af --- /dev/null +++ b/lib/crates/fabro-workflows/src/cli/inspect.rs @@ -0,0 +1,206 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use clap::Args; +use serde::Serialize; + +use crate::checkpoint::Checkpoint; +use crate::cli::runs::{default_runs_base, resolve_run, RunStatus}; +use crate::conclusion::Conclusion; +use crate::manifest::Manifest; +use crate::sandbox_record::SandboxRecord; + +#[derive(Args)] +pub struct InspectArgs { + /// Run ID prefix or workflow name (most recent run) + pub run: String, +} + +#[derive(Debug, Serialize)] +pub struct InspectOutput { + pub run_id: String, + pub run_dir: PathBuf, + pub status: RunStatus, + pub manifest: Option, + pub conclusion: Option, + pub checkpoint: Option, + pub sandbox: Option, +} + +pub fn inspect_command(args: &InspectArgs) -> Result<()> { + let base = default_runs_base(); + let run = resolve_run(&base, &args.run)?; + let output = inspect_run_dir(&run.run_id, &run.path, run.status)?; + let json = serde_json::to_string_pretty(&[output])?; + println!("{json}"); + Ok(()) +} + +fn inspect_run_dir(run_id: &str, run_dir: &Path, status: RunStatus) -> Result { + let manifest = Manifest::load(&run_dir.join("manifest.json")) + .ok() + .and_then(|v| serde_json::to_value(v).ok()); + let conclusion = Conclusion::load(&run_dir.join("conclusion.json")) + .ok() + .and_then(|v| serde_json::to_value(v).ok()); + let checkpoint = Checkpoint::load(&run_dir.join("checkpoint.json")) + .ok() + .and_then(|v| serde_json::to_value(v).ok()); + let sandbox = SandboxRecord::load(&run_dir.join("sandbox.json")) + .ok() + .and_then(|v| serde_json::to_value(v).ok()); + + Ok(InspectOutput { + run_id: run_id.to_string(), + run_dir: run_dir.to_path_buf(), + status, + manifest, + conclusion, + checkpoint, + sandbox, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::runs::RunStatus; + use crate::outcome::StageStatus; + + #[test] + fn nonexistent_run_returns_error() { + let args = InspectArgs { + run: "nonexistent-run-id".to_string(), + }; + assert!(inspect_command(&args).is_err()); + } + + #[test] + fn inspect_complete_run_has_all_sections() { + let dir = tempfile::tempdir().unwrap(); + let run_dir = dir.path().to_path_buf(); + + // Write all four JSON files + let manifest = Manifest { + run_id: "test-run".to_string(), + workflow_name: "test".to_string(), + goal: "test goal".to_string(), + start_time: chrono::Utc::now(), + node_count: 2, + edge_count: 1, + run_branch: None, + base_sha: None, + labels: Default::default(), + base_branch: None, + workflow_slug: None, + }; + manifest.save(&run_dir.join("manifest.json")).unwrap(); + + let conclusion = Conclusion { + timestamp: chrono::Utc::now(), + status: StageStatus::Success, + duration_ms: 1000, + failure_reason: None, + final_git_commit_sha: None, + stages: vec![], + total_cost: None, + total_retries: 0, + }; + conclusion.save(&run_dir.join("conclusion.json")).unwrap(); + + let checkpoint = Checkpoint { + timestamp: chrono::Utc::now(), + current_node: "end".to_string(), + completed_nodes: vec!["start".to_string()], + node_retries: Default::default(), + context_values: Default::default(), + logs: vec![], + node_outcomes: Default::default(), + next_node_id: None, + git_commit_sha: None, + loop_failure_signatures: Default::default(), + restart_failure_signatures: Default::default(), + node_visits: Default::default(), + }; + checkpoint.save(&run_dir.join("checkpoint.json")).unwrap(); + + let sandbox = SandboxRecord { + provider: "local".to_string(), + working_directory: "/tmp/work".to_string(), + identifier: None, + host_working_directory: None, + container_mount_point: None, + data_host: None, + }; + sandbox.save(&run_dir.join("sandbox.json")).unwrap(); + + let output = inspect_run_dir( + "test-run", + &run_dir, + RunStatus::Concluded(StageStatus::Success), + ) + .unwrap(); + + assert_eq!(output.run_id, "test-run"); + assert_eq!(output.run_dir, run_dir); + assert!(output.manifest.is_some()); + assert!(output.conclusion.is_some()); + assert!(output.checkpoint.is_some()); + assert!(output.sandbox.is_some()); + } + + #[test] + fn inspect_partial_run_has_null_sections() { + let dir = tempfile::tempdir().unwrap(); + let run_dir = dir.path().to_path_buf(); + + // Only write manifest + let manifest = Manifest { + run_id: "partial-run".to_string(), + workflow_name: "test".to_string(), + goal: "test goal".to_string(), + start_time: chrono::Utc::now(), + node_count: 1, + edge_count: 0, + run_branch: None, + base_sha: None, + labels: Default::default(), + base_branch: None, + workflow_slug: None, + }; + manifest.save(&run_dir.join("manifest.json")).unwrap(); + + let output = inspect_run_dir("partial-run", &run_dir, RunStatus::Running).unwrap(); + + assert_eq!(output.run_id, "partial-run"); + assert!(output.manifest.is_some()); + assert!(output.conclusion.is_none()); + assert!(output.checkpoint.is_none()); + assert!(output.sandbox.is_none()); + } + + #[test] + fn output_json_has_expected_keys() { + let output = InspectOutput { + run_id: "id-1".to_string(), + run_dir: PathBuf::from("/tmp/run"), + status: RunStatus::Unknown, + manifest: None, + conclusion: None, + checkpoint: None, + sandbox: None, + }; + + let json: serde_json::Value = serde_json::to_value(&[output]).unwrap(); + let obj = json.as_array().unwrap()[0].as_object().unwrap(); + let keys: Vec<&String> = obj.keys().collect(); + assert!(keys.contains(&&"run_id".to_string())); + assert!(keys.contains(&&"run_dir".to_string())); + assert!(keys.contains(&&"status".to_string())); + assert!(keys.contains(&&"manifest".to_string())); + assert!(keys.contains(&&"conclusion".to_string())); + assert!(keys.contains(&&"checkpoint".to_string())); + assert!(keys.contains(&&"sandbox".to_string())); + assert_eq!(keys.len(), 7); + } +} diff --git a/lib/crates/fabro-workflows/src/cli/mod.rs b/lib/crates/fabro-workflows/src/cli/mod.rs index c4fc1b3af..8f59dbf95 100644 --- a/lib/crates/fabro-workflows/src/cli/mod.rs +++ b/lib/crates/fabro-workflows/src/cli/mod.rs @@ -3,6 +3,7 @@ pub mod cli_backend; pub mod cp; pub mod diff; pub mod graph; +pub mod inspect; pub mod logs; pub mod parse; pub mod pr;