mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
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.
This commit is contained in:
parent
4fa4716015
commit
b5101bbde3
39 changed files with 548 additions and 823 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::<RunSummary>(json!({
|
||||
"run_id": run_id.to_string(),
|
||||
"workflow_name": "legacy",
|
||||
"status": {
|
||||
"kind": "running"
|
||||
}
|
||||
}));
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ pub(super) async fn resolve_artifacts(
|
|||
) -> Result<(RunId, Client, Vec<ArtifactEntry>)> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,6 @@ async fn resolve_run_for_pr(
|
|||
) -> Result<(CommandContext, Arc<Client>, 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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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, _) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<DateTime<Utc>> {
|
||||
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<String, String> {
|
||||
|
|
@ -53,11 +51,14 @@ impl ServerRunSummaryInfo {
|
|||
}
|
||||
|
||||
pub(crate) fn duration_ms(&self) -> Option<u64> {
|
||||
self.summary.duration_ms
|
||||
self.summary.timestamps.duration_ms
|
||||
}
|
||||
|
||||
pub(crate) fn total_usd_micros(&self) -> Option<i64> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<RunStatus> {
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppState>, run_id: RunId, reason: FailureReason, message: String) {
|
||||
|
|
|
|||
|
|
@ -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 { .. })) => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<RunModel> {
|
||||
|
|
@ -656,6 +683,10 @@ fn run_models(state: &RunProjection) -> Vec<RunModel> {
|
|||
models
|
||||
}
|
||||
|
||||
fn elapsed_secs(duration_ms: Option<u64>) -> Option<f64> {
|
||||
duration_ms.map(|ms| ms as f64 / 1000.0)
|
||||
}
|
||||
|
||||
fn checkpoint_from_props(props: &CheckpointCompletedProps, timestamp: DateTime<Utc>) -> Checkpoint {
|
||||
let loop_failure_signatures = props
|
||||
.loop_failure_signatures
|
||||
|
|
|
|||
|
|
@ -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<_>>(),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,19 @@ pub struct RepositoryRef {
|
|||
pub provider: RepositoryProvider,
|
||||
}
|
||||
|
||||
impl RepositoryRef {
|
||||
pub fn from_origin_and_source(
|
||||
origin_url: Option<String>,
|
||||
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<String> {
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RunId>,
|
||||
pub links: RunLinks,
|
||||
#[serde(skip, default = "RunId::new")]
|
||||
pub run_id: RunId,
|
||||
#[serde(skip)]
|
||||
pub workflow_name: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub workflow_slug: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub repo_origin_url: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub start_time: Option<DateTime<Utc>>,
|
||||
#[serde(skip, default = "Utc::now")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(skip)]
|
||||
pub last_event_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip, default = "default_run_status")]
|
||||
pub status: RunStatus,
|
||||
#[serde(skip)]
|
||||
pub pending_control: Option<RunControlAction>,
|
||||
#[serde(skip)]
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(skip)]
|
||||
pub elapsed_secs: Option<f64>,
|
||||
#[serde(skip)]
|
||||
pub total_usd_micros: Option<i64>,
|
||||
#[serde(skip)]
|
||||
pub diff_summary: Option<DiffSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RunParts {
|
||||
pub run_id: RunId,
|
||||
pub workflow_name: Option<String>,
|
||||
pub workflow_slug: Option<String>,
|
||||
pub goal: String,
|
||||
pub title: String,
|
||||
pub labels: HashMap<String, String>,
|
||||
pub source_directory: Option<String>,
|
||||
pub repo_origin_url: Option<String>,
|
||||
pub created_by: Option<Principal>,
|
||||
pub start_time: Option<DateTime<Utc>>,
|
||||
pub last_event_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub status: RunStatus,
|
||||
pub pending_control: Option<RunControlAction>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub total_usd_micros: Option<i64>,
|
||||
pub superseded_by: Option<RunId>,
|
||||
pub diff_summary: Option<DiffSummary>,
|
||||
pub pull_request: Option<PullRequest>,
|
||||
pub archived_at: Option<DateTime<Utc>>,
|
||||
pub sandbox: Option<RunSandbox>,
|
||||
pub models: Vec<RunModel>,
|
||||
pub current_question: Option<InterviewQuestionRecord>,
|
||||
pub web_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RunWire {
|
||||
#[serde(default)]
|
||||
id: Option<RunId>,
|
||||
#[serde(default)]
|
||||
run_id: Option<RunId>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
goal: Option<String>,
|
||||
#[serde(default)]
|
||||
workflow: Option<WorkflowRef>,
|
||||
#[serde(default)]
|
||||
workflow_name: Option<String>,
|
||||
#[serde(default)]
|
||||
workflow_slug: Option<String>,
|
||||
#[serde(default)]
|
||||
automation: Option<AutomationRef>,
|
||||
#[serde(default)]
|
||||
repository: Option<RepositoryRefWire>,
|
||||
#[serde(default)]
|
||||
repo_origin_url: Option<String>,
|
||||
#[serde(default)]
|
||||
created_by: Option<Principal>,
|
||||
#[serde(default)]
|
||||
origin: Option<RunOrigin>,
|
||||
#[serde(default)]
|
||||
labels: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
lifecycle: Option<RunLifecycle>,
|
||||
#[serde(default)]
|
||||
status: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pending_control: Option<RunControlAction>,
|
||||
#[serde(default)]
|
||||
archived_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
sandbox: Option<RunSandbox>,
|
||||
#[serde(default)]
|
||||
models: Vec<RunModel>,
|
||||
#[serde(default)]
|
||||
source_directory: Option<String>,
|
||||
#[serde(default)]
|
||||
timestamps: Option<RunTimestamps>,
|
||||
#[serde(default)]
|
||||
start_time: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
last_event_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
elapsed_secs: Option<f64>,
|
||||
#[serde(default)]
|
||||
billing: Option<RunBillingSummary>,
|
||||
#[serde(default)]
|
||||
total_usd_micros: Option<i64>,
|
||||
#[serde(default)]
|
||||
diff: Option<DiffSummary>,
|
||||
#[serde(default)]
|
||||
diff_summary: Option<DiffSummary>,
|
||||
#[serde(default)]
|
||||
pull_request: Option<PullRequest>,
|
||||
#[serde(default)]
|
||||
current_question: Option<InterviewQuestionRecord>,
|
||||
#[serde(default)]
|
||||
superseded_by: Option<RunId>,
|
||||
#[serde(default)]
|
||||
links: Option<RunLinks>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RepositoryRefWire {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
origin_url: Option<String>,
|
||||
#[serde(default)]
|
||||
provider: Option<RepositoryProvider>,
|
||||
}
|
||||
|
||||
impl From<RepositoryRefWire> 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<serde_json::Value>,
|
||||
) -> Result<(Option<RunStatus>, 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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<u64>) -> Option<f64> {
|
||||
duration_ms.map(|ms| ms as f64 / 1000.0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ fn load_run_checkpoint(run_dir: &Path) -> Result<Checkpoint, Box<dyn std::error:
|
|||
.into_iter()
|
||||
.next()
|
||||
.ok_or("test store should contain one run")?
|
||||
.run_id
|
||||
.id
|
||||
};
|
||||
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
||||
let state = runtime.block_on(async {
|
||||
|
|
@ -133,7 +133,7 @@ fn load_run_checkpoint(run_dir: &Path) -> Result<Checkpoint, Box<dyn std::error:
|
|||
.into_iter()
|
||||
.next()
|
||||
.ok_or("test store should contain one run")?
|
||||
.run_id
|
||||
.id
|
||||
};
|
||||
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
||||
runtime.block_on(async {
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ fn load_run_checkpoint(run_dir: &Path) -> Result<Checkpoint, Box<dyn std::error:
|
|||
.into_iter()
|
||||
.next()
|
||||
.ok_or("test store should contain one run")?
|
||||
.run_id
|
||||
.id
|
||||
};
|
||||
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
||||
let state = runtime.block_on(async {
|
||||
|
|
@ -157,7 +157,7 @@ fn load_run_checkpoint(run_dir: &Path) -> Result<Checkpoint, Box<dyn std::error:
|
|||
.into_iter()
|
||||
.next()
|
||||
.ok_or("test store should contain one run")?
|
||||
.run_id
|
||||
.id
|
||||
};
|
||||
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
||||
runtime.block_on(async {
|
||||
|
|
@ -236,7 +236,7 @@ fn resolve_checkpoint_text(
|
|||
.into_iter()
|
||||
.next()
|
||||
.ok_or("test store should contain one run")?
|
||||
.run_id
|
||||
.id
|
||||
};
|
||||
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
||||
let bytes = runtime
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue