mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-10 22:43:37 +00:00
feat(mcp): support run parent relationships (#295)
## Summary - Add parent metadata (`parent_id`, `children_count`) to Fabro MCP run summaries, search summaries, and created-run results. - Allow MCP clients to create child runs, search direct children, and link or unlink an existing run's parent through the existing run tools. - Update MCP docs and tool descriptions for the parent-aware create/search/interact behavior. ## Test Plan - [x] `cargo +nightly-2026-04-14 fmt --check --all` - [x] `cargo nextest run -p fabro-mcp-server` - [x] `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d1fd6d3abf
commit
73ebb7d28b
10 changed files with 448 additions and 14 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2120,10 +2120,12 @@ dependencies = [
|
|||
"fabro-types",
|
||||
"fabro-util",
|
||||
"futures",
|
||||
"httpmock",
|
||||
"rmcp",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ Pass `--server` when the MCP client should connect to a specific Fabro server, o
|
|||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `fabro_run_create` | Create one or more workflow runs, starting them by default. |
|
||||
| `fabro_run_search` | Search runs by ID, workflow, labels, status, archive state, and creation time. |
|
||||
| `fabro_run_interact` | Get, start, message, cancel, archive, unarchive, inspect questions, or answer a run. |
|
||||
| `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. |
|
||||
| `fabro_run_search` | Search runs by ID, parent, workflow, labels, status, archive state, and creation time. |
|
||||
| `fabro_run_interact` | Get, start, message, cancel, archive, unarchive, link or unlink a parent, inspect questions, or answer a run. |
|
||||
| `fabro_run_gather` | Wait for runs to reach terminal states, returning current state on timeout. |
|
||||
| `fabro_run_events` | List, inspect, or search stored events for a run. |
|
||||
|
||||
Run summaries returned by the MCP server include parent metadata. Use `parent_id` on `fabro_run_create` to create a child run, `parent_id` on `fabro_run_search` to list direct children, and the `link_parent` or `unlink_parent` actions on `fabro_run_interact` to change an existing run's parent.
|
||||
|
||||
## Fabro agents as MCP clients
|
||||
|
||||
When an agent session starts with MCP servers configured, Fabro:
|
||||
|
|
|
|||
|
|
@ -556,6 +556,8 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
|
|||
"runs": [
|
||||
{
|
||||
"run_id": "[RUN_ID]",
|
||||
"parent_id": null,
|
||||
"children_count": 0,
|
||||
"workflow_name": "Simple",
|
||||
"workflow_slug": "simple",
|
||||
"status": "queued",
|
||||
|
|
@ -1160,6 +1162,8 @@ async fn mcp_lifecycle_tools_manage_real_run() {
|
|||
"runs": [
|
||||
{
|
||||
"run_id": "[RUN_ID]",
|
||||
"parent_id": null,
|
||||
"children_count": 0,
|
||||
"workflow_name": "Simple",
|
||||
"workflow_slug": "simple",
|
||||
"status": "failed",
|
||||
|
|
|
|||
|
|
@ -30,3 +30,7 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
httpmock = "0.8"
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ pub(super) type ToolResult<T> = Result<T, ToolError>;
|
|||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct RunSummaryResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) parent_id: Option<String>,
|
||||
pub(crate) children_count: u64,
|
||||
pub(crate) workflow_name: String,
|
||||
pub(crate) workflow_slug: Option<String>,
|
||||
pub(crate) status: String,
|
||||
|
|
@ -90,6 +92,8 @@ pub(super) async fn retrieve_run(client: &Client, run_id: &RunId) -> ToolResult<
|
|||
pub(super) fn run_summary_result(run: &Run) -> RunSummaryResult {
|
||||
RunSummaryResult {
|
||||
run_id: run.id.to_string(),
|
||||
parent_id: run.parent_id.map(|parent_id| parent_id.to_string()),
|
||||
children_count: run.children_count,
|
||||
workflow_name: run.workflow.name.clone(),
|
||||
workflow_slug: run.workflow.slug.clone(),
|
||||
status: run_status_kind(run.lifecycle.status).to_string(),
|
||||
|
|
@ -139,3 +143,66 @@ fn format_tool_error(err: &anyhow::Error) -> String {
|
|||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunTimestamps, WorkflowRef};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn run_summary_result_includes_parent_metadata() {
|
||||
let parent_id = run_id("01KRBZW4DW0000000000000002");
|
||||
let run = Run {
|
||||
id: run_id("01KRBZW5C00000000000000001"),
|
||||
parent_id: Some(parent_id),
|
||||
children_count: 3,
|
||||
title: "test".to_string(),
|
||||
goal: "test".to_string(),
|
||||
workflow: WorkflowRef {
|
||||
slug: Some("simple".to_string()),
|
||||
name: "Simple".to_string(),
|
||||
},
|
||||
automation: None,
|
||||
repository: None,
|
||||
created_by: None,
|
||||
origin: RunOrigin::default(),
|
||||
labels: HashMap::new(),
|
||||
lifecycle: RunLifecycle {
|
||||
status: RunStatus::Submitted,
|
||||
pending_control: None,
|
||||
queue_position: None,
|
||||
error: None,
|
||||
archived: false,
|
||||
archived_at: None,
|
||||
},
|
||||
sandbox: None,
|
||||
models: Vec::new(),
|
||||
source_directory: None,
|
||||
timestamps: RunTimestamps {
|
||||
created_at: Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, 0).unwrap(),
|
||||
started_at: None,
|
||||
last_event_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
elapsed_secs: None,
|
||||
},
|
||||
billing: None,
|
||||
diff: None,
|
||||
pull_request: None,
|
||||
current_question: None,
|
||||
superseded_by: None,
|
||||
links: RunLinks { web: None },
|
||||
};
|
||||
|
||||
let summary = run_summary_result(&run);
|
||||
|
||||
assert_eq!(summary.parent_id, Some(parent_id.to_string()));
|
||||
assert_eq!(summary.children_count, 3);
|
||||
}
|
||||
|
||||
fn run_id(raw: &str) -> RunId {
|
||||
raw.parse().expect("test run id should parse")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub(crate) struct CreateRunSpec {
|
|||
pub(crate) workflow: String,
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
pub(crate) run_id: Option<String>,
|
||||
pub(crate) parent_id: Option<String>,
|
||||
pub(crate) goal: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) inputs: HashMap<String, RunInputValue>,
|
||||
|
|
@ -84,6 +85,7 @@ pub(crate) struct ValidatedCreateRunSpec {
|
|||
pub(crate) workflow: String,
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
pub(crate) run_id: Option<RunId>,
|
||||
pub(crate) parent_id: Option<String>,
|
||||
pub(crate) goal: Option<String>,
|
||||
pub(crate) inputs: HashMap<String, toml::Value>,
|
||||
pub(crate) labels: HashMap<String, String>,
|
||||
|
|
@ -122,6 +124,15 @@ impl TryFrom<CreateRunSpec> for ValidatedCreateRunSpec {
|
|||
.map_err(|err| {
|
||||
ToolError::message(format!("run_id must be a valid Fabro run id: {err}"))
|
||||
})?;
|
||||
let parent_id = spec
|
||||
.parent_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|parent_id| !parent_id.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
if spec.parent_id.is_some() && parent_id.is_none() {
|
||||
return Err(ToolError::message("parent_id must not be blank"));
|
||||
}
|
||||
let inputs = spec
|
||||
.inputs
|
||||
.into_iter()
|
||||
|
|
@ -134,6 +145,7 @@ impl TryFrom<CreateRunSpec> for ValidatedCreateRunSpec {
|
|||
workflow: spec.workflow,
|
||||
cwd: spec.cwd,
|
||||
run_id,
|
||||
parent_id,
|
||||
goal: spec.goal,
|
||||
inputs,
|
||||
labels: spec.labels,
|
||||
|
|
@ -155,10 +167,12 @@ pub(crate) struct CreateRunsResult {
|
|||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct CreatedRunResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) workflow: String,
|
||||
pub(crate) started: bool,
|
||||
pub(crate) status: String,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) parent_id: Option<String>,
|
||||
pub(crate) children_count: u64,
|
||||
pub(crate) workflow: String,
|
||||
pub(crate) started: bool,
|
||||
pub(crate) status: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn create_runs(
|
||||
|
|
@ -170,7 +184,15 @@ pub(crate) async fn create_runs(
|
|||
let mut created = Vec::with_capacity(params.runs.len());
|
||||
for spec in params.runs {
|
||||
let cwd = spec.cwd.clone().unwrap_or_else(|| base_cwd.to_path_buf());
|
||||
let manifest = manifest::build_mcp_run_manifest(&spec, &cwd, user_settings_path)?;
|
||||
let mut manifest = manifest::build_mcp_run_manifest(&spec, &cwd, user_settings_path)?;
|
||||
if let Some(parent_selector) = spec.parent_id.as_deref() {
|
||||
let parent_id = client
|
||||
.resolve_run(parent_selector)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
.id;
|
||||
manifest.parent_id = Some(parent_id.to_string());
|
||||
}
|
||||
let run_id = client
|
||||
.create_run_from_manifest(manifest)
|
||||
.await
|
||||
|
|
@ -189,6 +211,8 @@ pub(crate) async fn create_runs(
|
|||
};
|
||||
created.push(CreatedRunResult {
|
||||
run_id: summary.id.to_string(),
|
||||
parent_id: summary.parent_id.map(|parent_id| parent_id.to_string()),
|
||||
children_count: summary.children_count,
|
||||
workflow: spec.workflow,
|
||||
started,
|
||||
status: common::run_status_kind(summary.lifecycle.status).to_string(),
|
||||
|
|
@ -209,6 +233,7 @@ pub(crate) fn create_runs_text(result: &CreateRunsResult) -> String {
|
|||
mod tests {
|
||||
use schemars::SchemaGenerator;
|
||||
use serde_json::json;
|
||||
use tokio::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
|
|
@ -228,4 +253,159 @@ mod tests {
|
|||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_spec_accepts_parent_selector() {
|
||||
let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec {
|
||||
workflow: "simple.fabro".to_string(),
|
||||
cwd: None,
|
||||
run_id: None,
|
||||
parent_id: Some(" nightly-parent ".to_string()),
|
||||
goal: None,
|
||||
inputs: HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
dry_run: None,
|
||||
auto_approve: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
sandbox: None,
|
||||
preserve_sandbox: None,
|
||||
start: None,
|
||||
})
|
||||
.expect("parent selectors should validate without requiring exact run ids");
|
||||
|
||||
assert_eq!(spec.parent_id.as_deref(), Some("nightly-parent"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_runs_resolves_parent_selector_and_sends_parent_id_in_manifest() {
|
||||
let temp = tempfile::tempdir().expect("tempdir should be created");
|
||||
let workflow = temp.path().join("simple.fabro");
|
||||
fs::write(
|
||||
&workflow,
|
||||
r#"digraph Simple {
|
||||
graph [goal="Run tests and report results"]
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
start -> exit
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.expect("workflow should be written");
|
||||
let settings = temp.path().join("settings.toml");
|
||||
fs::write(&settings, "")
|
||||
.await
|
||||
.expect("settings should be written");
|
||||
|
||||
let server = httpmock::MockServer::start();
|
||||
let child_id = run_id("01KRBZW5C00000000000000001");
|
||||
let parent_id = run_id("01KRBZW4DW0000000000000002");
|
||||
let resolve_parent = server.mock(|when, then| {
|
||||
when.method("GET")
|
||||
.path("/api/v1/runs/resolve")
|
||||
.query_param("selector", "nightly-parent");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(run_summary_json(parent_id, None, 1));
|
||||
});
|
||||
let create = 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")
|
||||
.json_body(run_summary_json(child_id, Some(parent_id), 0));
|
||||
});
|
||||
let retrieve = server.mock(|when, then| {
|
||||
when.method("GET").path(format!("/api/v1/runs/{child_id}"));
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(run_summary_json(child_id, Some(parent_id), 0));
|
||||
});
|
||||
let client =
|
||||
Arc::new(Client::new_no_proxy(&server.base_url()).expect("client should build"));
|
||||
let params = ValidatedCreateRuns::try_from(FabroRunCreateParams {
|
||||
runs: vec![CreateRunSpec {
|
||||
workflow: workflow.display().to_string(),
|
||||
cwd: None,
|
||||
run_id: None,
|
||||
parent_id: Some("nightly-parent".to_string()),
|
||||
goal: None,
|
||||
inputs: HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
dry_run: Some(true),
|
||||
auto_approve: Some(true),
|
||||
model: None,
|
||||
provider: None,
|
||||
sandbox: None,
|
||||
preserve_sandbox: None,
|
||||
start: Some(false),
|
||||
}],
|
||||
})
|
||||
.expect("create params should validate");
|
||||
|
||||
let result = create_runs(client, temp.path(), &settings, params)
|
||||
.await
|
||||
.expect("run should be created");
|
||||
|
||||
assert_eq!(result.runs[0].parent_id, Some(parent_id.to_string()));
|
||||
assert_eq!(result.runs[0].children_count, 0);
|
||||
resolve_parent.assert();
|
||||
create.assert();
|
||||
retrieve.assert();
|
||||
}
|
||||
|
||||
fn run_id(raw: &str) -> RunId {
|
||||
raw.parse().expect("test run id should parse")
|
||||
}
|
||||
|
||||
fn run_summary_json(
|
||||
run_id: RunId,
|
||||
parent_id: Option<RunId>,
|
||||
children_count: u64,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": run_id,
|
||||
"parent_id": parent_id,
|
||||
"children_count": children_count,
|
||||
"title": "Test run",
|
||||
"goal": "Test run",
|
||||
"workflow": {
|
||||
"slug": "simple",
|
||||
"name": "Simple"
|
||||
},
|
||||
"repository": null,
|
||||
"origin": {
|
||||
"kind": "api"
|
||||
},
|
||||
"labels": {},
|
||||
"lifecycle": {
|
||||
"status": { "kind": "submitted" },
|
||||
"pending_control": null,
|
||||
"queue_position": null,
|
||||
"error": null,
|
||||
"archived": false,
|
||||
"archived_at": null
|
||||
},
|
||||
"models": [],
|
||||
"source_directory": "/srv/repo",
|
||||
"timestamps": {
|
||||
"created_at": "2026-04-05T12:00:00Z",
|
||||
"started_at": null,
|
||||
"last_event_at": null,
|
||||
"completed_at": null,
|
||||
"duration_ms": null,
|
||||
"elapsed_secs": null
|
||||
},
|
||||
"billing": null,
|
||||
"diff": null,
|
||||
"pull_request": null,
|
||||
"current_question": null,
|
||||
"superseded_by": null,
|
||||
"links": {
|
||||
"web": null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ pub(crate) enum RunInteractAction {
|
|||
Cancel,
|
||||
Archive,
|
||||
Unarchive,
|
||||
LinkParent,
|
||||
UnlinkParent,
|
||||
GetQuestions,
|
||||
Answer,
|
||||
}
|
||||
|
|
@ -33,6 +35,7 @@ pub(crate) enum RunInteractAction {
|
|||
pub(crate) struct FabroRunInteractParams {
|
||||
pub(crate) action: RunInteractAction,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) parent_id: Option<String>,
|
||||
pub(crate) message: Option<String>,
|
||||
pub(crate) interrupt: Option<bool>,
|
||||
pub(crate) question_id: Option<String>,
|
||||
|
|
@ -120,6 +123,10 @@ pub(crate) enum ValidatedInteractAction {
|
|||
Cancel,
|
||||
Archive,
|
||||
Unarchive,
|
||||
LinkParent {
|
||||
parent_id: String,
|
||||
},
|
||||
UnlinkParent,
|
||||
GetQuestions,
|
||||
Answer {
|
||||
question_id: String,
|
||||
|
|
@ -137,6 +144,8 @@ impl ValidatedInteractAction {
|
|||
Self::Cancel => RunInteractAction::Cancel,
|
||||
Self::Archive => RunInteractAction::Archive,
|
||||
Self::Unarchive => RunInteractAction::Unarchive,
|
||||
Self::LinkParent { .. } => RunInteractAction::LinkParent,
|
||||
Self::UnlinkParent => RunInteractAction::UnlinkParent,
|
||||
Self::GetQuestions => RunInteractAction::GetQuestions,
|
||||
Self::Answer { .. } => RunInteractAction::Answer,
|
||||
}
|
||||
|
|
@ -171,6 +180,22 @@ impl TryFrom<FabroRunInteractParams> for ValidatedInteractRun {
|
|||
RunInteractAction::Cancel => ValidatedInteractAction::Cancel,
|
||||
RunInteractAction::Archive => ValidatedInteractAction::Archive,
|
||||
RunInteractAction::Unarchive => ValidatedInteractAction::Unarchive,
|
||||
RunInteractAction::LinkParent => {
|
||||
let Some(parent_id) = params
|
||||
.parent_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|parent_id| !parent_id.is_empty())
|
||||
else {
|
||||
return Err(ToolError::message(
|
||||
"parent_id is required for action link_parent",
|
||||
));
|
||||
};
|
||||
ValidatedInteractAction::LinkParent {
|
||||
parent_id: parent_id.to_string(),
|
||||
}
|
||||
}
|
||||
RunInteractAction::UnlinkParent => ValidatedInteractAction::UnlinkParent,
|
||||
RunInteractAction::GetQuestions => ValidatedInteractAction::GetQuestions,
|
||||
RunInteractAction::Answer => {
|
||||
let Some(question_id) = params
|
||||
|
|
@ -260,6 +285,25 @@ pub(crate) async fn interact_run(
|
|||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::LinkParent { parent_id } => {
|
||||
let parent_id = client
|
||||
.resolve_run(&parent_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
.id;
|
||||
let summary = client
|
||||
.link_run_parent(&run_id, &parent_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::UnlinkParent => {
|
||||
let summary = client
|
||||
.unlink_run_parent(&run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::GetQuestions => {
|
||||
let questions = client
|
||||
.list_run_questions(&run_id)
|
||||
|
|
@ -402,6 +446,7 @@ mod tests {
|
|||
let err = ValidatedInteractRun::try_from(FabroRunInteractParams {
|
||||
action: RunInteractAction::Answer,
|
||||
run_id: "run_123".to_string(),
|
||||
parent_id: None,
|
||||
message: None,
|
||||
interrupt: None,
|
||||
question_id: Some("question-1".to_string()),
|
||||
|
|
@ -412,11 +457,55 @@ mod tests {
|
|||
assert!(err.as_str().contains("option, options, text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interact_link_parent_validation_rejects_missing_or_blank_parent_id() {
|
||||
for parent_id in [None, Some(" ".to_string())] {
|
||||
let err = ValidatedInteractRun::try_from(FabroRunInteractParams {
|
||||
action: RunInteractAction::LinkParent,
|
||||
run_id: "child-run".to_string(),
|
||||
parent_id,
|
||||
message: None,
|
||||
interrupt: None,
|
||||
question_id: None,
|
||||
answer: None,
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.as_str()
|
||||
.contains("parent_id is required for action link_parent"),
|
||||
"{}",
|
||||
err.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interact_unlink_parent_validation_does_not_require_parent_id() {
|
||||
let validated = ValidatedInteractRun::try_from(FabroRunInteractParams {
|
||||
action: RunInteractAction::UnlinkParent,
|
||||
run_id: " child-run ".to_string(),
|
||||
parent_id: None,
|
||||
message: None,
|
||||
interrupt: None,
|
||||
question_id: None,
|
||||
answer: None,
|
||||
})
|
||||
.expect("unlink_parent should not require parent_id");
|
||||
|
||||
assert_eq!(validated.run_id, "child-run");
|
||||
assert!(matches!(
|
||||
validated.action,
|
||||
ValidatedInteractAction::UnlinkParent
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_action_requires_only_run_id() {
|
||||
let validated = ValidatedInteractRun::try_from(FabroRunInteractParams {
|
||||
action: RunInteractAction::Interrupt,
|
||||
run_id: "run_123".to_string(),
|
||||
parent_id: None,
|
||||
message: None,
|
||||
interrupt: None,
|
||||
question_id: None,
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ mod tests {
|
|||
let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec {
|
||||
workflow: "simple".to_string(),
|
||||
run_id: None,
|
||||
parent_id: None,
|
||||
cwd: None,
|
||||
goal: None,
|
||||
inputs: HashMap::from([
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_client::Client;
|
||||
use fabro_types::{Run, RunStatusKind};
|
||||
use fabro_types::{Run, RunId, RunStatusKind};
|
||||
use futures::future::try_join_all;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -23,6 +23,7 @@ pub(crate) struct FabroRunSearchParams {
|
|||
pub(crate) created_before: Option<String>,
|
||||
pub(crate) first: Option<usize>,
|
||||
pub(crate) after: Option<String>,
|
||||
pub(crate) parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -34,7 +35,7 @@ pub(crate) struct ValidatedSearchRuns {
|
|||
impl TryFrom<FabroRunSearchParams> for ValidatedSearchRuns {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunSearchParams) -> Result<Self, Self::Error> {
|
||||
fn try_from(mut params: FabroRunSearchParams) -> Result<Self, Self::Error> {
|
||||
if params.first.is_some_and(|first| first > 100) {
|
||||
return Err(ToolError::message("first must be <= 100"));
|
||||
}
|
||||
|
|
@ -61,6 +62,13 @@ impl TryFrom<FabroRunSearchParams> for ValidatedSearchRuns {
|
|||
if let Some(created_before) = params.created_before.as_deref() {
|
||||
common::parse_datetime_filter("created_before", created_before)?;
|
||||
}
|
||||
if let Some(parent_id) = params.parent_id.take() {
|
||||
let parent_id = parent_id.trim().to_string();
|
||||
if parent_id.is_empty() {
|
||||
return Err(ToolError::message("parent_id must not be blank"));
|
||||
}
|
||||
params.parent_id = Some(parent_id);
|
||||
}
|
||||
Ok(Self {
|
||||
raw: params,
|
||||
status,
|
||||
|
|
@ -77,6 +85,8 @@ pub(crate) struct SearchRunsResult {
|
|||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct SearchRunSummaryResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) parent_id: Option<String>,
|
||||
pub(crate) children_count: u64,
|
||||
pub(crate) workflow_name: String,
|
||||
pub(crate) workflow_slug: Option<String>,
|
||||
pub(crate) status: String,
|
||||
|
|
@ -97,15 +107,31 @@ pub(crate) async fn search_runs(
|
|||
) -> ToolResult<SearchRunsResult> {
|
||||
let status = params.status;
|
||||
let raw = params.raw;
|
||||
let parent_id = if let Some(parent_selector) = raw.parent_id.as_deref() {
|
||||
Some(
|
||||
client
|
||||
.resolve_run(parent_selector)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
.id,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let runs = if let Some(run_ids) = raw.run_ids.as_ref() {
|
||||
resolve_requested_runs(&client, run_ids).await?
|
||||
} else if let Some(parent_id) = parent_id {
|
||||
client
|
||||
.list_store_runs_by_parent(parent_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
} else {
|
||||
client
|
||||
.list_store_runs()
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
};
|
||||
let page = filter_sort_and_page_runs(runs, &raw, status.as_deref())?;
|
||||
let page = filter_sort_and_page_runs(runs, &raw, status.as_deref(), parent_id)?;
|
||||
|
||||
Ok(SearchRunsResult {
|
||||
runs: page.runs.iter().map(search_run_summary_result).collect(),
|
||||
|
|
@ -116,6 +142,8 @@ pub(crate) async fn search_runs(
|
|||
fn search_run_summary_result(run: &Run) -> SearchRunSummaryResult {
|
||||
let RunSummaryResult {
|
||||
run_id,
|
||||
parent_id,
|
||||
children_count,
|
||||
workflow_name,
|
||||
workflow_slug,
|
||||
status,
|
||||
|
|
@ -132,6 +160,8 @@ fn search_run_summary_result(run: &Run) -> SearchRunSummaryResult {
|
|||
|
||||
SearchRunSummaryResult {
|
||||
run_id,
|
||||
parent_id,
|
||||
children_count,
|
||||
workflow_name,
|
||||
workflow_slug,
|
||||
status,
|
||||
|
|
@ -169,7 +199,11 @@ fn filter_sort_and_page_runs(
|
|||
mut runs: Vec<Run>,
|
||||
raw: &FabroRunSearchParams,
|
||||
status: Option<&[RunStatusKind]>,
|
||||
parent_id: Option<RunId>,
|
||||
) -> ToolResult<RunSearchPage> {
|
||||
if let Some(parent_id) = parent_id {
|
||||
runs.retain(|run| run.parent_id == Some(parent_id));
|
||||
}
|
||||
if let Some(workflow) = raw.workflow.as_deref() {
|
||||
runs.retain(|run| {
|
||||
run.workflow.name == workflow || run.workflow.slug.as_deref() == Some(workflow)
|
||||
|
|
@ -278,8 +312,10 @@ mod tests {
|
|||
created_before: None,
|
||||
first: Some(10),
|
||||
after: Some(unrelated_cursor.id.to_string()),
|
||||
parent_id: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("filtering should succeed");
|
||||
|
||||
|
|
@ -304,8 +340,10 @@ mod tests {
|
|||
created_before: None,
|
||||
first: Some(10),
|
||||
after: None,
|
||||
parent_id: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("filtering should succeed");
|
||||
|
||||
|
|
@ -315,16 +353,59 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn search_summary_uses_bounded_goal_preview() {
|
||||
let parent_id = run_id("01KRBZW4DW0000000000000002");
|
||||
let mut run = run("01KRBZW5C00000000000000001", "keep", 30);
|
||||
run.parent_id = Some(parent_id);
|
||||
run.children_count = 4;
|
||||
run.goal = format!("{}tail-marker", "a".repeat(300));
|
||||
|
||||
let summary = search_run_summary_result(&run);
|
||||
|
||||
assert_eq!(summary.parent_id, Some(parent_id.to_string()));
|
||||
assert_eq!(summary.children_count, 4);
|
||||
assert!(summary.goal_truncated);
|
||||
assert!(summary.goal_preview.len() < run.goal.len());
|
||||
assert!(!summary.goal_preview.contains("tail-marker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_filter_keeps_matching_direct_children_and_composes_with_archived_default() {
|
||||
let parent_id = run_id("01KRBZW5000000000000000004");
|
||||
let other_parent_id = run_id("01KRBZW4000000000000000005");
|
||||
let mut active_child = run("01KRBZW5C00000000000000001", "keep", 30);
|
||||
active_child.parent_id = Some(parent_id);
|
||||
let mut archived_child = archived_run("01KRBZW4DW0000000000000002", "keep", 20);
|
||||
archived_child.parent_id = Some(parent_id);
|
||||
let mut unrelated_child = run("01KRBZW3EF0000000000000003", "keep", 10);
|
||||
unrelated_child.parent_id = Some(other_parent_id);
|
||||
|
||||
let result = filter_sort_and_page_runs(
|
||||
vec![
|
||||
archived_child.clone(),
|
||||
unrelated_child.clone(),
|
||||
active_child.clone(),
|
||||
],
|
||||
&FabroRunSearchParams {
|
||||
run_ids: None,
|
||||
workflow: None,
|
||||
labels: None,
|
||||
status: None,
|
||||
archived: None,
|
||||
created_after: None,
|
||||
created_before: None,
|
||||
first: Some(10),
|
||||
after: None,
|
||||
parent_id: Some("nightly-parent".to_string()),
|
||||
},
|
||||
None,
|
||||
Some(parent_id),
|
||||
)
|
||||
.expect("filtering should succeed");
|
||||
|
||||
let ids = result.runs.iter().map(|run| run.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids, vec![active_child.id]);
|
||||
}
|
||||
|
||||
fn run(id: &str, group: &str, seconds: u32) -> Run {
|
||||
run_with_archived(id, group, seconds, false)
|
||||
}
|
||||
|
|
@ -333,6 +414,10 @@ mod tests {
|
|||
run_with_archived(id, group, seconds, true)
|
||||
}
|
||||
|
||||
fn run_id(raw: &str) -> fabro_types::RunId {
|
||||
raw.parse().expect("test run id should parse")
|
||||
}
|
||||
|
||||
fn run_with_archived(id: &str, group: &str, seconds: u32, archived: bool) -> Run {
|
||||
let created_at = Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, seconds).unwrap();
|
||||
Run {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ impl FabroMcpServer {
|
|||
|
||||
#[tool(
|
||||
name = "fabro_run_create",
|
||||
description = "Create one or more Fabro workflow runs, starting them by default."
|
||||
description = "Create one or more Fabro workflow runs, optionally under a parent run, starting them by default."
|
||||
)]
|
||||
async fn fabro_run_create(
|
||||
&self,
|
||||
|
|
@ -71,7 +71,7 @@ impl FabroMcpServer {
|
|||
|
||||
#[tool(
|
||||
name = "fabro_run_search",
|
||||
description = "Search Fabro workflow runs by id, workflow, labels, status, archival state, and creation time."
|
||||
description = "Search Fabro workflow runs by id, parent, workflow, labels, status, archival state, and creation time."
|
||||
)]
|
||||
async fn fabro_run_search(
|
||||
&self,
|
||||
|
|
@ -93,7 +93,7 @@ impl FabroMcpServer {
|
|||
|
||||
#[tool(
|
||||
name = "fabro_run_interact",
|
||||
description = "Get, start, message, interrupt, cancel, archive, unarchive, inspect questions, or answer a Fabro run."
|
||||
description = "Get, start, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect questions, or answer a Fabro run."
|
||||
)]
|
||||
async fn fabro_run_interact(
|
||||
&self,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue