From 0f1cf4da5cef3382eea30255b65153ea1226dffb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 12:15:28 -0700 Subject: [PATCH] feat(cli): wire run parent commands (#288) ## Summary Add CLI support for run parent relationships now that the server API can store them. This lets users create child runs, filter children, inspect parent metadata, and link or unlink parents without dropping to raw API calls. ## What Changed - Added top-level `fabro parent link` and `fabro parent unlink` commands with selector resolution, text output, and JSON summaries. - Added `--parent` to `fabro run`, `fabro create`, and `fabro ps`; create/run send `parent_id` in manifests and `ps` uses server-side parent filtering. - Surfaced `parent_id` in `ps --json` and `inspect`, with a conditional `PARENT` column for unfiltered tables. - Extended `fabro-client` parent-link APIs and `list_store_runs(parent_id)`. ## Test Plan - `cargo nextest run -p fabro-cli` - `cargo nextest run -p fabro-client` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy -p fabro-cli -p fabro-client --all-targets -- -D warnings` - `cargo insta pending-snapshots` - `git diff --check` --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) Generated with GPT-5 via [Codex](https://openai.com/codex) --- docs/public/reference/cli.mdx | 59 ++++++ lib/crates/fabro-cli/src/args.rs | 48 +++++ lib/crates/fabro-cli/src/commands/mod.rs | 28 +++ .../fabro-cli/src/commands/parent/link.rs | 31 +++ .../fabro-cli/src/commands/parent/mod.rs | 15 ++ .../fabro-cli/src/commands/parent/unlink.rs | 25 +++ lib/crates/fabro-cli/src/commands/pr/close.rs | 2 +- .../fabro-cli/src/commands/pr/create.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/link.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/merge.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 21 +- .../fabro-cli/src/commands/pr/unlink.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/view.rs | 2 +- .../fabro-cli/src/commands/run/create.rs | 18 +- .../fabro-cli/src/commands/runs/inspect.rs | 2 + .../fabro-cli/src/commands/runs/list.rs | 42 +++- lib/crates/fabro-cli/src/main.rs | 80 +++++++ lib/crates/fabro-cli/src/server_runs.rs | 18 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 41 ++++ lib/crates/fabro-cli/tests/it/cmd/fabro.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 66 +++++- lib/crates/fabro-cli/tests/it/cmd/mod.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/parent.rs | 198 ++++++++++++++++++ lib/crates/fabro-cli/tests/it/cmd/ps.rs | 111 ++++++++++ lib/crates/fabro-cli/tests/it/cmd/run.rs | 55 +++++ lib/crates/fabro-client/src/client.rs | 72 ++++++- 26 files changed, 901 insertions(+), 43 deletions(-) create mode 100644 lib/crates/fabro-cli/src/commands/parent/link.rs create mode 100644 lib/crates/fabro-cli/src/commands/parent/mod.rs create mode 100644 lib/crates/fabro-cli/src/commands/parent/unlink.rs create mode 100644 lib/crates/fabro-cli/tests/it/cmd/parent.rs diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index 434293abb..568d964c0 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -81,6 +81,7 @@ fabro [OPTIONS] [COMMAND] | `fabro logs` | View the raw worker tracing log of a workflow run | | `fabro mcp` | Model Context Protocol server | | `fabro model` | List and test LLM models | +| `fabro parent` | Manage run parent links | | `fabro pr` | Pull request operations | | `fabro preflight` | Validate run configuration without executing | | `fabro provider` | Provider operations | @@ -306,6 +307,7 @@ fabro create [OPTIONS] | `--goal-file ` | Read the workflow goal from a file | | `--label ` | Attach a label to this run (repeatable, format: KEY=VALUE) | | `--model ` | Override default LLM model | +| `--parent ` | Link this run to an existing orchestration parent run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | | `--provider ` | Override default LLM provider | | `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | @@ -627,6 +629,62 @@ fabro model test [OPTIONS] | `-p, --provider ` | Filter by provider | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | +### `fabro parent` + +Manage run parent links + +```bash +fabro parent [OPTIONS] +``` + +#### Subcommands + +| Command | Description | +| --- | --- | +| `fabro parent link` | Link or replace a run's orchestration parent | +| `fabro parent unlink` | Unlink a run from its orchestration parent | + +#### `fabro parent link` + +Link or replace a run's orchestration parent + +```bash +fabro parent link [OPTIONS] +``` + +#### Arguments + +| Name | Description | +| --- | --- | +| `CHILD_RUN` | Child run selector | +| `PARENT_RUN` | Parent run selector | + +#### Options + +| Option | Description | +| --- | --- | +| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | + +#### `fabro parent unlink` + +Unlink a run from its orchestration parent + +```bash +fabro parent unlink [OPTIONS] +``` + +#### Arguments + +| Name | Description | +| --- | --- | +| `CHILD_RUN` | Child run selector | + +#### Options + +| Option | Description | +| --- | --- | +| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | + ### `fabro pr` Pull request operations @@ -953,6 +1011,7 @@ fabro run [OPTIONS] | `--goal-file ` | Read the workflow goal from a file | | `--label ` | Attach a label to this run (repeatable, format: KEY=VALUE) | | `--model ` | Override default LLM model | +| `--parent ` | Link this run to an existing orchestration parent run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | | `--provider ` | Override default LLM provider | | `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index ccdeb1773..4bcd20f8b 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -293,6 +293,10 @@ pub(crate) struct RunArgs { #[arg(long = "label", value_name = "KEY=VALUE")] pub(crate) label: Vec, + /// Link this run to an existing orchestration parent run + #[arg(long, value_name = "RUN")] + pub(crate) parent: Option, + /// Keep the sandbox alive after the run finishes (for debugging) #[arg(long)] pub(crate) preserve_sandbox: bool, @@ -376,6 +380,10 @@ pub(crate) struct RunsListArgs { /// Only display run IDs #[arg(short = 'q', long)] pub(crate) quiet: bool, + + /// Only display runs linked to this orchestration parent + #[arg(long, value_name = "RUN")] + pub(crate) parent: Option, } #[derive(Args)] @@ -908,6 +916,26 @@ pub(crate) struct PrUnlinkArgs { pub(crate) run_id: String, } +#[derive(Args)] +pub(crate) struct ParentLinkArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Child run selector + pub(crate) child_run: String, + /// Parent run selector + pub(crate) parent_run: String, +} + +#[derive(Args)] +pub(crate) struct ParentUnlinkArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Child run selector + pub(crate) child_run: String, +} + #[derive(Args)] pub(crate) struct PrMergeArgs { #[command(flatten)] @@ -1202,6 +1230,8 @@ pub(crate) enum Commands { Auth(AuthNamespace), /// Pull request operations Pr(PrNamespace), + /// Manage run parent links + Parent(ParentNamespace), /// Manage server-owned secrets Secret(SecretNamespace), /// Inspect effective settings @@ -1310,6 +1340,10 @@ impl Commands { PrCommand::Merge(_) => "pr merge", PrCommand::Close(_) => "pr close", }, + Self::Parent(ns) => match &ns.command { + ParentCommand::Link(_) => "parent link", + ParentCommand::Unlink(_) => "parent unlink", + }, Self::Secret(ns) => match &ns.command { SecretCommand::List(_) => "secret list", SecretCommand::Rm(_) => "secret rm", @@ -1369,6 +1403,20 @@ pub(crate) enum PrCommand { Close(PrCloseArgs), } +#[derive(Args)] +pub(crate) struct ParentNamespace { + #[command(subcommand)] + pub(crate) command: ParentCommand, +} + +#[derive(Subcommand)] +pub(crate) enum ParentCommand { + /// Link or replace a run's orchestration parent + Link(ParentLinkArgs), + /// Unlink a run from its orchestration parent + Unlink(ParentUnlinkArgs), +} + #[derive(Args)] pub(crate) struct ArtifactNamespace { #[command(subcommand)] diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 7e5c3ee69..71d66bd08 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod graph; pub(crate) mod install; pub(crate) mod mcp; pub(crate) mod model; +pub(crate) mod parent; pub(crate) mod parse; pub(crate) mod pr; pub(crate) mod preflight; @@ -26,3 +27,30 @@ pub(crate) mod upgrade; pub(crate) mod validate; pub(crate) mod version; pub(crate) mod workflow; + +use std::sync::Arc; + +use anyhow::Result; +use fabro_client::Client; +use fabro_types::RunId; + +use crate::args::ServerTargetArgs; +use crate::command_context::CommandContext; + +pub(crate) async fn resolve_run_id(client: &Client, selector: &str) -> Result { + match selector.parse::() { + Ok(run_id) => Ok(run_id), + Err(_) => Ok(client.resolve_run(selector).await?.id), + } +} + +pub(crate) async fn resolve_run_selector( + base_ctx: &CommandContext, + server: &ServerTargetArgs, + selector: &str, +) -> Result<(CommandContext, Arc, RunId)> { + let ctx = base_ctx.with_target(server)?; + let client = ctx.server().await?; + let run_id = resolve_run_id(client.as_ref(), selector).await?; + Ok((ctx, client, run_id)) +} diff --git a/lib/crates/fabro-cli/src/commands/parent/link.rs b/lib/crates/fabro-cli/src/commands/parent/link.rs new file mode 100644 index 000000000..1289ef9f4 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/parent/link.rs @@ -0,0 +1,31 @@ +use anyhow::Result; +use tracing::info; + +use crate::args::ParentLinkArgs; +use crate::command_context::CommandContext; +use crate::shared::print_json_pretty; + +pub(super) async fn link_command(args: ParentLinkArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let client = ctx.server().await?; + let (child_id, parent_id) = tokio::try_join!( + super::resolve_run_id(client.as_ref(), &args.child_run), + super::resolve_run_id(client.as_ref(), &args.parent_run), + )?; + let summary = client.link_run_parent(&child_id, &parent_id).await?; + + info!(%child_id, %parent_id, "Linked run parent"); + + if ctx.json_output() { + print_json_pretty(&summary)?; + } else { + fabro_util::printout!( + ctx.printer(), + "Linked parent: {} -> {}", + child_id, + parent_id + ); + } + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/parent/mod.rs b/lib/crates/fabro-cli/src/commands/parent/mod.rs new file mode 100644 index 000000000..aae5a326e --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/parent/mod.rs @@ -0,0 +1,15 @@ +mod link; +mod unlink; + +use anyhow::Result; + +use super::{resolve_run_id, resolve_run_selector}; +use crate::args::{ParentCommand, ParentNamespace}; +use crate::command_context::CommandContext; + +pub(crate) async fn dispatch(ns: ParentNamespace, base_ctx: &CommandContext) -> Result<()> { + match ns.command { + ParentCommand::Link(args) => link::link_command(args, base_ctx).await, + ParentCommand::Unlink(args) => unlink::unlink_command(args, base_ctx).await, + } +} diff --git a/lib/crates/fabro-cli/src/commands/parent/unlink.rs b/lib/crates/fabro-cli/src/commands/parent/unlink.rs new file mode 100644 index 000000000..fc8ce68d1 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/parent/unlink.rs @@ -0,0 +1,25 @@ +use anyhow::Result; +use tracing::info; + +use crate::args::ParentUnlinkArgs; +use crate::command_context::CommandContext; +use crate::shared::print_json_pretty; + +pub(super) async fn unlink_command( + args: ParentUnlinkArgs, + base_ctx: &CommandContext, +) -> Result<()> { + let (ctx, client, child_id) = + super::resolve_run_selector(base_ctx, &args.server, &args.child_run).await?; + let summary = client.unlink_run_parent(&child_id).await?; + + info!(%child_id, "Unlinked run parent"); + + if ctx.json_output() { + print_json_pretty(&summary)?; + } else { + fabro_util::printout!(ctx.printer(), "Unlinked parent: {}", child_id); + } + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index 2aaf1b335..96c76b201 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let response = client.close_run_pull_request(&run_id).await?; info!(number = response.number, "Closed pull request"); diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index 0d846539c..2706c0991 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn create_command(args: PrCreateArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let record = client .create_run_pull_request(&run_id, args.force, args.model) .await?; diff --git a/lib/crates/fabro-cli/src/commands/pr/link.rs b/lib/crates/fabro-cli/src/commands/pr/link.rs index 1e0db9eed..ad95b7d20 100644 --- a/lib/crates/fabro-cli/src/commands/pr/link.rs +++ b/lib/crates/fabro-cli/src/commands/pr/link.rs @@ -8,7 +8,7 @@ use crate::shared::print_json_pretty; pub(super) async fn link_command(args: PrLinkArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let record = client.link_run_pull_request(&run_id, args.url).await?; info!( diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 820d34be6..297899001 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let response = client.merge_run_pull_request(&run_id, args.method).await?; info!( diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index a88976663..042ff90b8 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -5,13 +5,10 @@ mod merge; mod unlink; mod view; -use std::sync::Arc; - use anyhow::Result; -use fabro_client::Client; -use fabro_types::RunId; -use crate::args::{PrCommand, PrNamespace, ServerTargetArgs}; +use super::resolve_run_selector; +use crate::args::{PrCommand, PrNamespace}; use crate::command_context::CommandContext; pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Result<()> { @@ -24,17 +21,3 @@ pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Resu PrCommand::Close(args) => close::close_command(args, base_ctx).await, } } - -async fn resolve_run_for_pr( - base_ctx: &CommandContext, - server: &ServerTargetArgs, - selector: &str, -) -> Result<(CommandContext, Arc, RunId)> { - let ctx = base_ctx.with_target(server)?; - let client = ctx.server().await?; - let run_id = match selector.parse::() { - Ok(run_id) => run_id, - Err(_) => client.resolve_run(selector).await?.id, - }; - Ok((ctx, client, run_id)) -} diff --git a/lib/crates/fabro-cli/src/commands/pr/unlink.rs b/lib/crates/fabro-cli/src/commands/pr/unlink.rs index a6cc363e9..eea63a825 100644 --- a/lib/crates/fabro-cli/src/commands/pr/unlink.rs +++ b/lib/crates/fabro-cli/src/commands/pr/unlink.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn unlink_command(args: PrUnlinkArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let record = client.unlink_run_pull_request(&run_id).await?; info!( diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index 933dccb95..1326e42de 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let detail = client.get_run_pull_request(&run_id).await?; let pull_request = &detail.data.link; let github_details = detail.data.details.as_ref(); diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 2357b5758..23c3df685 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -11,6 +11,7 @@ use super::output::{api_diagnostics_to_local, print_workflow_summary}; use super::overrides::run_args_overrides; use crate::args::RunArgs; use crate::command_context::CommandContext; +use crate::commands::resolve_run_id; use crate::manifest_args::run_manifest_args; pub(crate) struct CreatedRun { @@ -40,7 +41,7 @@ pub(crate) async fn create_run( .transpose() .context("invalid run ID")?; - let built = build_run_manifest(ManifestBuildInput { + let mut built = build_run_manifest(ManifestBuildInput { workflow: workflow_path.clone(), cwd, run_overrides: cli_args_config.run, @@ -50,6 +51,16 @@ pub(crate) async fn create_run( run_id, user_settings_path: Some(active_settings_path(None)), })?; + + let client = if let Some(parent_selector) = args.parent.as_deref() { + let client = ctx.server().await?; + let parent_id = resolve_run_id(client.as_ref(), parent_selector).await?; + built.manifest.parent_id = Some(parent_id.to_string()); + Some(client) + } else { + None + }; + let validation = manifest_validation::validate_manifest( &RunLayer::default(), &built.manifest, @@ -72,7 +83,10 @@ pub(crate) async fn create_run( bail!("Validation failed"); } - let client = ctx.server().await?; + let client = match client { + Some(client) => client, + None => ctx.server().await?, + }; let created_run_id = client .create_run_from_manifest(built.manifest) .await diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index 9b5ff0532..4ee188a00 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -10,6 +10,7 @@ use crate::server_runs::ServerRunSummaryInfo; #[derive(Debug, Serialize)] pub(crate) struct InspectOutput { pub run_id: String, + pub parent_id: Option, pub status: RunStatus, pub run_spec: Option, pub start_record: Option, @@ -37,6 +38,7 @@ fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> Inspec .and_then(|record| serde_json::to_value(record).ok()); InspectOutput { run_id: run.run_id().to_string(), + parent_id: state.parent_id.map(|parent_id| parent_id.to_string()), status: state.status, run_spec: serde_json::to_value(state.spec).ok(), start_record: state diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 92e2db313..7adddc3b2 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -11,6 +11,7 @@ use fabro_workflow::run_status::RunStatus; use super::short_run_id; use crate::args::RunsListArgs; use crate::command_context::CommandContext; +use crate::commands::resolve_run_id; use crate::server_runs::{ServerSummaryLookup, filter_server_runs}; use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path}; @@ -21,7 +22,16 @@ pub(crate) async fn list_command( ) -> Result<()> { let ctx = base_ctx.with_target(&args.server)?; let printer = ctx.printer(); - let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let client = ctx.server().await?; + let parent_id = match args.parent.as_deref() { + Some(selector) => Some(resolve_run_id(client.as_ref(), selector).await?), + None => None, + }; + let filtered_by_parent = parent_id.is_some(); + let lookup = match parent_id { + Some(parent_id) => ServerSummaryLookup::from_client_by_parent(client, parent_id).await?, + None => ServerSummaryLookup::from_client(client).await?, + }; let label_filters = parse_label_filters(&args.filter.label); let filtered = filter_server_runs( lookup.runs(), @@ -37,6 +47,7 @@ pub(crate) async fn list_command( .map(|run| { serde_json::json!({ "run_id": run.run_id(), + "parent_id": run.parent_id(), "workflow_name": run.workflow_name(), "workflow_slug": run.workflow_slug(), "status": run.status(), @@ -75,17 +86,22 @@ pub(crate) async fn list_command( let mut display_runs = filtered; display_runs.reverse(); + let show_parent_column = + !filtered_by_parent && display_runs.iter().any(|run| run.parent_id().is_some()); let use_color = styles.use_color; let now = Utc::now(); - let title = vec![ - "RUN ID".cell().bold(use_color), + let mut title = vec!["RUN ID".cell().bold(use_color)]; + if show_parent_column { + title.push("PARENT".cell().bold(use_color)); + } + title.extend([ "WORKFLOW".cell().bold(use_color), "STATUS".cell().bold(use_color), "DIRECTORY".cell().bold(use_color), "DURATION".cell().bold(use_color), "GOAL".cell().bold(use_color), - ]; + ]); let rows: Vec> = display_runs .iter() @@ -105,10 +121,23 @@ pub(crate) async fn list_command( .map_or_else(|| "-".to_string(), |p| tilde_path(Path::new(p))); let run_id = run.run_id().to_string(); - vec![ + let mut row = vec![ short_run_id(&run_id) .cell() .foreground_color(color_if(use_color, Color::Ansi256(8))), + ]; + if show_parent_column { + let parent_display = run.parent_id().map_or_else( + || "-".to_string(), + |parent_id| short_run_id(&parent_id.to_string()).to_string(), + ); + row.push( + parent_display + .cell() + .foreground_color(color_if(use_color, Color::Ansi256(8))), + ); + } + row.extend([ run.workflow_name().cell(), status_cell(run.status(), use_color), dir_display.cell(), @@ -116,7 +145,8 @@ pub(crate) async fn list_command( truncate_goal(&run.goal(), 50) .cell() .foreground_color(color_if(use_color, Color::Ansi256(8))), - ] + ]); + row }) .collect(); diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index bb3686da6..8d7033829 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -329,6 +329,9 @@ async fn main_inner(worker_token: Option) -> (String, Result<()>) { Commands::Pr(ns) => { Box::pin(commands::pr::dispatch(ns, &base_ctx)).await?; } + Commands::Parent(ns) => { + commands::parent::dispatch(ns, &base_ctx).await?; + } Commands::Secret(ns) => { commands::secret::dispatch(ns, &base_ctx).await?; } @@ -1185,6 +1188,24 @@ destination = "{destination}" } } + #[test] + fn parse_create_parent_flag() { + let cli = Cli::try_parse_from([ + "fabro", + "create", + "--parent", + "nightly-parent", + "workflow.toml", + ]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunCmd(RunCommands::Create(args)) => { + assert_eq!(args.parent.as_deref(), Some("nightly-parent")); + } + _ => panic!("unexpected command variant"), + } + } + #[test] fn parse_run_input_short_flag() { let cli = Cli::try_parse_from(["fabro", "run", "workflow.toml", "-I", "foo=bar"]) @@ -1197,6 +1218,65 @@ destination = "{destination}" } } + #[test] + fn parse_run_parent_flag() { + let cli = Cli::try_parse_from([ + "fabro", + "run", + "--parent", + "nightly-parent", + "workflow.toml", + ]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunCmd(RunCommands::Run(args)) => { + assert_eq!(args.parent.as_deref(), Some("nightly-parent")); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_ps_parent_flag() { + let cli = Cli::try_parse_from(["fabro", "ps", "--parent", "nightly-parent"]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunsCmd(args::RunsCommands::Ps(args)) => { + assert_eq!(args.parent.as_deref(), Some("nightly-parent")); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_parent_link_command() { + let cli = Cli::try_parse_from(["fabro", "parent", "link", "child-run", "parent-run"]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::Parent(args::ParentNamespace { + command: args::ParentCommand::Link(args), + }) => { + assert_eq!(args.child_run, "child-run"); + assert_eq!(args.parent_run, "parent-run"); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_parent_unlink_command() { + let cli = + Cli::try_parse_from(["fabro", "parent", "unlink", "child-run"]).expect("should parse"); + match *cli.command.unwrap() { + Commands::Parent(args::ParentNamespace { + command: args::ParentCommand::Unlink(args), + }) => { + assert_eq!(args.child_run, "child-run"); + } + _ => panic!("unexpected command variant"), + } + } + #[test] fn run_manifest_args_preserves_input_only_manifest_args() { let cli = Cli::try_parse_from(["fabro", "run", "workflow.toml", "-I", "foo=bar"]) diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index 0b7517566..113694b64 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -21,6 +21,10 @@ impl ServerRunSummaryInfo { self.summary.id } + pub(crate) fn parent_id(&self) -> Option { + self.summary.parent_id + } + pub(crate) fn workflow_name(&self) -> String { self.summary.workflow.name.clone() } @@ -84,6 +88,18 @@ pub(crate) struct ServerSummaryLookup { impl ServerSummaryLookup { pub(crate) async fn from_client(client: Arc) -> Result { let summaries = client.list_store_runs().await?; + Ok(Self::from_summaries(summaries)) + } + + pub(crate) async fn from_client_by_parent( + client: Arc, + parent_id: RunId, + ) -> Result { + let summaries = client.list_store_runs_by_parent(parent_id).await?; + Ok(Self::from_summaries(summaries)) + } + + fn from_summaries(summaries: Vec) -> Self { let mut runs = summaries .into_iter() .map(ServerRunSummaryInfo::from_summary) @@ -93,7 +109,7 @@ impl ServerSummaryLookup { .cmp(&a.start_time_dt()) .then_with(|| b.run_id().cmp(&a.run_id())) }); - Ok(Self { runs }) + Self { runs } } pub(crate) fn runs(&self) -> &[ServerRunSummaryInfo] { diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index b69c6ae76..2a0424aae 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -60,6 +60,7 @@ fn help() { -v, --verbose Enable verbose output --sandbox Sandbox for agent tools [possible values: local, docker, daytona] --label Attach a label to this run (repeatable, format: KEY=VALUE) + --parent Link this run to an existing orchestration parent run --preserve-sandbox Keep the sandbox alive after the run finishes (for debugging) -d, --detach Run the workflow in the background and print the run ID -h, --help Print help @@ -129,6 +130,46 @@ fn create_uses_configured_server_target_without_server_flag() { assert_eq!(output_stdout(&output).trim(), run_id.as_str()); } +#[test] +fn create_parent_resolves_parent_and_sends_parent_id_in_manifest() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + let parent_id = unique_run_id(); + let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id); + let create_mock = server.mock(|when, then| { + when.method("POST") + .path("/api/v1/runs") + .json_body_includes(format!(r#"{{"parent_id":"{parent_id}"}}"#)); + then.status(201) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "submitted").to_string()); + }); + + let output = context + .create_cmd() + .args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "--dry-run", + "--parent", + "nightly-parent", + fixture("simple.fabro").to_str().unwrap(), + ]) + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + resolve_mock.assert(); + create_mock.assert(); + assert_eq!(output_stdout(&output).trim(), run_id.as_str()); +} + #[test] fn create_rejects_storage_dir_flag() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index 6fc7aaff8..fe29cbfee 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -41,6 +41,7 @@ fn help() { uninstall Uninstall Fabro from this machine auth Manage CLI authentication state pr Pull request operations + parent Manage run parent links secret Manage server-owned secrets settings Inspect effective settings workflow Workflow operations diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 844104f09..8d212c4b1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -1,7 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use insta::assert_snapshot; -use serde_json::json; +use serde_json::{Value, json}; use super::support::{ compact_git_inspect, compact_inspect, remote_run_summary_json, run_success, @@ -102,6 +102,7 @@ fn inspect_resolves_selector_via_server_endpoint() { [ { "run_id": "[ULID]", + "parent_id": null, "status": { "kind": "succeeded", "reason": "completed" @@ -217,6 +218,69 @@ fn inspect_resolves_selector_via_server_endpoint() { run_state.assert(); } +#[test] +fn inspect_includes_parent_id_from_run_projection() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + let parent_id = unique_run_id(); + let summary = remote_run_summary( + run_id.as_str(), + &json!({ + "kind": "succeeded", + "reason": "completed" + }), + ); + + let resolve_run = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/runs/resolve") + .query_param("selector", "nightly-build"); + then.status(200) + .header("content-type", "application/json") + .body(summary.to_string()); + }); + let run_state = server.mock(|when, then| { + when.method("GET") + .path(format!("/api/v1/runs/{}/state", run_id.as_str())); + let mut state = run_projection_json( + run_id.as_str(), + &json!({ + "kind": "succeeded", + "reason": "completed" + }), + ); + state["parent_id"] = json!(parent_id); + then.status(200) + .header("content-type", "application/json") + .body(state.to_string()); + }); + + let output = context + .command() + .args([ + "inspect", + "--server", + &format!("{}/api/v1", server.base_url()), + "nightly-build", + ]) + .output() + .expect("inspect should execute"); + + assert!( + output.status.success(), + "inspect failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let items: Value = serde_json::from_slice(&output.stdout).expect("inspect JSON should parse"); + assert_eq!(items[0]["run_id"], run_id); + assert_eq!(items[0]["parent_id"], parent_id); + + resolve_run.assert(); + run_state.assert(); +} + #[test] fn inspect_created_run_shows_run_spec_without_start_or_conclusion() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 68b8e4da7..79adb52e8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -24,6 +24,7 @@ mod mcp; mod model; mod model_list; mod model_test; +mod parent; mod parse; mod pr; mod pr_close; diff --git a/lib/crates/fabro-cli/tests/it/cmd/parent.rs b/lib/crates/fabro-cli/tests/it/cmd/parent.rs new file mode 100644 index 000000000..173ad9f57 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/parent.rs @@ -0,0 +1,198 @@ +use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; +use serde_json::Value; + +use super::support::{mock_resolved_run, remote_run_summary_json}; +use crate::support::unique_run_id; + +#[test] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["parent", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Manage run parent links + + Usage: fabro parent [OPTIONS] + + Commands: + link Link or replace a run's orchestration parent + unlink Unlink a run from its orchestration parent + help Print this message or the help of the given subcommand(s) + + Options: + --json Output as JSON [env: FABRO_JSON=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +fn parent_link_resolves_selectors_calls_endpoint_and_prints_link() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + let parent_id = unique_run_id(); + + let child_resolve = mock_resolved_run(&server, "child-build", &child_id); + let parent_resolve = mock_resolved_run(&server, "parent-build", &parent_id); + let link_mock = server.mock(|when, then| { + when.method("PUT") + .path(format!("/api/v1/runs/{child_id}/parent")) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "parent_id": parent_id + })); + let mut summary = remote_run_summary_json( + &child_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + ); + summary["parent_id"] = serde_json::json!(parent_id); + then.status(200) + .header("Content-Type", "application/json") + .json_body(summary); + }); + + let mut cmd = context.command(); + cmd.args([ + "parent", + "link", + "--server", + &server.base_url(), + "child-build", + "parent-build", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Linked parent: [ULID] -> [ULID] + ----- stderr ----- + "); + + child_resolve.assert(); + parent_resolve.assert(); + link_mock.assert(); +} + +#[test] +fn parent_link_json_prints_updated_run_summary() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + let parent_id = unique_run_id(); + + let child_resolve = mock_resolved_run(&server, "child-build", &child_id); + let parent_resolve = mock_resolved_run(&server, "parent-build", &parent_id); + let link_mock = server.mock(|when, then| { + when.method("PUT") + .path(format!("/api/v1/runs/{child_id}/parent")) + .json_body(serde_json::json!({ + "parent_id": parent_id + })); + let mut summary = remote_run_summary_json( + &child_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + ); + summary["parent_id"] = serde_json::json!(parent_id); + then.status(200) + .header("Content-Type", "application/json") + .json_body(summary); + }); + + let output = context + .command() + .args([ + "--json", + "parent", + "link", + "--server", + &server.base_url(), + "child-build", + "parent-build", + ]) + .output() + .expect("parent link should execute"); + + assert!( + output.status.success(), + "parent link failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let summary: Value = serde_json::from_slice(&output.stdout).expect("JSON should parse"); + assert_eq!(summary["id"], child_id); + assert_eq!(summary["parent_id"], parent_id); + + child_resolve.assert(); + parent_resolve.assert(); + link_mock.assert(); +} + +#[test] +fn parent_unlink_resolves_selector_calls_endpoint_and_prints_unlinked_child() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + + let child_resolve = mock_resolved_run(&server, "child-build", &child_id); + let unlink_mock = server.mock(|when, then| { + when.method("DELETE") + .path(format!("/api/v1/runs/{child_id}/parent")); + then.status(200) + .header("Content-Type", "application/json") + .json_body(remote_run_summary_json( + &child_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + )); + }); + + let mut cmd = context.command(); + cmd.args([ + "parent", + "unlink", + "--server", + &server.base_url(), + "child-build", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Unlinked parent: [ULID] + ----- stderr ----- + "); + + child_resolve.assert(); + unlink_mock.assert(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 31dc0e63c..0f82641d7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -54,6 +54,7 @@ fn help() { --verbose Enable verbose output [env: FABRO_VERBOSE=] -a, --all Show all runs, not just running (like docker ps -a) -q, --quiet Only display run IDs + --parent Only display runs linked to this orchestration parent -h, --help Print help ----- stderr ----- "); @@ -372,6 +373,116 @@ fn ps_uses_configured_server_target_without_server_flag() { assert_eq!(runs[0]["source_directory"], "/srv/repo"); } +#[test] +fn ps_parent_resolves_parent_and_filters_on_server() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + let parent_id = unique_run_id(); + let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id); + let mut summary = remote_run_summary_json( + &child_id, + "Child Workflow", + "child-workflow", + "Child goal", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-20T12:00:00Z", + ); + summary["parent_id"] = serde_json::json!(parent_id); + let list_mock = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/runs") + .query_param("parent_id", parent_id.as_str()); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [summary], + "meta": { "has_more": false } + }) + .to_string(), + ); + }); + + let output = context + .ps() + .args([ + "-a", + "--json", + "--server", + &format!("{}/api/v1", server.base_url()), + "--parent", + "nightly-parent", + ]) + .output() + .expect("ps should execute"); + + assert!(output.status.success(), "ps should succeed"); + let runs: Vec = serde_json::from_slice(&output.stdout).expect("ps JSON should parse"); + resolve_mock.assert(); + list_mock.assert(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0]["run_id"], child_id); + assert_eq!(runs[0]["parent_id"], parent_id); +} + +#[test] +fn ps_table_adds_parent_column_for_unfiltered_child_runs() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + let parent_id = unique_run_id(); + let mut summary = remote_run_summary_json( + &child_id, + "Child Workflow", + "child-workflow", + "Child goal", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-20T12:00:00Z", + ); + summary["parent_id"] = serde_json::json!(parent_id); + let list_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": [summary], + "meta": { "has_more": false } + }) + .to_string(), + ); + }); + + let output = context + .ps() + .args(["-a", "--server", &format!("{}/api/v1", server.base_url())]) + .output() + .expect("ps should execute"); + + assert!(output.status.success(), "ps should succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + list_mock.assert(); + assert!( + stdout.contains("PARENT"), + "table should include parent column:\n{stdout}" + ); + assert!( + stdout.contains(&child_id[..12]), + "table should include child run id:\n{stdout}" + ); + assert!( + stdout.contains(&parent_id[..12]), + "table should include parent run id:\n{stdout}" + ); +} + #[test] fn ps_explicit_remote_target_ignores_broken_local_storage_settings() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 8fead8139..29e76ef01 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -154,6 +154,7 @@ fn help() { -v, --verbose Enable verbose output --sandbox Sandbox for agent tools [possible values: local, docker, daytona] --label Attach a label to this run (repeatable, format: KEY=VALUE) + --parent Link this run to an existing orchestration parent run --preserve-sandbox Keep the sandbox alive after the run finishes (for debugging) -d, --detach Run the workflow in the background and print the run ID -h, --help Print help @@ -209,6 +210,60 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() { ); } +#[test] +fn run_parent_resolves_parent_and_sends_parent_id_in_manifest() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + let parent_id = unique_run_id(); + let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id); + let create_mock = server.mock(|when, then| { + when.method("POST") + .path("/api/v1/runs") + .json_body_includes(format!(r#"{{"parent_id":"{parent_id}"}}"#)); + then.status(201) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "submitted").to_string()); + }); + let start_mock = server.mock(|when, then| { + when.method("POST") + .path(format!("/api/v1/runs/{run_id}/start")); + then.status(200) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "queued").to_string()); + }); + + let workflow = context.install_fixture("simple.fabro"); + let output = context + .run_cmd() + .args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "--detach", + "--dry-run", + "--auto-approve", + "--parent", + "nightly-parent", + workflow.to_str().unwrap(), + ]) + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + resolve_mock.assert(); + create_mock.assert(); + start_mock.assert(); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + run_id.as_str() + ); +} + #[test] fn detach_uses_configured_server_target_without_server_flag() { let context = test_context!(); diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 557893c20..d584a8783 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -49,6 +49,11 @@ pub struct RewindRunResult { pub response: types::RewindResponse, } +#[derive(Default)] +struct ListStoreRunsOptions { + parent_id: Option, +} + #[derive(Clone)] struct ClientState { client: fabro_api::ApiClient, @@ -913,20 +918,41 @@ impl Client { } pub async fn list_store_runs(&self) -> Result> { + self.list_store_runs_with_options(ListStoreRunsOptions::default()) + .await + } + + pub async fn list_store_runs_by_parent(&self, parent_id: RunId) -> Result> { + self.list_store_runs_with_options(ListStoreRunsOptions { + parent_id: Some(parent_id), + }) + .await + } + + async fn list_store_runs_with_options( + &self, + options: ListStoreRunsOptions, + ) -> Result> { let mut all_runs = Vec::new(); let mut offset = 0_u64; let limit = 100_u64; + let parent_id = options.parent_id.map(|run_id| run_id.to_string()); loop { let response = self - .send_api(|client| async move { - client - .list_runs() - .page_limit(limit) - .page_offset(offset) - .include_archived(true) - .send() - .await + .send_api(|client| { + let parent_id = parent_id.clone(); + async move { + let mut request = client + .list_runs() + .page_limit(limit) + .page_offset(offset) + .include_archived(true); + if let Some(parent_id) = parent_id { + request = request.parent_id(parent_id); + } + request.send().await + } }) .await?; let parsed = response.into_inner(); @@ -947,6 +973,36 @@ impl Client { Ok(all_runs) } + pub async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> Result { + let body = types::UpdateRunParentRequest { + parent_id: parent_id.to_string(), + }; + let response = self + .send_api(|client| async move { + client + .link_run_parent() + .id(child_id.to_string()) + .body(body.clone()) + .send() + .await + }) + .await?; + convert_type(response.into_inner()) + } + + pub async fn unlink_run_parent(&self, child_id: &RunId) -> Result { + let response = self + .send_api(|client| async move { + client + .unlink_run_parent() + .id(child_id.to_string()) + .send() + .await + }) + .await?; + convert_type(response.into_inner()) + } + pub async fn retrieve_run(&self, run_id: &RunId) -> Result { let response = self .send_api(