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(