From b5101bbde31f2a8eb08aa703a22499b664bfdbc9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 10 May 2026 23:29:41 -0400 Subject: [PATCH] refactor(types): remove legacy run summary shape Use the canonical nested Run DTO directly and reject the old flat run summary JSON shape. Update store, server, CLI, and fixtures to read and produce canonical fields. --- lib/crates/fabro-api/src/lib.rs | 2 +- .../fabro-api/tests/run_summary_round_trip.rs | 90 ++-- .../fabro-cli/src/commands/artifact/mod.rs | 2 +- lib/crates/fabro-cli/src/commands/dump.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 2 +- lib/crates/fabro-cli/src/commands/run/cp.rs | 2 +- lib/crates/fabro-cli/src/commands/run/diff.rs | 2 +- .../fabro-cli/src/commands/run/events.rs | 2 +- lib/crates/fabro-cli/src/commands/run/fork.rs | 2 +- lib/crates/fabro-cli/src/commands/run/logs.rs | 2 +- lib/crates/fabro-cli/src/commands/run/mod.rs | 4 +- .../fabro-cli/src/commands/run/preview.rs | 2 +- .../fabro-cli/src/commands/run/resume.rs | 2 +- .../fabro-cli/src/commands/run/rewind.rs | 2 +- lib/crates/fabro-cli/src/commands/run/ssh.rs | 2 +- .../fabro-cli/src/commands/run/steer.rs | 2 +- lib/crates/fabro-cli/src/commands/run/wait.rs | 4 +- .../fabro-cli/src/commands/runs/archive.rs | 2 +- lib/crates/fabro-cli/src/commands/runs/rm.rs | 2 +- lib/crates/fabro-cli/src/server_runs.rs | 28 +- lib/crates/fabro-cli/tests/it/cmd/archive.rs | 57 +-- lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 30 +- lib/crates/fabro-cli/tests/it/cmd/ps.rs | 68 ++- lib/crates/fabro-cli/tests/it/cmd/rm.rs | 55 +-- .../fabro-cli/tests/it/cmd/sandbox_preview.rs | 29 +- lib/crates/fabro-cli/tests/it/cmd/support.rs | 81 +++- .../fabro-cli/tests/it/cmd/unarchive.rs | 57 +-- lib/crates/fabro-cli/tests/it/cmd/wait.rs | 27 +- lib/crates/fabro-server/src/demo/mod.rs | 94 ++-- lib/crates/fabro-server/src/server.rs | 2 +- .../fabro-server/src/server/handler/runs.rs | 16 +- lib/crates/fabro-server/src/server/tests.rs | 23 +- lib/crates/fabro-store/src/run_state.rs | 103 ++-- lib/crates/fabro-store/src/slate/mod.rs | 33 +- lib/crates/fabro-types/src/lib.rs | 2 +- lib/crates/fabro-types/src/repository.rs | 76 +++ lib/crates/fabro-types/src/run_summary.rs | 450 +----------------- .../tests/it/daytona_integration.rs | 4 +- .../fabro-workflow/tests/it/integration.rs | 6 +- 39 files changed, 548 insertions(+), 823 deletions(-) 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