mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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` --- [](https://github.com/EveryInc/compound-engineering-plugin) Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
parent
2296ba6ea8
commit
0f1cf4da5c
26 changed files with 901 additions and 43 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
31
lib/crates/fabro-cli/src/commands/parent/link.rs
Normal file
31
lib/crates/fabro-cli/src/commands/parent/link.rs
Normal 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(())
|
||||
}
|
||||
15
lib/crates/fabro-cli/src/commands/parent/mod.rs
Normal file
15
lib/crates/fabro-cli/src/commands/parent/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
25
lib/crates/fabro-cli/src/commands/parent/unlink.rs
Normal file
25
lib/crates/fabro-cli/src/commands/parent/unlink.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ mod mcp;
|
|||
mod model;
|
||||
mod model_list;
|
||||
mod model_test;
|
||||
mod parent;
|
||||
mod parse;
|
||||
mod pr;
|
||||
mod pr_close;
|
||||
|
|
|
|||
198
lib/crates/fabro-cli/tests/it/cmd/parent.rs
Normal file
198
lib/crates/fabro-cli/tests/it/cmd/parent.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue