diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 2af22b8fe..e5a6e62a7 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -32,7 +32,7 @@ pub mod types { AuthMethod, BilledTokenCounts, CommandTermination, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord, PendingInterviewRecord, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, - QuestionType, RepositoryRef, Run, RunClientProvenance, RunEvent, RunParts, RunProjection, + QuestionType, RepositoryRef, Run, RunClientProvenance, RunEvent, RunProjection, RunProvenance, RunSandbox, RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxProvider, SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs index ab27a63a1..fc1ab4a79 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -5,7 +5,8 @@ use chrono::{TimeZone, Utc}; use fabro_api::types::{RepositoryRef as ApiRepositoryRef, RunSummary as ApiRunSummary}; use fabro_types::status::{RunStatus, SuccessReason}; use fabro_types::{ - DiffSummary, PullRequest, RepositoryProvider, RepositoryRef, RunId, RunParts, RunSummary, + DiffSummary, PullRequest, RepositoryProvider, RepositoryRef, RunBillingSummary, RunId, + RunLifecycle, RunLinks, RunOrigin, RunSummary, RunTimestamps, WorkflowRef, }; use serde_json::json; @@ -21,32 +22,53 @@ fn run_summary_json_matches_openapi_shape() { let run_id = RunId::with_timestamp(created_at, 7); let last_event_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 42).unwrap(); let archived_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 1, 0).unwrap(); - let summary = RunSummary::from_parts(RunParts { - run_id, - workflow_name: Some("workflow".to_string()), - workflow_slug: Some("workflow".to_string()), - goal: String::new(), - title: "API title".to_string(), - labels: HashMap::from([("team".to_string(), "core".to_string())]), - source_directory: Some("/tmp/fabro".to_string()), - repo_origin_url: None, - created_by: None, - start_time: Some(created_at), - last_event_at: Some(last_event_at), - completed_at: None, - status: RunStatus::Succeeded { - reason: SuccessReason::PartialSuccess, + let summary = RunSummary { + id: run_id, + title: "API title".to_string(), + goal: String::new(), + workflow: WorkflowRef { + slug: Some("workflow".to_string()), + name: "workflow".to_string(), }, - pending_control: None, - duration_ms: Some(42_000), - total_usd_micros: Some(123), - superseded_by: None, - diff_summary: Some(DiffSummary { + automation: None, + repository: Some(RepositoryRef { + name: "fabro".to_string(), + origin_url: None, + provider: RepositoryProvider::Unknown, + }), + created_by: None, + origin: RunOrigin::default(), + labels: HashMap::from([("team".to_string(), "core".to_string())]), + lifecycle: RunLifecycle { + status: RunStatus::Succeeded { + reason: SuccessReason::PartialSuccess, + }, + pending_control: None, + queue_position: None, + error: None, + archived: true, + archived_at: Some(archived_at), + }, + sandbox: None, + models: vec![], + source_directory: Some("/tmp/fabro".to_string()), + timestamps: RunTimestamps { + created_at, + started_at: Some(created_at), + last_event_at: Some(last_event_at), + completed_at: None, + duration_ms: Some(42_000), + elapsed_secs: Some(42.0), + }, + billing: Some(RunBillingSummary { + total_usd_micros: Some(123), + }), + diff: Some(DiffSummary { files_changed: 3, additions: 12, deletions: 4, }), - pull_request: Some(PullRequest { + pull_request: Some(PullRequest { provider: "github".to_string(), html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(), number: 123, @@ -56,12 +78,10 @@ fn run_summary_json_matches_openapi_shape() { head_branch: "fabro/run/demo".to_string(), title: "Add run PR chip".to_string(), }), - archived_at: Some(archived_at), - sandbox: None, - models: vec![], current_question: None, - web_url: None, - }); + superseded_by: None, + links: RunLinks { web: None }, + }; assert_eq!( serde_json::to_value(&summary).unwrap(), @@ -203,6 +223,22 @@ fn run_summary_deserializes_when_optional_fields_are_absent() { assert_eq!(summary.pull_request, None); } +#[test] +fn run_summary_rejects_legacy_flat_json() { + let created_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap(); + let run_id = RunId::with_timestamp(created_at, 7); + + let result = serde_json::from_value::(json!({ + "run_id": run_id.to_string(), + "workflow_name": "legacy", + "status": { + "kind": "running" + } + })); + + assert!(result.is_err()); +} + fn assert_same_type() { assert_eq!( TypeId::of::(), diff --git a/lib/crates/fabro-cli/src/commands/artifact/mod.rs b/lib/crates/fabro-cli/src/commands/artifact/mod.rs index d69caf24e..f0325fd49 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/mod.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/mod.rs @@ -27,7 +27,7 @@ pub(super) async fn resolve_artifacts( ) -> Result<(RunId, Client, Vec)> { let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(run_selector).await?.run_id; + let run_id = client.resolve_run(run_selector).await?.id; let mut entries = Vec::new(); for entry in client.list_run_artifacts(&run_id).await? { if node.is_some_and(|value| entry.node_slug != value) { diff --git a/lib/crates/fabro-cli/src/commands/dump.rs b/lib/crates/fabro-cli/src/commands/dump.rs index 81201fdc5..b3938e12a 100644 --- a/lib/crates/fabro-cli/src/commands/dump.rs +++ b/lib/crates/fabro-cli/src/commands/dump.rs @@ -21,7 +21,7 @@ pub(crate) async fn run(args: &DumpArgs, base_ctx: &CommandContext) -> Result<() let ctx = base_ctx.with_target(&args.server)?; let printer = ctx.printer(); let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; let state = client.get_run_state(&run_id).await?; let file_count = export_run(client.as_ref(), &run_id, &state, &args.output).await?; if ctx.json_output() { diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index 29220fe00..f0d310acc 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -28,6 +28,6 @@ async fn resolve_run_for_pr( ) -> Result<(CommandContext, Arc, RunId)> { let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(selector).await?.run_id; + let run_id = client.resolve_run(selector).await?.id; Ok((ctx, client, run_id)) } diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 159354a8c..983097685 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -122,7 +122,7 @@ async fn resolve_client_and_run_id( ) -> Result<(Client, fabro_types::RunId)> { let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(run_prefix).await?.run_id; + let run_id = client.resolve_run(run_prefix).await?.id; Ok((client.clone_for_reuse(), run_id)) } diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 49d808b7d..41e86954a 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -21,7 +21,7 @@ pub(crate) async fn run(args: DiffArgs, base_ctx: &CommandContext) -> Result<()> info!(run_id = %args.run, "Showing diff"); let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; let state = client.get_run_state(&run_id).await?; let patch = resolve_diff(&state, &args)?; diff --git a/lib/crates/fabro-cli/src/commands/run/events.rs b/lib/crates/fabro-cli/src/commands/run/events.rs index 8136f6392..e87da50e9 100644 --- a/lib/crates/fabro-cli/src/commands/run/events.rs +++ b/lib/crates/fabro-cli/src/commands/run/events.rs @@ -34,7 +34,7 @@ pub(crate) async fn run( ) -> Result<()> { let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; info!(run_id = %run_id, "Showing events"); let since_cutoff = match &args.since { diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index b37509711..7238fb889 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -10,7 +10,7 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, base_ctx: &CommandCont let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run_id).await?.run_id; + let run_id = client.resolve_run(&args.run_id).await?.id; super::checkpoints::ensure_origin_if_local(client.as_ref(), &run_id, "fork").await?; if args.list { diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 130b3c4f9..5b4d432f7 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -20,7 +20,7 @@ pub(crate) async fn run(args: &LogsArgs, base_ctx: &CommandContext) -> Result<() let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; info!(run_id = %run_id, "Showing raw run log"); let bytes = client diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 165e6f256..330e56e9c 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -52,7 +52,7 @@ pub(crate) async fn dispatch( RunCommands::Start(StartArgs { server, run }) => { let ctx = base_ctx.with_target(&server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&run).await?.run_id; + let run_id = client.resolve_run(&run).await?.id; start::start_run_with_client(client.as_ref(), &run_id, false).await?; if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; @@ -63,7 +63,7 @@ pub(crate) async fn dispatch( let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let ctx = base_ctx.with_target(&server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&run).await?.run_id; + let run_id = client.resolve_run(&run).await?.id; let json = ctx.json_output(); let exit_code = Box::pin(attach::attach_run_with_client( client.as_ref(), diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index c4c79753e..7128cf45f 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -9,7 +9,7 @@ pub(crate) async fn run(args: PreviewArgs, base_ctx: &CommandContext) -> Result< let ctx = base_ctx.with_target(&args.server)?; let printer = ctx.printer(); let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; let expires_in_secs = u64::try_from(args.ttl).map_err(|_| anyhow::anyhow!("--ttl must be positive"))?; let response = client diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 2e28c8f8c..121ad9e25 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -17,7 +17,7 @@ pub(crate) async fn resume_command( let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; super::start::start_run_with_client(client.as_ref(), &run_id, true).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index d00fa9738..01f284950 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -15,7 +15,7 @@ pub(crate) async fn run( let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run_id).await?.run_id; + let run_id = client.resolve_run(&args.run_id).await?.id; ensure_origin_if_local(client.as_ref(), &run_id, "rewind").await?; if args.list || args.target.is_none() { diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index c4ed5fc79..4e0d3cbfc 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -13,7 +13,7 @@ pub(crate) async fn run(args: SshArgs, base_ctx: &CommandContext) -> Result<()> let ctx = base_ctx.with_target(&args.server)?; let printer = ctx.printer(); let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; let ssh = client.create_run_ssh_access(&run_id, args.ttl).await?; info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access"); diff --git a/lib/crates/fabro-cli/src/commands/run/steer.rs b/lib/crates/fabro-cli/src/commands/run/steer.rs index afe13b3b0..fbc908440 100644 --- a/lib/crates/fabro-cli/src/commands/run/steer.rs +++ b/lib/crates/fabro-cli/src/commands/run/steer.rs @@ -8,7 +8,7 @@ use crate::command_context::CommandContext; pub(crate) async fn run(args: SteerArgs, base_ctx: &CommandContext) -> Result<()> { let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; let text = match (args.text_stdin, args.text.clone()) { (true, _) => { diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 3c9c26feb..a2d5bf148 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -26,7 +26,7 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, base_ctx: &CommandCont let printer = base_ctx.printer(); let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(&args.run).await?.run_id; + let run_id = client.resolve_run(&args.run).await?.id; info!(run_id = %run_id, "Waiting for run to complete"); let deadline = args @@ -34,7 +34,7 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, base_ctx: &CommandCont .map(|secs| std::time::Instant::now() + std::time::Duration::from_secs(secs)); let interval = std::time::Duration::from_millis(args.interval); let final_status = loop { - let status = client.retrieve_run(&run_id).await?.status; + let status = client.retrieve_run(&run_id).await?.lifecycle.status; if status.is_terminal() { break status; diff --git a/lib/crates/fabro-cli/src/commands/runs/archive.rs b/lib/crates/fabro-cli/src/commands/runs/archive.rs index bf9543392..9694bd829 100644 --- a/lib/crates/fabro-cli/src/commands/runs/archive.rs +++ b/lib/crates/fabro-cli/src/commands/runs/archive.rs @@ -65,7 +65,7 @@ async fn run_bulk(action: Action, identifiers: &[String], ctx: &CommandContext) } }; - let run_id = run.run_id; + let run_id = run.id; let result = match action { Action::Archive => client.archive_run(&run_id).await, Action::Unarchive => client.unarchive_run(&run_id).await, diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index f6fe97c52..f238125f0 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -84,7 +84,7 @@ async fn resolve_target( return Ok(run_id); } } - Ok(client.resolve_run(identifier).await?.run_id) + Ok(client.resolve_run(identifier).await?.id) } async fn delete_server_run( diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index e45666a1d..0b7517566 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -18,22 +18,19 @@ impl ServerRunSummaryInfo { } pub(crate) fn run_id(&self) -> RunId { - self.summary.run_id + self.summary.id } pub(crate) fn workflow_name(&self) -> String { - self.summary - .workflow_name - .clone() - .unwrap_or_else(|| "[no run spec]".to_string()) + self.summary.workflow.name.clone() } pub(crate) fn workflow_slug(&self) -> Option<&str> { - self.summary.workflow_slug.as_deref() + self.summary.workflow.slug.as_deref() } pub(crate) fn status(&self) -> RunStatus { - self.summary.status + self.summary.lifecycle.status } pub(crate) fn start_time(&self) -> String { @@ -44,8 +41,9 @@ impl ServerRunSummaryInfo { pub(crate) fn start_time_dt(&self) -> Option> { self.summary - .start_time - .or(Some(self.summary.run_id.created_at())) + .timestamps + .started_at + .or(Some(self.summary.id.created_at())) } pub(crate) fn labels(&self) -> &HashMap { @@ -53,11 +51,14 @@ impl ServerRunSummaryInfo { } pub(crate) fn duration_ms(&self) -> Option { - self.summary.duration_ms + self.summary.timestamps.duration_ms } pub(crate) fn total_usd_micros(&self) -> Option { - self.summary.total_usd_micros + self.summary + .billing + .as_ref() + .and_then(|billing| billing.total_usd_micros) } pub(crate) fn source_directory(&self) -> Option<&str> { @@ -65,7 +66,10 @@ impl ServerRunSummaryInfo { } pub(crate) fn repo_origin_url(&self) -> Option<&str> { - self.summary.repo_origin_url.as_deref() + self.summary + .repository + .as_ref() + .and_then(|repository| repository.origin_url.as_deref()) } pub(crate) fn goal(&self) -> String { diff --git a/lib/crates/fabro-cli/tests/it/cmd/archive.rs b/lib/crates/fabro-cli/tests/it/cmd/archive.rs index 9bba641a1..dd2d39662 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/archive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/archive.rs @@ -2,7 +2,9 @@ use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use serde_json::Value; -use super::support::{setup_seeded_completed_dry_run, setup_seeded_created_dry_run}; +use super::support::{ + remote_run_summary_json, setup_seeded_completed_dry_run, setup_seeded_created_dry_run, +}; use crate::support::unique_run_id; fn ulid_filter() -> (String, String) { @@ -213,26 +215,17 @@ fn archive_resolves_selector_via_server_endpoint() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!({ - "run_id": run_id, - "workflow_name": "Nightly Build", - "workflow_slug": "nightly-build", - "goal": "Nightly run", - "title": "Nightly run", - "labels": {}, - "source_directory": null, - "repository": { "name": "unknown" }, - "start_time": "2026-04-05T12:00:00Z", - "created_at": "2026-04-05T12:00:00Z", - "status": { + remote_run_summary_json( + &run_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ "kind": "succeeded", "reason": "completed" - }, - "pending_control": null, - "duration_ms": 123, - "elapsed_secs": 0, - "total_usd_micros": null - }) + }), + "2026-04-05T12:00:00Z", + ) .to_string(), ); }); @@ -242,21 +235,17 @@ fn archive_resolves_selector_via_server_endpoint() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!({ - "id": run_id, - "status": { - "kind": "archived", - "prior": { - "kind": "succeeded", - "reason": "completed" - } - }, - "error": null, - "queue_position": null, - "pending_control": null, - "title": "Nightly run", - "created_at": "2026-04-05T12:00:00Z" - }) + remote_run_summary_json( + &run_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + ) .to_string(), ); }); diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 0633cdb59..cf1639391 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -4,29 +4,21 @@ use insta::assert_snapshot; use serde_json::json; use super::support::{ - compact_git_inspect, compact_inspect, run_success, setup_seeded_completed_dry_run, - setup_seeded_created_dry_run, setup_seeded_git_backed_changed_run, + compact_git_inspect, compact_inspect, remote_run_summary_json, run_success, + setup_seeded_completed_dry_run, setup_seeded_created_dry_run, + setup_seeded_git_backed_changed_run, }; use crate::support::{run_projection_json, unique_run_id}; fn remote_run_summary(run_id: &str, status: &serde_json::Value) -> serde_json::Value { - json!({ - "run_id": run_id, - "workflow_name": "Nightly Build", - "workflow_slug": "nightly-build", - "goal": "Inspect remote state", - "title": "Inspect remote state", - "labels": {}, - "source_directory": "/srv/repo", - "repository": { "name": "repo" }, - "start_time": "2026-04-19T12:00:00Z", - "created_at": "2026-04-19T12:00:00Z", - "status": status, - "pending_control": null, - "duration_ms": null, - "elapsed_secs": null, - "total_usd_micros": null - }) + remote_run_summary_json( + run_id, + "Nightly Build", + "nightly-build", + "Inspect remote state", + status, + "2026-04-19T12:00:00Z", + ) } #[test] diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index e912b13ff..31dc0e63c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -6,7 +6,8 @@ use httpmock::MockServer; use serde_json::Value; use super::support::{ - local_dev_token, setup_seeded_completed_dry_run, setup_seeded_created_dry_run, + local_dev_token, remote_run_summary_json, setup_seeded_completed_dry_run, + setup_seeded_created_dry_run, }; use crate::support::{fatal_error_line, seed_dev_token_auth, unique_run_id}; @@ -329,32 +330,27 @@ fn ps_uses_configured_server_target_without_server_flag() { let context = test_context!(); let server = MockServer::start(); let run_id = unique_run_id(); + let mut summary = remote_run_summary_json( + &run_id, + "Remote Workflow", + "remote-workflow", + "Remote goal", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + ); + summary["labels"] = serde_json::json!({ + "suite": "remote" + }); let mock = server.mock(|when, then| { when.method("GET").path("/api/v1/runs"); then.status(200) .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [{ - "run_id": run_id, - "workflow_name": "Remote Workflow", - "workflow_slug": "remote-workflow", - "goal": "Remote goal", - "title": "Remote goal", - "labels": { - "suite": "remote" - }, - "source_directory": "/srv/repo", - "repository": { "name": "repo" }, - "start_time": "2026-04-05T12:00:00Z", - "created_at": "2026-04-05T12:00:00Z", - "status": { - "kind": "succeeded", - "reason": "completed" - }, - "duration_ms": 123, - "total_usd_micros": null - }], + "data": [summary], "meta": { "has_more": false } }) .to_string(), @@ -381,30 +377,24 @@ fn ps_explicit_remote_target_ignores_broken_local_storage_settings() { let context = test_context!(); let server = MockServer::start(); let run_id = unique_run_id(); + let summary = remote_run_summary_json( + &run_id, + "Explicit Remote", + "explicit-remote", + "Remote goal", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-20T12:00:00Z", + ); let mock = server.mock(|when, then| { when.method("GET").path("/api/v1/runs"); then.status(200) .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [{ - "run_id": run_id, - "workflow_name": "Explicit Remote", - "workflow_slug": "explicit-remote", - "goal": "Remote goal", - "title": "Remote goal", - "labels": {}, - "source_directory": "/srv/repo", - "repository": { "name": "repo" }, - "start_time": "2026-04-20T12:00:00Z", - "created_at": "2026-04-20T12:00:00Z", - "status": { - "kind": "succeeded", - "reason": "completed" - }, - "duration_ms": 123, - "total_usd_micros": null - }], + "data": [summary], "meta": { "has_more": false } }) .to_string(), diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index 6b16ef84f..2a6b50be0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -3,7 +3,8 @@ use httpmock::MockServer; use serde_json::Value; use super::support::{ - setup_local_sandbox_run, setup_seeded_completed_dry_run, setup_seeded_created_dry_run, + remote_run_summary_json, setup_local_sandbox_run, setup_seeded_completed_dry_run, + setup_seeded_created_dry_run, }; use crate::support::unique_run_id; @@ -189,25 +190,16 @@ fn rm_without_force_uses_resolve_then_surfaces_server_conflict() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!({ - "run_id": run_id, - "workflow_name": "Active Workflow", - "workflow_slug": "active-workflow", - "goal": "Active goal", - "title": "Active goal", - "labels": {}, - "source_directory": null, - "repository": { "name": "unknown" }, - "start_time": "2026-04-05T12:00:00Z", - "created_at": "2026-04-05T12:00:00Z", - "status": { + remote_run_summary_json( + &run_id, + "Active Workflow", + "active-workflow", + "Active goal", + &serde_json::json!({ "kind": "running" - }, - "pending_control": null, - "duration_ms": 123, - "elapsed_secs": 0, - "total_usd_micros": null - }) + }), + "2026-04-05T12:00:00Z", + ) .to_string(), ); }); @@ -317,24 +309,17 @@ fn rm_uses_configured_server_target_without_local_run_dir() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!({ - "run_id": run_id, - "workflow_name": "Remote Workflow", - "workflow_slug": "remote-workflow", - "goal": "Remote goal", - "title": "Remote goal", - "labels": {}, - "source_directory": null, - "repository": { "name": "unknown" }, - "start_time": "2026-04-05T12:00:00Z", - "created_at": "2026-04-05T12:00:00Z", - "status": { + remote_run_summary_json( + &run_id, + "Remote Workflow", + "remote-workflow", + "Remote goal", + &serde_json::json!({ "kind": "succeeded", "reason": "completed" - }, - "duration_ms": 123, - "total_usd_micros": null - }) + }), + "2026-04-05T12:00:00Z", + ) .to_string(), ); }); diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs index 3e68944e5..b3ffdd7c8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs @@ -2,29 +2,20 @@ use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use serde_json::{Value, json}; -use super::support::setup_local_sandbox_run; +use super::support::{remote_run_summary_json, setup_local_sandbox_run}; use crate::support::unique_run_id; fn remote_run_summary(run_id: &str) -> serde_json::Value { - json!({ - "run_id": run_id, - "workflow_name": "Preview Test", - "workflow_slug": "preview-test", - "goal": "Preview test", - "title": "Preview test", - "labels": {}, - "source_directory": "/srv/repo", - "repository": { "name": "repo" }, - "start_time": "2026-04-19T12:00:00Z", - "created_at": "2026-04-19T12:00:00Z", - "status": { + remote_run_summary_json( + run_id, + "Preview Test", + "preview-test", + "Preview test", + &json!({ "kind": "running" - }, - "pending_control": null, - "duration_ms": null, - "elapsed_secs": null, - "total_usd_micros": null - }) + }), + "2026-04-19T12:00:00Z", + ) } #[test] diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index ec582164d..26a994c3e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -120,26 +120,71 @@ pub(crate) fn mock_resolved_run<'a>( .query_param("selector", selector); then.status(200) .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "run_id": run_id, - "workflow_name": "Nightly Build", - "workflow_slug": "nightly-build", - "goal": "Nightly run", - "title": "Nightly run", - "labels": {}, - "source_directory": null, - "repository": { "name": "unknown" }, - "start_time": "2026-04-05T12:00:00Z", - "created_at": "2026-04-05T12:00:00Z", - "status": { + .json_body(remote_run_summary_json( + run_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ "kind": "succeeded", "reason": "completed" - }, - "pending_control": null, - "duration_ms": 123, - "elapsed_secs": 0, - "total_usd_micros": null - })); + }), + "2026-04-05T12:00:00Z", + )); + }) +} + +pub(crate) fn remote_run_summary_json( + run_id: &str, + workflow_name: &str, + workflow_slug: &str, + goal: &str, + status: &Value, + timestamp: &str, +) -> Value { + serde_json::json!({ + "id": run_id, + "title": goal, + "goal": goal, + "workflow": { + "slug": workflow_slug, + "name": workflow_name + }, + "repository": { + "name": "repo", + "origin_url": null, + "provider": "unknown" + }, + "origin": { + "kind": "api" + }, + "labels": {}, + "lifecycle": { + "status": status, + "pending_control": null, + "queue_position": null, + "error": null, + "archived": false, + "archived_at": null + }, + "models": [], + "source_directory": "/srv/repo", + "timestamps": { + "created_at": timestamp, + "started_at": timestamp, + "last_event_at": null, + "completed_at": null, + "duration_ms": null, + "elapsed_secs": null + }, + "billing": null, + "diff": null, + "pull_request": null, + "current_question": null, + "superseded_by": null, + "links": { + "web": null + } }) } diff --git a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs index a53f72138..bd7044cf7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs @@ -2,7 +2,9 @@ use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use serde_json::Value; -use super::support::{setup_seeded_completed_dry_run, setup_seeded_created_dry_run}; +use super::support::{ + remote_run_summary_json, setup_seeded_completed_dry_run, setup_seeded_created_dry_run, +}; use crate::support::unique_run_id; fn ulid_filter() -> (String, String) { @@ -219,29 +221,17 @@ fn unarchive_resolves_selector_via_server_endpoint() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!({ - "run_id": run_id, - "workflow_name": "Nightly Build", - "workflow_slug": "nightly-build", - "goal": "Nightly run", - "title": "Nightly run", - "labels": {}, - "source_directory": null, - "repository": { "name": "unknown" }, - "start_time": "2026-04-05T12:00:00Z", - "created_at": "2026-04-05T12:00:00Z", - "status": { - "kind": "archived", - "prior": { - "kind": "succeeded", - "reason": "completed" - } - }, - "pending_control": null, - "duration_ms": 123, - "elapsed_secs": 0, - "total_usd_micros": null - }) + remote_run_summary_json( + &run_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + ) .to_string(), ); }); @@ -251,18 +241,17 @@ fn unarchive_resolves_selector_via_server_endpoint() { then.status(200) .header("Content-Type", "application/json") .body( - serde_json::json!({ - "id": run_id, - "status": { + remote_run_summary_json( + &run_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ "kind": "succeeded", "reason": "completed" - }, - "error": null, - "queue_position": null, - "pending_control": null, - "title": "Nightly run", - "created_at": "2026-04-05T12:00:00Z" - }) + }), + "2026-04-05T12:00:00Z", + ) .to_string(), ); }); diff --git a/lib/crates/fabro-cli/tests/it/cmd/wait.rs b/lib/crates/fabro-cli/tests/it/cmd/wait.rs index 423a2f9f5..dffa0b799 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/wait.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/wait.rs @@ -2,25 +2,20 @@ use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use serde_json::json; -use super::support::{setup_seeded_completed_dry_run, setup_seeded_created_dry_run}; +use super::support::{ + remote_run_summary_json, setup_seeded_completed_dry_run, setup_seeded_created_dry_run, +}; use crate::support::{run_projection_json, unique_run_id}; fn remote_run_summary(run_id: &str, status: &serde_json::Value) -> serde_json::Value { - json!({ - "run_id": run_id, - "workflow_name": "Blocked Remote Workflow", - "workflow_slug": "blocked-remote-workflow", - "goal": "Wait for approval", - "title": "Wait for approval", - "labels": {}, - "source_directory": "/srv/repo", - "repository": { "name": "repo" }, - "start_time": "2026-04-19T12:00:00Z", - "created_at": "2026-04-19T12:00:00Z", - "status": status, - "duration_ms": null, - "total_usd_micros": null - }) + remote_run_summary_json( + run_id, + "Blocked Remote Workflow", + "blocked-remote-workflow", + "Wait for approval", + status, + "2026-04-19T12:00:00Z", + ) } #[test] diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 94ba6af2a..ced4bf19a 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -99,12 +99,16 @@ pub(crate) async fn resolve_run( match resolve_run_by_selector( &runs, ¶ms.selector, - |run| run.run_id.to_string(), - |run| run.workflow_slug.clone(), - |run| run.workflow_name.clone(), - |run| run.created_at, - |run| run.created_at.to_rfc3339(), - |run| run.repo_origin_url.clone(), + |run| run.id.to_string(), + |run| run.workflow.slug.clone(), + |run| Some(run.workflow.name.clone()), + |run| run.timestamps.created_at, + |run| run.timestamps.created_at.to_rfc3339(), + |run| { + run.repository + .as_ref() + .and_then(|repository| repository.origin_url.clone()) + }, ) { Ok(run) => (StatusCode::OK, Json(run.clone())).into_response(), Err(ResolveRunError::InvalidSelector | ResolveRunError::AmbiguousPrefix { .. }) => { @@ -455,7 +459,7 @@ pub(crate) async fn get_run_status( ) -> Response { match runs::summaries() .into_iter() - .find(|run| run.run_id.to_string() == id) + .find(|run| run.id.to_string() == id) { Some(run) => (StatusCode::OK, Json(run)).into_response(), None => ApiError::not_found("Run not found.").into_response(), @@ -804,7 +808,7 @@ pub(crate) async fn list_workflow_runs( let runs = runs::summaries() .into_iter() - .filter(|run| run.workflow_slug.as_deref() == Some(&name)) + .filter(|run| run.workflow.slug.as_deref() == Some(&name)) .collect(); paginated_response(runs, &pagination) } @@ -989,7 +993,10 @@ mod runs { RunPrepareSettings, RunSandboxSettings, }; use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace}; - use fabro_types::{RunId, StageId, WorkflowSettings}; + use fabro_types::{ + RepositoryRef, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin, RunTimestamps, + StageId, WorkflowRef, WorkflowSettings, + }; use super::ts; use crate::server::run_stage_from_stage_id; @@ -1036,33 +1043,54 @@ mod runs { ) -> RunSummary { let created_at = ts(created_at); let run_id = RunId::with_timestamp(created_at, sequence); - RunSummary::from_parts(RunParts { - run_id, - workflow_name: Some(workflow_name.into()), - workflow_slug: Some(workflow_slug.into()), - goal: goal.into(), + let source_directory = Some(format!("/demo/{repo_name}")); + let repo_origin_url = Some(format!("https://github.com/demo/{repo_name}.git")); + let duration_ms = elapsed_secs.and_then(duration_ms_from_secs); + RunSummary { + id: run_id, title: fabro_types::infer_run_title(goal), - labels: labels(entries), - source_directory: Some(format!("/demo/{repo_name}")), - repo_origin_url: Some(format!("https://github.com/demo/{repo_name}.git")), + goal: goal.into(), + workflow: WorkflowRef { + slug: Some(workflow_slug.into()), + name: workflow_name.into(), + }, + automation: None, + repository: Some(RepositoryRef::from_origin_and_source( + repo_origin_url, + source_directory.as_deref(), + )), created_by: None, - start_time: Some(created_at), - last_event_at: Some(created_at), - completed_at: Some(created_at), - status: parse_run_status(status, status_reason) - .unwrap_or_else(|| panic!("invalid demo run status: {status}")), - pending_control, - duration_ms: elapsed_secs.and_then(duration_ms_from_secs), - total_usd_micros, - superseded_by: None, - diff_summary: None, - pull_request: None, - archived_at: None, + origin: RunOrigin::default(), + labels: labels(entries), + lifecycle: RunLifecycle { + status: parse_run_status(status, status_reason) + .unwrap_or_else(|| panic!("invalid demo run status: {status}")), + pending_control, + queue_position: None, + error: None, + archived: false, + archived_at: None, + }, sandbox: None, models: Vec::new(), + source_directory, + timestamps: RunTimestamps { + created_at, + started_at: Some(created_at), + last_event_at: Some(created_at), + completed_at: Some(created_at), + duration_ms, + elapsed_secs, + }, + billing: total_usd_micros.map(|total_usd_micros| RunBillingSummary { + total_usd_micros: Some(total_usd_micros), + }), + diff: None, + pull_request: None, current_question: None, - web_url: None, - }) + superseded_by: None, + links: RunLinks { web: None }, + } } fn parse_run_status(status: &str, status_reason: Option<&str>) -> Option { @@ -1678,7 +1706,7 @@ mod runs { &[], ); - assert_eq!(summary.status, RunStatus::Failed { + assert_eq!(summary.lifecycle.status, RunStatus::Failed { reason: FailureReason::Cancelled, }); } @@ -1700,7 +1728,7 @@ mod runs { &[], ); - assert_eq!(summary.status, RunStatus::Failed { + assert_eq!(summary.lifecycle.status, RunStatus::Failed { reason: FailureReason::WorkflowError, }); } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index f71878142..94158ea8a 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2307,7 +2307,7 @@ async fn load_pending_control( .runs() .find(&run_id) .await? - .and_then(|summary| summary.pending_control)) + .and_then(|summary| summary.lifecycle.pending_control)) } fn fail_managed_run(state: &Arc, run_id: RunId, reason: FailureReason, message: String) { diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index 2be84bf3c..bb433a271 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -281,12 +281,16 @@ async fn resolve_run( match resolve_run_by_selector( &runs, &query.selector, - |run| run.run_id.to_string(), - |run| run.workflow_slug.clone(), - |run| run.workflow_name.clone(), - |run| run.run_id.created_at(), - |run| run.run_id.created_at().to_rfc3339(), - |run| run.repo_origin_url.clone(), + |run| run.id.to_string(), + |run| run.workflow.slug.clone(), + |run| Some(run.workflow.name.clone()), + |run| run.id.created_at(), + |run| run.id.created_at().to_rfc3339(), + |run| { + run.repository + .as_ref() + .and_then(|repository| repository.origin_url.clone()) + }, ) { Ok(run) => (StatusCode::OK, Json(run.clone())).into_response(), Err(err @ (ResolveRunError::InvalidSelector | ResolveRunError::AmbiguousPrefix { .. })) => { diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 8317f1b56..867be6ba6 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -8143,7 +8143,10 @@ async fn cancel_run_overwrites_pending_pause_request() { assert_eq!(run_json_pending_control(&body).as_str(), Some("cancel")); let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); - assert_eq!(summary.pending_control, Some(RunControlAction::Cancel)); + assert_eq!( + summary.lifecycle.pending_control, + Some(RunControlAction::Cancel) + ); } #[tokio::test] @@ -8172,7 +8175,10 @@ async fn pause_run_rejects_when_control_is_already_pending() { assert_status!(response, StatusCode::CONFLICT).await; let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); - assert_eq!(summary.pending_control, Some(RunControlAction::Cancel)); + assert_eq!( + summary.lifecycle.pending_control, + Some(RunControlAction::Cancel) + ); } #[tokio::test] @@ -8290,10 +8296,10 @@ async fn pause_run_immediately_pauses_blocked_run() { assert_eq!(run_json_pending_control(&body), &serde_json::Value::Null); let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); - assert_eq!(summary.status, RunStatus::Paused { + assert_eq!(summary.lifecycle.status, RunStatus::Paused { prior_block: Some(BlockedReason::HumanInputRequired), }); - assert_eq!(summary.pending_control, None); + assert_eq!(summary.lifecycle.pending_control, None); } #[tokio::test] @@ -8321,7 +8327,10 @@ async fn unpause_run_sets_pending_control() { assert_eq!(run_json_pending_control(&body).as_str(), Some("unpause")); let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); - assert_eq!(summary.pending_control, Some(RunControlAction::Unpause)); + assert_eq!( + summary.lifecycle.pending_control, + Some(RunControlAction::Unpause) + ); } #[tokio::test] @@ -8396,10 +8405,10 @@ async fn unpause_run_returns_blocked_when_human_gate_is_still_unresolved() { assert_eq!(run_json_pending_control(&body), &serde_json::Value::Null); let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); - assert_eq!(summary.status, RunStatus::Blocked { + assert_eq!(summary.lifecycle.status, RunStatus::Blocked { blocked_reason: BlockedReason::HumanInputRequired, }); - assert_eq!(summary.pending_control, None); + assert_eq!(summary.lifecycle.pending_control, None); } #[tokio::test] diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 6993bd86f..d1bdf7b2a 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -10,9 +10,10 @@ use fabro_types::settings::run::RunSandboxSettings; use fabro_types::{ BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination, Conclusion, EventBody, FailureSignature, InterviewQuestionRecord, Outcome, PendingInterviewRecord, PullRequestRecord, - RunControlAction, RunDiff, RunEvent, RunId, RunModel, RunParts, RunProjection, RunSandbox, - RunSandboxRuntime, RunSpec, RunStatus, RunSummary, SandboxProvider, StageCompletion, - StageHandler, StageId, StageOutcome, StageProjection, StageState, StartRecord, first_event_seq, + RepositoryRef, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, + RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox, RunSandboxRuntime, RunSpec, + RunStatus, RunSummary, RunTimestamps, SandboxProvider, StageCompletion, StageHandler, StageId, + StageOutcome, StageProjection, StageState, StartRecord, WorkflowRef, first_event_seq, }; use fabro_util::error::render_with_causes; use serde_json::Value; @@ -566,11 +567,11 @@ fn stage_at_completed_visit<'a>( } pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary { - let workflow_name = Some(if state.spec.graph.name.is_empty() { + let workflow_name = if state.spec.graph.name.is_empty() { "unnamed".to_string() } else { state.spec.graph.name.clone() - }); + }; let goal = state.spec.graph.goal().to_string(); let diff_summary = state .conclusion @@ -599,43 +600,69 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary .provenance .as_ref() .and_then(|provenance| provenance.subject.clone()); + let source_directory = state.spec.source_directory.clone(); + let repo_origin_url = state.spec.git.as_ref().map(|git| git.origin_url.clone()); + let start_time = state.start.as_ref().map(|start| start.start_time); + let completed_at = state + .conclusion + .as_ref() + .map(|conclusion| conclusion.timestamp); + let duration_ms = state + .conclusion + .as_ref() + .map(|conclusion| conclusion.duration_ms); + let total_usd_micros = state + .conclusion + .as_ref() + .and_then(|conclusion| conclusion.billing.as_ref()) + .and_then(|billing| billing.total_usd_micros); - RunSummary::from_parts(RunParts { - run_id: *run_id, - workflow_name, - workflow_slug: state.spec.workflow_slug.clone(), - goal, + RunSummary { + id: *run_id, title: state.title().into_owned(), - labels: state.spec.labels.clone(), - source_directory: state.spec.source_directory.clone(), - repo_origin_url: state.spec.git.as_ref().map(|git| git.origin_url.clone()), + goal, + workflow: WorkflowRef { + slug: state.spec.workflow_slug.clone(), + name: workflow_name, + }, + automation: None, + repository: Some(RepositoryRef::from_origin_and_source( + repo_origin_url, + source_directory.as_deref(), + )), created_by, - start_time: state.start.as_ref().map(|start| start.start_time), - last_event_at: Some(state.last_event_at), - completed_at: state - .conclusion - .as_ref() - .map(|conclusion| conclusion.timestamp), - status: state.status, - pending_control: state.pending_control, - duration_ms: state - .conclusion - .as_ref() - .map(|conclusion| conclusion.duration_ms), - total_usd_micros: state - .conclusion - .as_ref() - .and_then(|conclusion| conclusion.billing.as_ref()) - .and_then(|billing| billing.total_usd_micros), - superseded_by: state.superseded_by, - diff_summary, - pull_request: state.pull_request.clone(), - archived_at: state.archived_at, + origin: RunOrigin::default(), + labels: state.spec.labels.clone(), + lifecycle: RunLifecycle { + status: state.status, + pending_control: state.pending_control, + queue_position: None, + error: None, + archived: state.archived_at.is_some(), + archived_at: state.archived_at, + }, sandbox: state.sandbox.clone(), models, + source_directory, + timestamps: RunTimestamps { + created_at: run_id.created_at(), + started_at: start_time, + last_event_at: Some(state.last_event_at), + completed_at, + duration_ms, + elapsed_secs: elapsed_secs(duration_ms), + }, + billing: total_usd_micros.map(|total_usd_micros| RunBillingSummary { + total_usd_micros: Some(total_usd_micros), + }), + diff: diff_summary, + pull_request: state.pull_request.clone(), current_question, - web_url: state.web_url.clone(), - }) + superseded_by: state.superseded_by, + links: RunLinks { + web: state.web_url.clone(), + }, + } } fn run_models(state: &RunProjection) -> Vec { @@ -656,6 +683,10 @@ fn run_models(state: &RunProjection) -> Vec { models } +fn elapsed_secs(duration_ms: Option) -> Option { + duration_ms.map(|ms| ms as f64 / 1000.0) +} + fn checkpoint_from_props(props: &CheckpointCompletedProps, timestamp: DateTime) -> Checkpoint { let loop_failure_signatures = props .loop_failure_signatures diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index c422ee244..7e90f2603 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -592,11 +592,11 @@ mod tests { let summary = store.list_runs(&ListRunsQuery::default()).await.unwrap(); assert_eq!(summary.len(), 2); - assert_eq!(summary[0].run_id, test_run_id("run-2")); - assert_eq!(summary[1].run_id, test_run_id("run-1")); - assert_eq!(summary[1].workflow_name, Some("night-sky".to_string())); + assert_eq!(summary[0].id, test_run_id("run-2")); + assert_eq!(summary[1].id, test_run_id("run-1")); + assert_eq!(summary[1].workflow.name, "night-sky"); assert_eq!(summary[1].goal, "map the constellations"); - assert_eq!(summary[1].status, RunStatus::Succeeded { + assert_eq!(summary[1].lifecycle.status, RunStatus::Succeeded { reason: SuccessReason::Completed, }); @@ -608,7 +608,7 @@ mod tests { assert!(store.open_run(&test_run_id("run-1")).await.is_err()); let remaining = store.list_runs(&ListRunsQuery::default()).await.unwrap(); assert_eq!(remaining.len(), 1); - assert_eq!(remaining[0].run_id, test_run_id("run-2")); + assert_eq!(remaining[0].id, test_run_id("run-2")); assert!(!list_paths(object_store, "runs/").await.is_empty()); } @@ -666,8 +666,11 @@ mod tests { let summary = store.list_runs(&ListRunsQuery::default()).await.unwrap(); assert_eq!(summary.len(), 1); - assert_eq!(summary[0].status, RunStatus::Running); - assert_eq!(summary[0].pending_control, Some(RunControlAction::Pause)); + assert_eq!(summary[0].lifecycle.status, RunStatus::Running); + assert_eq!( + summary[0].lifecycle.pending_control, + Some(RunControlAction::Pause) + ); } #[tokio::test] @@ -731,10 +734,10 @@ mod tests { let summary = store.list_runs(&ListRunsQuery::default()).await.unwrap(); assert_eq!(summary.len(), 1); - assert_eq!(summary[0].status, RunStatus::Failed { + assert_eq!(summary[0].lifecycle.status, RunStatus::Failed { reason: FailureReason::Cancelled, }); - assert_eq!(summary[0].pending_control, None); + assert_eq!(summary[0].lifecycle.pending_control, None); } #[tokio::test] @@ -792,8 +795,8 @@ mod tests { let reopened = Database::new(object_store, "runs", Duration::from_millis(1), None); let summary = reopened.list_runs(&ListRunsQuery::default()).await.unwrap(); assert_eq!(summary.len(), 1); - assert_eq!(summary[0].run_id, test_run_id("run-1")); - assert_eq!(summary[0].status, RunStatus::Succeeded { + assert_eq!(summary[0].id, test_run_id("run-1")); + assert_eq!(summary[0].lifecycle.status, RunStatus::Succeeded { reason: SuccessReason::Completed, }); } @@ -817,7 +820,7 @@ mod tests { entries.iter().map(|entry| entry.run_id).collect::>(), vec![test_run_id("run-2"), test_run_id("run-1")] ); - assert_eq!(entries[0].summary.status, RunStatus::Running); + assert_eq!(entries[0].summary.lifecycle.status, RunStatus::Running); assert_eq!(entries[0].projection.spec().run_id, test_run_id("run-2")); assert_eq!(entries[0].last_seq, 3); @@ -836,7 +839,7 @@ mod tests { .await .unwrap() .unwrap(); - assert_eq!(cached.summary.status, RunStatus::Succeeded { + assert_eq!(cached.summary.lifecycle.status, RunStatus::Succeeded { reason: SuccessReason::Completed, }); } @@ -1021,7 +1024,7 @@ mod tests { .await .unwrap() .unwrap(); - assert_eq!(cached.summary.status, RunStatus::Running); + assert_eq!(cached.summary.lifecycle.status, RunStatus::Running); assert_eq!(cached.last_seq, 6); assert_eq!( cached @@ -1152,7 +1155,7 @@ mod tests { let cached = reopened.get_cached_run(&run_id).await.unwrap().unwrap(); assert_eq!(cached.summary.title, "Renamed failed run"); - assert_eq!(cached.summary.status, RunStatus::Failed { + assert_eq!(cached.summary.lifecycle.status, RunStatus::Failed { reason: FailureReason::WorkflowError, }); } diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 86f623dfa..dcac895f2 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -83,7 +83,7 @@ pub use run_projection::{ pub use run_sandbox::{RunSandbox, RunSandboxRuntime}; pub use run_summary::{ AutomationRef, Run, RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin, - RunOriginKind, RunParts, RunTimestamps, WorkflowRef, + RunOriginKind, RunTimestamps, WorkflowRef, }; pub type RunSummary = Run; pub type PullRequestRecord = PullRequest; diff --git a/lib/crates/fabro-types/src/repository.rs b/lib/crates/fabro-types/src/repository.rs index 526a33d4e..75167296d 100644 --- a/lib/crates/fabro-types/src/repository.rs +++ b/lib/crates/fabro-types/src/repository.rs @@ -8,6 +8,19 @@ pub struct RepositoryRef { pub provider: RepositoryProvider, } +impl RepositoryRef { + pub fn from_origin_and_source( + origin_url: Option, + source_directory: Option<&str>, + ) -> Self { + Self { + name: repository_name(origin_url.as_deref(), source_directory), + provider: repository_provider(origin_url.as_deref()), + origin_url, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RepositoryProvider { @@ -15,3 +28,66 @@ pub enum RepositoryProvider { Git, Unknown, } + +fn repository_provider(origin_url: Option<&str>) -> RepositoryProvider { + let Some(origin) = origin_url.filter(|origin| !origin.trim().is_empty()) else { + return RepositoryProvider::Unknown; + }; + if is_github_origin(origin) { + RepositoryProvider::Github + } else { + RepositoryProvider::Git + } +} + +fn is_github_origin(origin: &str) -> bool { + origin.starts_with("git@github.com:") + || origin.starts_with("https://github.com/") + || origin.starts_with("http://github.com/") + || origin.starts_with("ssh://git@github.com/") +} + +fn repository_name(origin_url: Option<&str>, source_directory: Option<&str>) -> String { + origin_url + .and_then(repository_name_from_origin) + .or_else(|| { + source_directory + .and_then(path_basename) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +#[expect( + clippy::disallowed_types, + reason = "Run summaries parse the origin only to extract an owner/repo label; raw URLs are not logged." +)] +fn repository_name_from_origin(origin: &str) -> Option { + if let Some(path) = origin + .strip_prefix("git@") + .and_then(|url| url.split_once(':').map(|(_, path)| path)) + { + return repository_name_from_path(path).map(ToOwned::to_owned); + } + + let parsed = url::Url::parse(origin).ok()?; + let path = parsed.path().trim_matches('/'); + repository_name_from_path(path).map(ToOwned::to_owned) +} + +fn repository_name_from_path(path: &str) -> Option<&str> { + let stripped = path.strip_suffix(".git").unwrap_or(path); + let mut segments = stripped.rsplit('/').filter(|segment| !segment.is_empty()); + let repo = segments.next()?; + let owner = segments.next(); + if let Some(owner) = owner { + let start = stripped.len() - owner.len() - repo.len() - 1; + stripped.get(start..) + } else { + Some(repo) + } +} + +fn path_basename(path: &str) -> Option<&str> { + path.rsplit(['/', '\\']).find(|segment| !segment.is_empty()) +} diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index e3f9f7a5f..f58c1b936 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -1,15 +1,14 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; -use serde::de::Error; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use crate::{ - DiffSummary, InterviewQuestionRecord, Principal, PullRequest, RepositoryProvider, - RepositoryRef, RunControlAction, RunId, RunSandbox, RunStatus, SuccessReason, + DiffSummary, InterviewQuestionRecord, Principal, PullRequest, RepositoryRef, RunControlAction, + RunId, RunSandbox, RunStatus, }; -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Run { pub id: RunId, pub title: String, @@ -41,273 +40,6 @@ pub struct Run { #[serde(default)] pub superseded_by: Option, pub links: RunLinks, - #[serde(skip, default = "RunId::new")] - pub run_id: RunId, - #[serde(skip)] - pub workflow_name: Option, - #[serde(skip)] - pub workflow_slug: Option, - #[serde(skip)] - pub repo_origin_url: Option, - #[serde(skip)] - pub start_time: Option>, - #[serde(skip, default = "Utc::now")] - pub created_at: DateTime, - #[serde(skip)] - pub last_event_at: Option>, - #[serde(skip, default = "default_run_status")] - pub status: RunStatus, - #[serde(skip)] - pub pending_control: Option, - #[serde(skip)] - pub duration_ms: Option, - #[serde(skip)] - pub elapsed_secs: Option, - #[serde(skip)] - pub total_usd_micros: Option, - #[serde(skip)] - pub diff_summary: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct RunParts { - pub run_id: RunId, - pub workflow_name: Option, - pub workflow_slug: Option, - pub goal: String, - pub title: String, - pub labels: HashMap, - pub source_directory: Option, - pub repo_origin_url: Option, - pub created_by: Option, - pub start_time: Option>, - pub last_event_at: Option>, - pub completed_at: Option>, - pub status: RunStatus, - pub pending_control: Option, - pub duration_ms: Option, - pub total_usd_micros: Option, - pub superseded_by: Option, - pub diff_summary: Option, - pub pull_request: Option, - pub archived_at: Option>, - pub sandbox: Option, - pub models: Vec, - pub current_question: Option, - pub web_url: Option, -} - -#[derive(Debug, Deserialize)] -struct RunWire { - #[serde(default)] - id: Option, - #[serde(default)] - run_id: Option, - #[serde(default)] - title: Option, - #[serde(default)] - goal: Option, - #[serde(default)] - workflow: Option, - #[serde(default)] - workflow_name: Option, - #[serde(default)] - workflow_slug: Option, - #[serde(default)] - automation: Option, - #[serde(default)] - repository: Option, - #[serde(default)] - repo_origin_url: Option, - #[serde(default)] - created_by: Option, - #[serde(default)] - origin: Option, - #[serde(default)] - labels: HashMap, - #[serde(default)] - lifecycle: Option, - #[serde(default)] - status: Option, - #[serde(default)] - pending_control: Option, - #[serde(default)] - archived_at: Option>, - #[serde(default)] - sandbox: Option, - #[serde(default)] - models: Vec, - #[serde(default)] - source_directory: Option, - #[serde(default)] - timestamps: Option, - #[serde(default)] - start_time: Option>, - #[serde(default)] - created_at: Option>, - #[serde(default)] - last_event_at: Option>, - #[serde(default)] - duration_ms: Option, - #[serde(default)] - elapsed_secs: Option, - #[serde(default)] - billing: Option, - #[serde(default)] - total_usd_micros: Option, - #[serde(default)] - diff: Option, - #[serde(default)] - diff_summary: Option, - #[serde(default)] - pull_request: Option, - #[serde(default)] - current_question: Option, - #[serde(default)] - superseded_by: Option, - #[serde(default)] - links: Option, -} - -#[derive(Debug, Deserialize)] -struct RepositoryRefWire { - name: String, - #[serde(default)] - origin_url: Option, - #[serde(default)] - provider: Option, -} - -impl From for RepositoryRef { - fn from(value: RepositoryRefWire) -> Self { - Self { - name: value.name, - provider: value - .provider - .unwrap_or_else(|| repository_provider(value.origin_url.as_deref())), - origin_url: value.origin_url, - } - } -} - -fn legacy_status( - value: Option, -) -> Result<(Option, bool), serde_json::Error> { - let Some(value) = value else { - return Ok((None, false)); - }; - if value.get("kind").and_then(serde_json::Value::as_str) == Some("archived") { - let status = match value.get("prior") { - Some(prior) => serde_json::from_value(prior.clone())?, - None => RunStatus::Succeeded { - reason: SuccessReason::Completed, - }, - }; - return Ok((Some(status), true)); - } - serde_json::from_value(value).map(|status| (Some(status), false)) -} - -impl<'de> Deserialize<'de> for Run { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let wire = RunWire::deserialize(deserializer)?; - let id = wire - .id - .or(wire.run_id) - .ok_or_else(|| D::Error::missing_field("id"))?; - let goal = wire.goal.unwrap_or_else(|| { - wire.title - .clone() - .unwrap_or_else(|| "Untitled run".to_string()) - }); - let workflow = wire.workflow.unwrap_or_else(|| WorkflowRef { - slug: wire.workflow_slug, - name: wire.workflow_name.unwrap_or_else(|| "unnamed".to_string()), - }); - let repository = wire.repository.map(Into::into).or_else(|| { - Some(repository_ref( - wire.repo_origin_url.as_deref(), - wire.source_directory.as_deref(), - )) - }); - let repo_origin_url = repository - .as_ref() - .and_then(|repository: &RepositoryRef| repository.origin_url.clone()) - .or(wire.repo_origin_url); - let (legacy_status, legacy_archived) = - legacy_status(wire.status).map_err(D::Error::custom)?; - let lifecycle = wire.lifecycle.unwrap_or_else(|| RunLifecycle { - status: legacy_status.unwrap_or_else(default_run_status), - pending_control: wire.pending_control, - queue_position: None, - error: None, - archived: wire.archived_at.is_some() || legacy_archived, - archived_at: wire.archived_at, - }); - let timestamps = wire.timestamps.unwrap_or_else(|| { - let created_at = wire.created_at.unwrap_or_else(|| id.created_at()); - RunTimestamps { - created_at, - started_at: wire.start_time, - last_event_at: wire.last_event_at, - completed_at: None, - duration_ms: wire.duration_ms, - elapsed_secs: wire.elapsed_secs.or_else(|| elapsed_secs(wire.duration_ms)), - } - }); - let total_usd_micros = wire - .billing - .as_ref() - .and_then(|billing| billing.total_usd_micros) - .or(wire.total_usd_micros); - let diff = wire.diff.or(wire.diff_summary); - let title = wire.title.unwrap_or_else(|| crate::infer_run_title(&goal)); - let workflow_name = Some(workflow.name.clone()); - let workflow_slug = workflow.slug.clone(); - - Ok(Self { - id, - title, - goal, - workflow, - automation: wire.automation, - repository, - created_by: wire.created_by, - origin: wire.origin.unwrap_or_default(), - labels: wire.labels, - status: lifecycle.status, - pending_control: lifecycle.pending_control, - lifecycle, - sandbox: wire.sandbox, - models: wire.models, - source_directory: wire.source_directory, - start_time: timestamps.started_at, - created_at: timestamps.created_at, - last_event_at: timestamps.last_event_at, - duration_ms: timestamps.duration_ms, - elapsed_secs: timestamps.elapsed_secs, - timestamps, - total_usd_micros, - billing: wire.billing.or_else(|| { - total_usd_micros.map(|total_usd_micros| RunBillingSummary { - total_usd_micros: Some(total_usd_micros), - }) - }), - diff_summary: diff, - diff, - pull_request: wire.pull_request, - current_question: wire.current_question, - superseded_by: wire.superseded_by, - links: wire.links.unwrap_or(RunLinks { web: None }), - run_id: id, - workflow_name, - workflow_slug, - repo_origin_url, - }) - } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -395,177 +127,3 @@ pub struct RunLinks { #[serde(default)] pub web: Option, } - -impl Run { - pub fn from_parts(parts: RunParts) -> Self { - let RunParts { - run_id, - workflow_name, - workflow_slug, - goal, - title, - labels, - source_directory, - repo_origin_url, - created_by, - start_time, - last_event_at, - completed_at, - status, - pending_control, - duration_ms, - total_usd_micros, - superseded_by, - diff_summary, - pull_request, - archived_at, - sandbox, - models, - current_question, - web_url, - } = parts; - let created_at = run_id.created_at(); - let repository = Some(repository_ref( - repo_origin_url.as_deref(), - source_directory.as_deref(), - )); - let elapsed_secs = elapsed_secs(duration_ms); - let billing = total_usd_micros.map(|total_usd_micros| RunBillingSummary { - total_usd_micros: Some(total_usd_micros), - }); - let workflow_name_for_compat = workflow_name.unwrap_or_else(|| "unnamed".to_string()); - let workflow_slug_for_compat = workflow_slug.clone(); - - Self { - id: run_id, - title, - goal, - workflow: WorkflowRef { - slug: workflow_slug, - name: workflow_name_for_compat.clone(), - }, - automation: None, - repository, - created_by, - origin: RunOrigin::default(), - labels, - lifecycle: RunLifecycle { - status, - pending_control, - queue_position: None, - error: None, - archived: archived_at.is_some(), - archived_at, - }, - sandbox, - models, - source_directory, - timestamps: RunTimestamps { - created_at, - started_at: start_time, - last_event_at, - completed_at, - duration_ms, - elapsed_secs, - }, - billing, - diff: diff_summary, - pull_request, - current_question, - superseded_by, - links: RunLinks { web: web_url }, - run_id, - workflow_name: Some(workflow_name_for_compat.clone()), - workflow_slug: workflow_slug_for_compat, - repo_origin_url, - start_time, - created_at, - last_event_at, - status, - pending_control, - duration_ms, - elapsed_secs, - total_usd_micros, - diff_summary, - } - } -} - -fn default_run_status() -> RunStatus { - RunStatus::Submitted -} - -fn repository_ref(repo_origin_url: Option<&str>, source_directory: Option<&str>) -> RepositoryRef { - RepositoryRef { - name: repository_name(repo_origin_url, source_directory), - origin_url: repo_origin_url.map(ToOwned::to_owned), - provider: repository_provider(repo_origin_url), - } -} - -fn repository_provider(repo_origin_url: Option<&str>) -> RepositoryProvider { - let Some(origin) = repo_origin_url.filter(|origin| !origin.trim().is_empty()) else { - return RepositoryProvider::Unknown; - }; - if is_github_origin(origin) { - RepositoryProvider::Github - } else { - RepositoryProvider::Git - } -} - -fn is_github_origin(origin: &str) -> bool { - origin.starts_with("git@github.com:") - || origin.starts_with("https://github.com/") - || origin.starts_with("http://github.com/") - || origin.starts_with("ssh://git@github.com/") -} - -fn repository_name(repo_origin_url: Option<&str>, source_directory: Option<&str>) -> String { - repo_origin_url - .and_then(repository_name_from_origin) - .or_else(|| { - source_directory - .and_then(path_basename) - .map(ToOwned::to_owned) - }) - .unwrap_or_else(|| "unknown".to_string()) -} - -#[expect( - clippy::disallowed_types, - reason = "Run summaries parse the origin only to extract an owner/repo label; raw URLs are not logged." -)] -fn repository_name_from_origin(origin: &str) -> Option { - if let Some(path) = origin - .strip_prefix("git@") - .and_then(|url| url.split_once(':').map(|(_, path)| path)) - { - return repository_name_from_path(path).map(ToOwned::to_owned); - } - - let parsed = url::Url::parse(origin).ok()?; - let path = parsed.path().trim_matches('/'); - repository_name_from_path(path).map(ToOwned::to_owned) -} - -fn repository_name_from_path(path: &str) -> Option<&str> { - let stripped = path.strip_suffix(".git").unwrap_or(path); - let mut segments = stripped.rsplit('/').filter(|segment| !segment.is_empty()); - let repo = segments.next()?; - let owner = segments.next(); - if let Some(owner) = owner { - let start = stripped.len() - owner.len() - repo.len() - 1; - stripped.get(start..) - } else { - Some(repo) - } -} - -fn path_basename(path: &str) -> Option<&str> { - path.rsplit(['/', '\\']).find(|segment| !segment.is_empty()) -} - -fn elapsed_secs(duration_ms: Option) -> Option { - duration_ms.map(|ms| ms as f64 / 1000.0) -} diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index f02c2d0b6..50e5bd39c 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -95,7 +95,7 @@ fn load_run_checkpoint(run_dir: &Path) -> Result Result Result Result