fabro/lib/crates/fabro-tool/src/common.rs
fabro-sh-0530[bot] 0754f1ca4a
Add fabro_run_get read-only run inspection tool (#358)
## Summary

Adds a new `fabro_run_get` MCP tool that returns a run's summary,
resolved ID, projection, and pending questions without any mutation
capability. This separates read-only inspection from operational
control, allowing Ask Fabro sessions to inspect runs safely without
access to write operations.

## What changed and why

**New tool (`fabro-tool/src/get.rs`):** `FabroRunGetParams` /
`ValidatedRunGet` / `RunGetResult` follow the same
validation-and-dispatch pattern as other run tools. The implementation
resolves a selector, then fans out to three read-only API calls
(retrieve run, get state, list questions) and assembles them into a
single structured result.

**Tool registry and dispatch:** `FABRO_RUN_GET_TOOL_NAME` is exported
from `common.rs` and `lib.rs`, added to `TOOL_DEFINITIONS`, wired into
the MCP stdio server (`fabro-mcp-server/src/server.rs`), and dispatched
in the LLM agent executor (`fabro-workflow/src/handler/llm/api.rs`).

**Ask Fabro access policy (`fabro-server/.../sessions.rs`):** The
session now registers and allows only `fabro_run_events` +
`fabro_run_get` via the new `ASK_FABRO_RUN_TOOL_NAMES` constant.
`fabro_run_interact` is explicitly moved to the denied set, closing off
mutation from that session type. The policy match arm is refactored from
a hardcoded `|`-chain to a slice `contains` check so the constant is the
single source of truth.

**Docs:** `mcp.mdx` now lists `fabro_run_get` as the inspection tool and
redescribes `fabro_run_interact` as control-oriented.

**Backward compatibility:** `fabro_run_interact` (including `get` and
`get_questions` actions) is unchanged and still fully operational for
contexts that allow it.

### Plan Summary

- New `get.rs` module in `fabro-tool` with validation, async fetch, and
unit tests
- Constants + schema registration in `common.rs` / `lib.rs`
- MCP server and LLM dispatch branches added for
`FABRO_RUN_GET_TOOL_NAME`
- Ask Fabro session swaps `FABRO_RUN_INTERACT_TOOL_NAME` →
`FABRO_RUN_GET_TOOL_NAME` in registry and policy
- MCP integration tests: tool count constant, schema assertions, two new
end-to-end tests
(`mcp_get_resolves_selector_and_returns_summary_projection_and_questions`,
`mcp_get_rejects_blank_run_id_before_auth_or_network`)
- Public MCP docs updated


### Fabro Details

<details>
<summary>Ran 9 stages in 43m 30s for $13.42</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 2s | – | 0 |
| preflight_lint | 2m 14s | – | 0 |
| implement | 19m 43s | $8.85 | 0 |
| simplify_opus | 5m 52s | $1.45 | 0 |
| simplify_gpt | 10m 16s | $3.12 | 0 |
| verify | 2m 53s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **43m 30s** | **$13.42** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
2026-05-22 19:03:56 -04:00

367 lines
13 KiB
Rust

use std::collections::HashMap;
use std::path::Path;
use std::sync::LazyLock;
use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, Utc};
use fabro_api::types;
use fabro_types::{
PairId, PairMessageRecord, PairMessageRequest, PairRecord, PairTranscriptResponse, Run, RunId,
RunPairStatusResponse, RunStatus, StageId,
};
use fabro_util::exit::{self, ExitClass};
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug)]
pub struct ToolError {
message: String,
}
impl ToolError {
pub fn message(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn from_anyhow(err: &anyhow::Error) -> Self {
Self::message(format_tool_error(err))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.message
}
}
impl std::fmt::Display for ToolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ToolError {}
pub type ToolResult<T> = Result<T, ToolError>;
#[async_trait]
pub trait FabroToolBackend: Send + Sync {
async fn create_run_from_spec(
&self,
spec: &crate::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
parent_id: Option<RunId>,
) -> anyhow::Result<RunId>;
async fn resolve_run(&self, selector: &str) -> anyhow::Result<Run>;
async fn retrieve_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
async fn start_run(&self, run_id: &RunId, resume: bool) -> anyhow::Result<Run>;
async fn cancel_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
async fn interrupt_run(&self, run_id: &RunId) -> anyhow::Result<()>;
async fn steer_run(&self, run_id: &RunId, text: String, interrupt: bool) -> anyhow::Result<()>;
async fn archive_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
async fn unarchive_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
async fn list_store_runs(&self) -> anyhow::Result<Vec<Run>>;
async fn list_store_runs_by_parent(&self, parent_id: RunId) -> anyhow::Result<Vec<Run>>;
async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> anyhow::Result<Run>;
async fn unlink_run_parent(&self, child_id: &RunId) -> anyhow::Result<Run>;
async fn get_run_state(&self, run_id: &RunId) -> anyhow::Result<fabro_types::RunProjection>;
async fn list_run_events(
&self,
run_id: &RunId,
after: Option<u32>,
limit: Option<usize>,
) -> anyhow::Result<Vec<fabro_types::EventEnvelope>>;
async fn list_run_events_until(
&self,
run_id: &RunId,
after: Option<u32>,
limit: usize,
) -> anyhow::Result<Vec<fabro_types::EventEnvelope>>;
async fn list_run_questions(&self, run_id: &RunId) -> anyhow::Result<Vec<types::ApiQuestion>>;
async fn submit_run_answer(
&self,
run_id: &RunId,
question_id: &str,
body: types::SubmitAnswerRequest,
) -> anyhow::Result<()>;
async fn get_run_pair_status(&self, _run_id: &RunId) -> anyhow::Result<RunPairStatusResponse> {
Err(ToolError::message(format!("{FABRO_RUN_PAIR_TOOL_NAME} is not available")).into())
}
async fn start_run_pair(
&self,
_run_id: &RunId,
_stage_id: StageId,
) -> anyhow::Result<PairRecord> {
Err(ToolError::message(format!("{FABRO_RUN_PAIR_TOOL_NAME} is not available")).into())
}
async fn get_run_pair(&self, _run_id: &RunId, _pair_id: &PairId) -> anyhow::Result<PairRecord> {
Err(ToolError::message(format!("{FABRO_RUN_PAIR_TOOL_NAME} is not available")).into())
}
async fn end_run_pair(&self, _run_id: &RunId, _pair_id: &PairId) -> anyhow::Result<PairRecord> {
Err(ToolError::message(format!("{FABRO_RUN_PAIR_TOOL_NAME} is not available")).into())
}
async fn send_run_pair_message(
&self,
_run_id: &RunId,
_pair_id: &PairId,
_request: PairMessageRequest,
) -> anyhow::Result<PairMessageRecord> {
Err(ToolError::message(format!("{FABRO_RUN_PAIR_TOOL_NAME} is not available")).into())
}
async fn get_run_pair_transcript(
&self,
_run_id: &RunId,
_pair_id: &PairId,
_since_seq: Option<u32>,
_limit: Option<u32>,
) -> anyhow::Result<PairTranscriptResponse> {
Err(ToolError::message(format!("{FABRO_RUN_PAIR_TOOL_NAME} is not available")).into())
}
}
pub trait RunManifestBuilder: Send + Sync {
fn build_run_manifest(
&self,
spec: &crate::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest>;
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct RunSummaryResult {
pub run_id: String,
pub parent_id: Option<String>,
pub children_count: u64,
pub workflow_name: Option<String>,
pub workflow_graph_name: Option<String>,
pub workflow_slug: Option<String>,
pub status: String,
pub archived: bool,
pub created_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub labels: HashMap<String, String>,
pub source_directory: Option<String>,
pub repo_origin_url: Option<String>,
pub goal: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ToolDefinition {
pub name: &'static str,
pub description: &'static str,
pub parameters: Value,
}
pub const FABRO_RUN_CREATE_TOOL_NAME: &str = "fabro_run_create";
pub const FABRO_RUN_SEARCH_TOOL_NAME: &str = "fabro_run_search";
pub const FABRO_RUN_GET_TOOL_NAME: &str = "fabro_run_get";
pub const FABRO_RUN_INTERACT_TOOL_NAME: &str = "fabro_run_interact";
pub const FABRO_RUN_GATHER_TOOL_NAME: &str = "fabro_run_gather";
pub const FABRO_RUN_EVENTS_TOOL_NAME: &str = "fabro_run_events";
pub const FABRO_RUN_PAIR_TOOL_NAME: &str = "fabro_run_pair";
static TOOL_DEFINITIONS: LazyLock<Vec<ToolDefinition>> = LazyLock::new(|| {
vec![
tool_definition::<crate::FabroRunCreateParams>(
FABRO_RUN_CREATE_TOOL_NAME,
"Create one or more Fabro workflow runs, optionally under a parent run, starting them by default.",
),
tool_definition::<crate::FabroRunSearchParams>(
FABRO_RUN_SEARCH_TOOL_NAME,
"Search Fabro workflow runs by id, parent, workflow, labels, status, archival state, and creation time.",
),
tool_definition::<crate::FabroRunGetParams>(
FABRO_RUN_GET_TOOL_NAME,
"Read-only inspection of a Fabro run: returns its summary, projection, and pending questions without mutating state.",
),
tool_definition::<crate::FabroRunInteractParams>(
FABRO_RUN_INTERACT_TOOL_NAME,
"Control a Fabro run: start, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. Use fabro_run_get for read-only inspection.",
),
tool_definition::<crate::FabroRunGatherParams>(
FABRO_RUN_GATHER_TOOL_NAME,
"Wait for Fabro runs to reach terminal states, returning current state on timeout.",
),
tool_definition::<crate::FabroRunEventsParams>(
FABRO_RUN_EVENTS_TOOL_NAME,
"List, inspect, or search stored events for a Fabro workflow run.",
),
]
});
#[must_use]
pub fn tool_definitions() -> &'static [ToolDefinition] {
TOOL_DEFINITIONS.as_slice()
}
fn tool_definition<T>(name: &'static str, description: &'static str) -> ToolDefinition
where
T: JsonSchema,
{
ToolDefinition {
name,
description,
parameters: serde_json::to_value(schemars::schema_for!(T))
.expect("tool parameter schema should serialize"),
}
}
pub(super) fn validate_len(name: &str, len: usize, min: usize, max: usize) -> ToolResult<()> {
if len < min {
return Err(ToolError::message(format!(
"{name} must contain at least {min} item(s)"
)));
}
if len > max {
return Err(ToolError::message(format!(
"{name} must contain no more than {max} item(s)"
)));
}
Ok(())
}
pub(super) async fn retrieve_run(
backend: &dyn FabroToolBackend,
run_id: &RunId,
) -> ToolResult<Run> {
backend
.retrieve_run(run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))
}
pub(crate) 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_graph_name: run.workflow.graph_name.clone(),
workflow_slug: run.workflow.slug.clone(),
status: run_status_kind(run.lifecycle.status).to_string(),
archived: run.lifecycle.archived,
created_at: run.timestamps.created_at.to_rfc3339(),
started_at: run
.timestamps
.started_at
.map(|timestamp| timestamp.to_rfc3339()),
completed_at: run
.timestamps
.completed_at
.map(|timestamp| timestamp.to_rfc3339()),
labels: run.labels.clone(),
source_directory: run.source_directory.clone(),
repo_origin_url: run
.repository
.as_ref()
.and_then(|repository| repository.origin_url.clone()),
goal: run.goal.clone(),
}
}
pub(crate) fn parse_datetime_filter(name: &str, raw: &str) -> ToolResult<DateTime<Utc>> {
if let Ok(timestamp) = DateTime::parse_from_rfc3339(raw) {
return Ok(timestamp.with_timezone(&Utc));
}
let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").map_err(|err| {
ToolError::message(format!("{name} must be RFC3339 or YYYY-MM-DD: {err}"))
})?;
let datetime = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| ToolError::message(format!("{name} contains an invalid date")))?;
Ok(DateTime::from_naive_utc_and_offset(datetime, Utc))
}
pub(crate) fn run_status_kind(status: RunStatus) -> &'static str {
status.kind().into()
}
fn format_tool_error(err: &anyhow::Error) -> String {
let mut rendered = format!("{err:#}");
if exit::exit_class_for(err) == Some(ExitClass::AuthRequired)
&& !rendered.contains("fabro auth login")
{
rendered.push_str("\nRun `fabro auth login` to authenticate.");
}
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: Some("Simple".to_string()),
graph_name: Some("GraphName".to_string()),
node_count: 0,
edge_count: 0,
},
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,
},
timing: None,
billing: None,
ask_fabro: fabro_types::AskFabro::default(),
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);
assert_eq!(summary.workflow_name.as_deref(), Some("Simple"));
assert_eq!(summary.workflow_graph_name.as_deref(), Some("GraphName"));
}
fn run_id(raw: &str) -> RunId {
raw.parse().expect("test run id should parse")
}
}