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)
This commit is contained in:
Bryan Helmkamp 2026-05-16 12:15:28 -07:00 committed by GitHub
parent 2296ba6ea8
commit 0f1cf4da5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 901 additions and 43 deletions

View file

@ -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] <WORKFLOW>
| `--goal-file <goal_file>` | Read the workflow goal from a file |
| `--label <key=value>` | Attach a label to this run (repeatable, format: KEY=VALUE) |
| `--model <model>` | Override default LLM model |
| `--parent <run>` | Link this run to an existing orchestration parent run |
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools<br />Values: `local`, `docker`, `daytona` |
@ -627,6 +629,62 @@ fabro model test [OPTIONS]
| `-p, --provider <provider>` | Filter by provider |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
### `fabro parent`
Manage run parent links
```bash
fabro parent [OPTIONS] <COMMAND>
```
#### 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] <CHILD_RUN> <PARENT_RUN>
```
#### Arguments
| Name | Description |
| --- | --- |
| `CHILD_RUN` | Child run selector |
| `PARENT_RUN` | Parent run selector |
#### Options
| Option | Description |
| --- | --- |
| `--server <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] <CHILD_RUN>
```
#### Arguments
| Name | Description |
| --- | --- |
| `CHILD_RUN` | Child run selector |
#### Options
| Option | Description |
| --- | --- |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
### `fabro pr`
Pull request operations
@ -953,6 +1011,7 @@ fabro run [OPTIONS] <WORKFLOW>
| `--goal-file <goal_file>` | Read the workflow goal from a file |
| `--label <key=value>` | Attach a label to this run (repeatable, format: KEY=VALUE) |
| `--model <model>` | Override default LLM model |
| `--parent <run>` | Link this run to an existing orchestration parent run |
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `--provider <provider>` | Override default LLM provider |
| `--sandbox <sandbox>` | Sandbox for agent tools<br />Values: `local`, `docker`, `daytona` |

View file

@ -293,6 +293,10 @@ pub(crate) struct RunArgs {
#[arg(long = "label", value_name = "KEY=VALUE")]
pub(crate) label: Vec<String>,
/// Link this run to an existing orchestration parent run
#[arg(long, value_name = "RUN")]
pub(crate) parent: Option<String>,
/// 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<String>,
}
#[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)]

View file

@ -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<RunId> {
match selector.parse::<RunId>() {
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<Client>, 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))
}

View file

@ -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(())
}

View file

@ -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,
}
}

View file

@ -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(())
}

View file

@ -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");

View file

@ -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?;

View file

@ -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!(

View file

@ -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!(

View file

@ -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<Client>, RunId)> {
let ctx = base_ctx.with_target(server)?;
let client = ctx.server().await?;
let run_id = match selector.parse::<RunId>() {
Ok(run_id) => run_id,
Err(_) => client.resolve_run(selector).await?.id,
};
Ok((ctx, client, run_id))
}

View file

@ -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!(

View file

@ -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();

View file

@ -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

View file

@ -10,6 +10,7 @@ use crate::server_runs::ServerRunSummaryInfo;
#[derive(Debug, Serialize)]
pub(crate) struct InspectOutput {
pub run_id: String,
pub parent_id: Option<String>,
pub status: RunStatus,
pub run_spec: Option<serde_json::Value>,
pub start_record: Option<serde_json::Value>,
@ -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

View file

@ -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<Vec<CellStruct>> = 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();

View file

@ -329,6 +329,9 @@ async fn main_inner(worker_token: Option<String>) -> (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"])

View file

@ -21,6 +21,10 @@ impl ServerRunSummaryInfo {
self.summary.id
}
pub(crate) fn parent_id(&self) -> Option<RunId> {
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<Client>) -> Result<Self> {
let summaries = client.list_store_runs().await?;
Ok(Self::from_summaries(summaries))
}
pub(crate) async fn from_client_by_parent(
client: Arc<Client>,
parent_id: RunId,
) -> Result<Self> {
let summaries = client.list_store_runs_by_parent(parent_id).await?;
Ok(Self::from_summaries(summaries))
}
fn from_summaries(summaries: Vec<RunSummary>) -> 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] {

View file

@ -60,6 +60,7 @@ fn help() {
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> 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!();

View file

@ -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

View file

@ -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!();

View file

@ -24,6 +24,7 @@ mod mcp;
mod model;
mod model_list;
mod model_test;
mod parent;
mod parse;
mod pr;
mod pr_close;

View file

@ -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] <COMMAND>
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();
}

View file

@ -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 <RUN> 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<Value> = 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!();

View file

@ -154,6 +154,7 @@ fn help() {
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> 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!();

View file

@ -49,6 +49,11 @@ pub struct RewindRunResult {
pub response: types::RewindResponse,
}
#[derive(Default)]
struct ListStoreRunsOptions {
parent_id: Option<RunId>,
}
#[derive(Clone)]
struct ClientState {
client: fabro_api::ApiClient,
@ -913,20 +918,41 @@ impl Client {
}
pub async fn list_store_runs(&self) -> Result<Vec<RunSummary>> {
self.list_store_runs_with_options(ListStoreRunsOptions::default())
.await
}
pub async fn list_store_runs_by_parent(&self, parent_id: RunId) -> Result<Vec<RunSummary>> {
self.list_store_runs_with_options(ListStoreRunsOptions {
parent_id: Some(parent_id),
})
.await
}
async fn list_store_runs_with_options(
&self,
options: ListStoreRunsOptions,
) -> Result<Vec<RunSummary>> {
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<RunSummary> {
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<RunSummary> {
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<RunSummary> {
let response = self
.send_api(