feat(agent): expose Fabro run tools in sessions (#339)

## Summary

API-backed Fabro agent sessions can now use the same five run-control
tools that were previously MCP-only, while the server uses a single
scoped `FABRO_WORKER_TOKEN` path for worker authorization. This lets
server-dispatched API agents create, gather, inspect, and interact with
runs without reintroducing a separate delegated run-agent token or
leaking credentials into sandboxed child environments.

## Changes

- Moved the reusable run-tool implementation into the new `fabro-tool`
crate so MCP and API agent backends share the same tool schemas and
client behavior.
- Registered the five `fabro_run_*` tools for API-mode agents, with ACP
sessions continuing to omit those tools.
- Replaced `FABRO_RUN_AGENT_TOKEN` with scoped worker-token auth: base
worker tokens keep same-run access, and `run:worker agent:run_tools`
tokens can call the run-control API across runs.
- Added server auth guards for run-tool actors and
run-scoped-or-run-tools routes, then applied them only to the routes
used by the run-tool client backend.
- Kept `FABRO_WORKER_TOKEN` scrubbed from sandbox commands, hooks, ACP
subprocesses, MCP env providers, and other child tool environments.

## Testing

- `cargo nextest run -p fabro-server worker_token principal_middleware
spawn_env --no-fail-fast`
- `cargo nextest run -p fabro-cli runner --no-fail-fast`
- `cargo nextest run -p fabro-workflow agent_run --no-fail-fast`
- `cargo nextest run -p fabro-static --no-fail-fast`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy -p fabro-server -p fabro-cli -p
fabro-static --all-targets -- -D warnings`
- `git diff --check`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
Bryan Helmkamp 2026-05-21 19:48:54 -04:00 • committed by GitHub
parent 4e4ab091d4
commit fb2174c7d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2881 additions and 1180 deletions

25
Cargo.lock generated
View file

@ -1753,6 +1753,7 @@ dependencies = [
"fabro-telemetry",
"fabro-template",
"fabro-test",
"fabro-tool",
"fabro-types",
"fabro-util",
"fabro-validate",
@ -2114,6 +2115,7 @@ dependencies = [
"fabro-manifest",
"fabro-model",
"fabro-server",
"fabro-tool",
"fabro-types",
"fabro-util",
"futures",
@ -2278,6 +2280,7 @@ dependencies = [
"fabro-static",
"fabro-store",
"fabro-test",
"fabro-tool",
"fabro-types",
"fabro-util",
"fabro-validate",
@ -2446,6 +2449,26 @@ dependencies = [
"twin-openai",
]
[[package]]
name = "fabro-tool"
version = "0.240.0-nightly.1"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"fabro-api",
"fabro-client",
"fabro-types",
"fabro-util",
"futures",
"schemars 1.2.1",
"serde",
"serde_json",
"tempfile",
"tokio",
"toml 0.8.23",
]
[[package]]
name = "fabro-tracker"
version = "0.240.0-nightly.1"
@ -2539,6 +2562,7 @@ dependencies = [
"dirs",
"fabro-acp",
"fabro-agent",
"fabro-api",
"fabro-auth",
"fabro-checkpoint",
"fabro-config",
@ -2560,6 +2584,7 @@ dependencies = [
"fabro-store",
"fabro-template",
"fabro-test",
"fabro-tool",
"fabro-types",
"fabro-util",
"fabro-validate",

View file

@ -51,6 +51,7 @@ fabro-util = { path = "../fabro-util" }
fabro-http.workspace = true
fabro-static.workspace = true
fabro-template = { path = "../fabro-template" }
fabro-tool = { path = "../fabro-tool" }
clap.workspace = true
clap_complete.workspace = true
cli-table.workspace = true

View file

@ -11,12 +11,16 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use fabro_api::types::RunManifest;
use fabro_config::user::active_settings_path;
use fabro_config::{ServerSettingsBuilder, Storage, load_llm_catalog_settings};
use fabro_interview::{
AnswerSubmission, ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage,
};
use fabro_model::Catalog;
use fabro_server::run_tool_manifest;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_tool::fabro_client::ClientBackend;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{RunMode, RunNamespace};
use fabro_types::{
@ -29,6 +33,7 @@ use fabro_workflow::event::{Emitter, RunEventSink};
use fabro_workflow::operations::{self, StartServices};
use fabro_workflow::run_control::RunControlState;
use fabro_workflow::runtime_store::{RunStoreBackend, RunStoreHandle};
use fabro_workflow::services::FabroRunToolServices;
#[cfg(unix)]
use tokio::signal::unix::{SignalKind, signal};
use tokio::sync::{Mutex, RwLock as AsyncRwLock, mpsc};
@ -77,11 +82,25 @@ pub(crate) async fn execute(
.await
.with_context(|| format!("failed to load run state for {run_id}"))?;
let run_spec = &run_state.spec;
let llm_catalog_settings =
load_llm_catalog_settings(None).context("failed to load worker LLM catalog settings")?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.context("failed to build worker LLM catalog")?,
);
let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader(
run_id,
client.clone_for_reuse(),
worker_token.to_owned(),
)));
let fabro_run_tools = build_fabro_run_tool_services(
worker_token,
client.clone_for_reuse(),
run_id,
run_spec.source_directory.as_deref(),
&run_dir,
Arc::clone(&catalog),
);
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
let emitter = Arc::new(Emitter::new(run_id));
@ -94,12 +113,6 @@ pub(crate) async fn execute(
let run_control = RunControlState::new();
install_signal_handlers(Arc::clone(&run_control), cancel_token.clone())?;
let vault = load_worker_vault(storage_dir.as_deref())?;
let llm_catalog_settings =
load_llm_catalog_settings(None).context("failed to load worker LLM catalog settings")?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.context("failed to build worker LLM catalog")?,
);
let github_app = {
let vault_guard = match &vault {
Some(arc) => Some(arc.read().await),
@ -137,6 +150,7 @@ pub(crate) async fn execute(
catalog,
on_node: None,
registry_override: None,
fabro_run_tools,
};
match mode {
@ -151,6 +165,47 @@ pub(crate) async fn execute(
Ok(())
}
fn build_fabro_run_tool_services(
worker_token: &str,
client: fabro_client::Client,
current_run_id: RunId,
source_directory: Option<&str>,
run_dir: &Path,
catalog: Arc<Catalog>,
) -> Option<FabroRunToolServices> {
if worker_token.trim().is_empty() {
return None;
}
let backend = ClientBackend::new(Arc::new(client))
.with_manifest_builder(Arc::new(WorkerRunManifestBuilder { catalog }));
Some(FabroRunToolServices {
backend: Arc::new(backend),
current_run_id,
base_cwd: source_directory.map_or_else(|| run_dir.to_path_buf(), PathBuf::from),
user_settings_path: active_settings_path(None),
})
}
struct WorkerRunManifestBuilder {
catalog: Arc<Catalog>,
}
impl fabro_tool::RunManifestBuilder for WorkerRunManifestBuilder {
fn build_run_manifest(
&self,
spec: &fabro_tool::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> fabro_tool::ToolResult<RunManifest> {
run_tool_manifest::build_run_tool_manifest(
spec,
cwd,
user_settings_path,
Arc::clone(&self.catalog),
)
}
}
fn load_worker_vault(storage_dir: Option<&Path>) -> Result<Option<Arc<AsyncRwLock<Vault>>>> {
let Some(storage_dir) = storage_dir else {
return Ok(None);

View file

@ -54,11 +54,11 @@ async fn main() {
// Capture the worker bearer token immediately and scrub it from the process
// env before any subprocess can be spawned. Every descendant of the worker
// (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore
// inherits a process env that no longer contains FABRO_WORKER_TOKEN, so an
// inherits a process env that no longer contains this credential, so an
// unscrubbed spawn site cannot leak it. The token flows to `runner::execute`
// through an explicit function argument instead of the environment.
// through explicit function arguments instead of the environment.
let worker_token = if subcommand == Some("__run-worker") {
let token = process_env_var(EnvVars::FABRO_WORKER_TOKEN);
let worker_token = process_env_var(EnvVars::FABRO_WORKER_TOKEN);
#[expect(
clippy::disallowed_methods,
reason = "Scrub the worker bearer from this process's env before any \
@ -67,7 +67,7 @@ async fn main() {
{
std::env::remove_var(EnvVars::FABRO_WORKER_TOKEN);
}
token
worker_token
} else {
None
};

View file

@ -21,6 +21,7 @@ fabro-manifest = { path = "../fabro-manifest" }
fabro-config = { path = "../fabro-config" }
fabro-model = { path = "../fabro-model" }
fabro-server = { path = "../fabro-server" }
fabro-tool = { path = "../fabro-tool" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
futures.workspace = true

View file

@ -1,5 +1,5 @@
mod config;
mod run_tools;
mod manifest_builder;
mod server;
use std::future::Future;

View file

@ -0,0 +1,36 @@
use std::path::Path;
use std::sync::Arc;
use fabro_api::types;
use fabro_config::load_llm_catalog_settings;
use fabro_model::Catalog;
use fabro_server::run_tool_manifest;
use fabro_tool::{RunManifestBuilder, ToolError, ToolResult, ValidatedCreateRunSpec};
#[derive(Default)]
pub(crate) struct McpRunManifestBuilder;
impl RunManifestBuilder for McpRunManifestBuilder {
fn build_run_manifest(
&self,
spec: &ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest> {
build_mcp_run_manifest(spec, cwd, user_settings_path)
}
}
fn build_mcp_run_manifest(
spec: &ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest> {
let llm_catalog_settings = load_llm_catalog_settings(Some(user_settings_path))
.map_err(|err| ToolError::message(err.to_string()))?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.map_err(|err| ToolError::message(err.to_string()))?,
);
run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path, catalog)
}

View file

@ -1,21 +0,0 @@
#![allow(
dead_code,
reason = "MCP DTO fields are consumed by serde and schema generation even when not read directly."
)]
mod common;
mod create;
mod events;
mod gather;
mod interact;
mod manifest;
mod search;
pub(crate) use common::{ToolError, error_result, success_result};
pub(crate) use create::{FabroRunCreateParams, ValidatedCreateRuns, create_runs, create_runs_text};
pub(crate) use events::{FabroRunEventsParams, ValidatedRunEvents, run_events, run_events_text};
pub(crate) use gather::{FabroRunGatherParams, ValidatedGatherRuns, gather_runs, gather_runs_text};
pub(crate) use interact::{
FabroRunInteractParams, ValidatedInteractRun, interact_run, interact_run_text,
};
pub(crate) use search::{FabroRunSearchParams, ValidatedSearchRuns, search_runs, search_runs_text};

View file

@ -1,215 +0,0 @@
use std::collections::HashMap;
use chrono::{DateTime, NaiveDate, Utc};
use fabro_client::Client;
use fabro_types::{Run, RunId, RunStatus};
use fabro_util::exit::{self, ExitClass};
use rmcp::model::{CallToolResult, Content};
use schemars::JsonSchema;
use serde::Serialize;
#[derive(Debug)]
pub(crate) struct ToolError {
message: String,
}
impl ToolError {
pub(crate) fn message(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub(crate) fn from_anyhow(err: &anyhow::Error) -> Self {
Self::message(format_tool_error(err))
}
pub(crate) fn as_str(&self) -> &str {
&self.message
}
}
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: Option<String>,
pub(crate) workflow_graph_name: Option<String>,
pub(crate) workflow_slug: Option<String>,
pub(crate) status: String,
pub(crate) archived: bool,
pub(crate) created_at: String,
pub(crate) started_at: Option<String>,
pub(crate) completed_at: Option<String>,
pub(crate) labels: HashMap<String, String>,
pub(crate) source_directory: Option<String>,
pub(crate) repo_origin_url: Option<String>,
pub(crate) goal: String,
}
pub(crate) fn success_result<T: Serialize>(
value: &T,
text: impl Into<String>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let structured_content = serde_json::to_value(value).map_err(|err| {
rmcp::ErrorData::internal_error(
format!("failed to serialize Fabro MCP tool result: {err}"),
None,
)
})?;
let mut result = CallToolResult::structured(structured_content);
result.content = vec![Content::text(text.into())];
Ok(result)
}
pub(crate) fn error_result(err: ToolError) -> CallToolResult {
CallToolResult::error(vec![Content::text(err.message)])
}
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(client: &Client, run_id: &RunId) -> ToolResult<Run> {
client
.retrieve_run(run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))
}
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_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(super) 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(super) 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,
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);
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")
}
}

View file

@ -1,411 +0,0 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fabro_client::Client;
use fabro_types::RunId;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::common::{ToolError, ToolResult};
use super::{common, manifest};
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct FabroRunCreateParams {
pub(crate) runs: Vec<CreateRunSpec>,
}
#[derive(Debug, Deserialize, JsonSchema)]
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>,
#[serde(default)]
pub(crate) labels: HashMap<String, String>,
pub(crate) dry_run: Option<bool>,
pub(crate) auto_approve: Option<bool>,
pub(crate) model: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) sandbox: Option<String>,
pub(crate) preserve_sandbox: Option<bool>,
pub(crate) start: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub(crate) struct RunInputValue(Value);
impl From<Value> for RunInputValue {
fn from(value: Value) -> Self {
Self(value)
}
}
impl RunInputValue {
fn into_inner(self) -> Value {
self.0
}
}
impl JsonSchema for RunInputValue {
fn inline_schema() -> bool {
true
}
fn schema_name() -> Cow<'static, str> {
"RunInputValue".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Run input override value. Inputs are TOML-compatible scalar values: string, boolean, integer, or float.",
"anyOf": [
{ "type": "string" },
{ "type": "boolean" },
{ "type": "integer" },
{ "type": "number" }
]
})
}
}
#[derive(Debug)]
pub(crate) struct ValidatedCreateRuns {
pub(crate) runs: Vec<ValidatedCreateRunSpec>,
}
#[derive(Debug)]
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>,
pub(crate) dry_run: Option<bool>,
pub(crate) auto_approve: Option<bool>,
pub(crate) model: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) sandbox: Option<String>,
pub(crate) preserve_sandbox: Option<bool>,
pub(crate) start: Option<bool>,
}
impl TryFrom<FabroRunCreateParams> for ValidatedCreateRuns {
type Error = ToolError;
fn try_from(params: FabroRunCreateParams) -> Result<Self, Self::Error> {
common::validate_len("runs", params.runs.len(), 1, 50)?;
let runs = params
.runs
.into_iter()
.map(ValidatedCreateRunSpec::try_from)
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { runs })
}
}
impl TryFrom<CreateRunSpec> for ValidatedCreateRunSpec {
type Error = ToolError;
fn try_from(spec: CreateRunSpec) -> Result<Self, Self::Error> {
let run_id = spec
.run_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.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()
.map(|(key, value)| {
let value = value.into_inner();
manifest::json_to_toml_value(&key, &value).map(|value| (key, value))
})
.collect::<ToolResult<HashMap<_, _>>>()?;
Ok(Self {
workflow: spec.workflow,
cwd: spec.cwd,
run_id,
parent_id,
goal: spec.goal,
inputs,
labels: spec.labels,
dry_run: spec.dry_run,
auto_approve: spec.auto_approve,
model: spec.model,
provider: spec.provider,
sandbox: spec.sandbox,
preserve_sandbox: spec.preserve_sandbox,
start: spec.start,
})
}
}
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct CreateRunsResult {
pub(crate) runs: Vec<CreatedRunResult>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct CreatedRunResult {
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(
client: Arc<Client>,
base_cwd: &Path,
user_settings_path: &Path,
params: ValidatedCreateRuns,
) -> ToolResult<CreateRunsResult> {
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 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
.map_err(|err| ToolError::from_anyhow(&err))?;
let started = spec.start.unwrap_or(true);
let summary = if started {
client
.start_run(&run_id, false)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
} else {
client
.retrieve_run(&run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
};
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(),
});
}
Ok(CreateRunsResult { runs: created })
}
pub(crate) fn create_runs_text(result: &CreateRunsResult) -> String {
let started = result.runs.iter().filter(|run| run.started).count();
format!(
"created {} Fabro run(s), started {started}",
result.runs.len()
)
}
#[cfg(test)]
mod tests {
use schemars::SchemaGenerator;
use serde_json::json;
use tokio::fs;
use super::*;
#[test]
fn run_input_value_schema_allows_only_json_scalars() {
let mut generator = SchemaGenerator::default();
let schema = RunInputValue::json_schema(&mut generator);
let schema = serde_json::to_value(schema).expect("schema should serialize");
assert_eq!(
schema["anyOf"],
json!([
{ "type": "string" },
{ "type": "boolean" },
{ "type": "integer" },
{ "type": "number" },
])
);
}
#[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
}
})
}
}

View file

@ -1,188 +0,0 @@
use std::path::{Path, PathBuf};
use fabro_api::types;
use fabro_config::{CliLayer, RunLayer, load_llm_catalog_settings};
use fabro_manifest::{self, ManifestBuildInput, RunOverrideInput};
use fabro_model::Catalog;
use fabro_server::manifest_validation;
use serde_json::Value;
use super::common::{ToolError, ToolResult};
use super::create::ValidatedCreateRunSpec;
pub(super) fn build_mcp_run_manifest(
spec: &ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest> {
let built = fabro_manifest::build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(&spec.workflow),
cwd: cwd.to_path_buf(),
run_overrides: mcp_run_overrides(spec),
cli_overrides: Some(CliLayer::default()),
input_overrides: spec.inputs.clone(),
args: mcp_manifest_args(spec),
run_id: spec.run_id,
user_settings_path: Some(user_settings_path.to_path_buf()),
})
.map_err(|err| ToolError::from_anyhow(&err))?;
let llm_catalog_settings = load_llm_catalog_settings(Some(user_settings_path))
.map_err(|err| ToolError::message(err.to_string()))?;
let catalog = std::sync::Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.map_err(|err| ToolError::message(err.to_string()))?,
);
let mut validation =
manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest, catalog)
.map_err(|err| ToolError::from_anyhow(&err))?;
manifest_validation::promote_template_undefined_variables_to_errors(&mut validation);
if !validation.ok {
return Err(ToolError::message("workflow manifest validation failed"));
}
Ok(built.manifest)
}
pub(super) fn json_to_toml_value(key: &str, value: &Value) -> ToolResult<toml::Value> {
match value {
Value::Null => Err(ToolError::message(format!(
"input `{key}` cannot be null; use a string, boolean, or number"
))),
Value::Bool(value) => Ok(toml::Value::Boolean(*value)),
Value::Number(value) => {
if let Some(integer) = value.as_i64() {
Ok(toml::Value::Integer(integer))
} else if let Some(float) = value.as_f64() {
Ok(toml::Value::Float(float))
} else {
Err(ToolError::message(format!(
"input `{key}` contains a number outside TOML's supported range"
)))
}
}
Value::String(value) => Ok(toml::Value::String(value.clone())),
Value::Array(_) => Err(ToolError::message(format!(
"input `{key}` does not support array values; use a string, boolean, or number",
))),
Value::Object(_) => Err(ToolError::message(format!(
"input `{key}` does not support object values; use a string, boolean, or number",
))),
}
}
fn mcp_manifest_args(spec: &ValidatedCreateRunSpec) -> Option<types::ManifestArgs> {
let mut input = spec
.inputs
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
input.sort();
let mut label = spec
.labels
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
label.sort();
let payload = types::ManifestArgs {
auto_approve: spec.auto_approve.filter(|value| *value),
docker_image: None,
dry_run: spec.dry_run.filter(|value| *value),
input,
label,
model: spec.model.clone(),
preserve_sandbox: spec.preserve_sandbox.filter(|value| *value),
provider: spec.provider.clone(),
sandbox: spec.sandbox.clone(),
verbose: None,
};
(!fabro_manifest::manifest_args_is_empty(&payload)).then_some(payload)
}
fn mcp_run_overrides(spec: &ValidatedCreateRunSpec) -> Option<RunLayer> {
fabro_manifest::build_sparse_run_overrides(RunOverrideInput {
goal: spec.goal.as_deref(),
model: spec.model.as_deref(),
provider: spec.provider.as_deref(),
sandbox: spec.sandbox.as_deref(),
docker_image: None,
preserve_sandbox: spec.preserve_sandbox,
dry_run: spec.dry_run,
auto_approve: spec.auto_approve,
labels: spec.labels.clone(),
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::{Value, json};
use super::super::create::CreateRunSpec;
use super::*;
#[test]
fn json_inputs_convert_scalar_values_to_toml_values() {
let cases = [
(json!("hello"), toml::Value::String("hello".to_string())),
(json!(true), toml::Value::Boolean(true)),
(json!(42), toml::Value::Integer(42)),
(json!(0.5), toml::Value::Float(0.5)),
];
for (json, expected) in cases {
assert_eq!(json_to_toml_value("input", &json).unwrap(), expected);
}
}
#[test]
fn json_input_arrays_and_objects_are_rejected() {
let array_err = json_to_toml_value("matrix", &json!(["a", 1])).unwrap_err();
assert_eq!(
array_err.as_str(),
"input `matrix` does not support array values; use a string, boolean, or number",
);
let object_err = json_to_toml_value("settings", &json!({ "enabled": true })).unwrap_err();
assert_eq!(
object_err.as_str(),
"input `settings` does not support object values; use a string, boolean, or number",
);
}
#[test]
fn json_input_null_is_rejected_with_key_name() {
let err = json_to_toml_value("goal", &Value::Null).unwrap_err();
assert_eq!(
err.as_str(),
"input `goal` cannot be null; use a string, boolean, or number",
);
}
#[test]
fn mcp_manifest_args_preserve_input_provenance() {
let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec {
workflow: "simple".to_string(),
run_id: None,
parent_id: None,
cwd: None,
goal: None,
inputs: HashMap::from([
("count".to_string(), json!(3).into()),
("decision".to_string(), json!("approve").into()),
]),
labels: HashMap::new(),
model: None,
provider: None,
sandbox: None,
dry_run: None,
auto_approve: None,
preserve_sandbox: None,
start: None,
})
.expect("create spec should validate");
let args = mcp_manifest_args(&spec).expect("input args should be present");
assert_eq!(args.input, vec![r"count=3", r#"decision="approve""#]);
}
}

View file

@ -2,20 +2,23 @@ use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use fabro_client::Client;
use fabro_tool::fabro_client::ClientBackend;
use fabro_tool::{self as run_tools, FabroToolBackend};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, ServerCapabilities, ServerInfo};
use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo};
use rmcp::transport::stdio;
use rmcp::{ErrorData, ServerHandler, serve_server, tool, tool_handler, tool_router};
use serde::Serialize;
use tokio::sync::OnceCell;
use crate::{FabroMcpServerSettings, run_tools};
use crate::FabroMcpServerSettings;
use crate::manifest_builder::McpRunManifestBuilder;
#[derive(Clone)]
pub(crate) struct FabroMcpServer {
settings: Arc<FabroMcpServerSettings>,
client: Arc<OnceCell<Arc<Client>>>,
backend: Arc<OnceCell<Arc<dyn FabroToolBackend>>>,
cwd: PathBuf,
tool_router: ToolRouter<Self>,
}
@ -41,7 +44,7 @@ impl FabroMcpServer {
let cwd = settings.cwd.clone();
Self {
settings,
client: Arc::new(OnceCell::new()),
backend: Arc::new(OnceCell::new()),
cwd,
tool_router: Self::tool_router(),
}
@ -57,15 +60,15 @@ impl FabroMcpServer {
) -> Result<CallToolResult, ErrorData> {
let params = match run_tools::ValidatedCreateRuns::try_from(params.0) {
Ok(params) => params,
Err(err) => return Ok(run_tools::error_result(err)),
Err(err) => return Ok(error_result(&err)),
};
let client = match self.client().await {
Ok(client) => client,
Err(err) => return Ok(run_tools::error_result(err)),
let backend = match self.backend().await {
Ok(backend) => backend,
Err(err) => return Ok(error_result(&err)),
};
match run_tools::create_runs(client, &self.cwd, &self.settings.config_path, params).await {
Ok(result) => run_tools::success_result(&result, run_tools::create_runs_text(&result)),
Err(err) => Ok(run_tools::error_result(err)),
match run_tools::create_runs(backend, &self.cwd, &self.settings.config_path, params).await {
Ok(result) => success_result(&result, run_tools::create_runs_text(&result)),
Err(err) => Ok(error_result(&err)),
}
}
@ -79,15 +82,15 @@ impl FabroMcpServer {
) -> Result<CallToolResult, ErrorData> {
let params = match run_tools::ValidatedSearchRuns::try_from(params.0) {
Ok(params) => params,
Err(err) => return Ok(run_tools::error_result(err)),
Err(err) => return Ok(error_result(&err)),
};
let client = match self.client().await {
Ok(client) => client,
Err(err) => return Ok(run_tools::error_result(err)),
let backend = match self.backend().await {
Ok(backend) => backend,
Err(err) => return Ok(error_result(&err)),
};
match run_tools::search_runs(client, params).await {
Ok(result) => run_tools::success_result(&result, run_tools::search_runs_text(&result)),
Err(err) => Ok(run_tools::error_result(err)),
match run_tools::search_runs(backend, params).await {
Ok(result) => success_result(&result, run_tools::search_runs_text(&result)),
Err(err) => Ok(error_result(&err)),
}
}
@ -101,15 +104,15 @@ impl FabroMcpServer {
) -> Result<CallToolResult, ErrorData> {
let params = match run_tools::ValidatedInteractRun::try_from(params.0) {
Ok(params) => params,
Err(err) => return Ok(run_tools::error_result(err)),
Err(err) => return Ok(error_result(&err)),
};
let client = match self.client().await {
Ok(client) => client,
Err(err) => return Ok(run_tools::error_result(err)),
let backend = match self.backend().await {
Ok(backend) => backend,
Err(err) => return Ok(error_result(&err)),
};
match run_tools::interact_run(client, params).await {
Ok(result) => run_tools::success_result(&result, run_tools::interact_run_text(&result)),
Err(err) => Ok(run_tools::error_result(err)),
match run_tools::interact_run(backend, params).await {
Ok(result) => success_result(&result, run_tools::interact_run_text(&result)),
Err(err) => Ok(error_result(&err)),
}
}
@ -123,15 +126,15 @@ impl FabroMcpServer {
) -> Result<CallToolResult, ErrorData> {
let params = match run_tools::ValidatedGatherRuns::try_from(params.0) {
Ok(params) => params,
Err(err) => return Ok(run_tools::error_result(err)),
Err(err) => return Ok(error_result(&err)),
};
let client = match self.client().await {
Ok(client) => client,
Err(err) => return Ok(run_tools::error_result(err)),
let backend = match self.backend().await {
Ok(backend) => backend,
Err(err) => return Ok(error_result(&err)),
};
match run_tools::gather_runs(client, params).await {
Ok(result) => run_tools::success_result(&result, run_tools::gather_runs_text(&result)),
Err(err) => Ok(run_tools::error_result(err)),
match run_tools::gather_runs(backend, params).await {
Ok(result) => success_result(&result, run_tools::gather_runs_text(&result)),
Err(err) => Ok(error_result(&err)),
}
}
@ -145,27 +148,51 @@ impl FabroMcpServer {
) -> Result<CallToolResult, ErrorData> {
let params = match run_tools::ValidatedRunEvents::try_from(params.0) {
Ok(params) => params,
Err(err) => return Ok(run_tools::error_result(err)),
Err(err) => return Ok(error_result(&err)),
};
let client = match self.client().await {
Ok(client) => client,
Err(err) => return Ok(run_tools::error_result(err)),
let backend = match self.backend().await {
Ok(backend) => backend,
Err(err) => return Ok(error_result(&err)),
};
match run_tools::run_events(client, params).await {
Ok(result) => run_tools::success_result(&result, run_tools::run_events_text(&result)),
Err(err) => Ok(run_tools::error_result(err)),
match run_tools::run_events(backend, params).await {
Ok(result) => success_result(&result, run_tools::run_events_text(&result)),
Err(err) => Ok(error_result(&err)),
}
}
async fn client(&self) -> Result<Arc<Client>, run_tools::ToolError> {
self.client
async fn backend(&self) -> Result<Arc<dyn FabroToolBackend>, run_tools::ToolError> {
self.backend
.get_or_try_init(|| async {
(self.settings.client_factory)()
.await
.map(Arc::new)
.map(|client| {
Arc::new(
ClientBackend::new(Arc::new(client))
.with_manifest_builder(Arc::new(McpRunManifestBuilder)),
) as Arc<dyn FabroToolBackend>
})
.map_err(|err| run_tools::ToolError::from_anyhow(&err))
})
.await
.map(Arc::clone)
}
}
fn success_result<T: Serialize>(
value: &T,
text: impl Into<String>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let structured_content = serde_json::to_value(value).map_err(|err| {
rmcp::ErrorData::internal_error(
format!("failed to serialize Fabro MCP tool result: {err}"),
None,
)
})?;
let mut result = CallToolResult::structured(structured_content);
result.content = vec![Content::text(text.into())];
Ok(result)
}
fn error_result(err: &run_tools::ToolError) -> CallToolResult {
CallToolResult::error(vec![Content::text(err.to_string())])
}

View file

@ -38,6 +38,7 @@ fabro-llm = { path = "../fabro-llm" }
fabro-manifest = { path = "../fabro-manifest" }
fabro-model = { path = "../fabro-model" }
fabro-proc = { path = "../fabro-proc" }
fabro-tool = { path = "../fabro-tool" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
fabro-api = { path = "../fabro-api" }

View file

@ -31,6 +31,7 @@ mod run_files;
mod run_files_security;
mod run_manifest;
mod run_selector;
pub mod run_tool_manifest;
pub mod security_headers;
pub mod serve;
pub mod server;

View file

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
@ -14,7 +15,7 @@ use crate::auth::{AuthErrorCode, JwtError, REFRESH_TOKEN_PREFIX};
use crate::error::ApiError;
use crate::jwt_auth::{self, AuthMode, ConfiguredAuth};
use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path};
use crate::worker_token::{self, WORKER_TOKEN_KID};
use crate::worker_token::{self, WORKER_TOKEN_KID, WorkerScopeSet};
#[derive(Clone, Debug)]
pub(crate) struct RequestAuthContext {
@ -22,6 +23,7 @@ pub(crate) struct RequestAuthContext {
pub auth_status: AuthStatus,
pub auth_error_code: Option<AuthErrorCode>,
pub user_profile: Option<UserProfile>,
pub worker_scopes: WorkerScopeSet,
}
#[derive(Clone, Debug)]
@ -54,7 +56,9 @@ pub(crate) struct AuthContextSlot(pub(crate) Arc<Mutex<RequestAuthContext>>);
pub(crate) struct RequestAuth(pub(crate) AuthContextSlot);
pub(crate) struct RequiredUser(pub(crate) UserPrincipal);
pub(crate) struct RequiredRunToolActor(pub(crate) Principal);
pub(crate) struct RequireRunScoped(pub(crate) RunId);
pub(crate) struct RequireRunScopedOrRunTools(pub(crate) RunId, pub(crate) Principal);
pub(crate) struct RequireRunBlob(pub(crate) RunId, pub(crate) RunBlobId);
pub(crate) struct RequireRunStageScoped(pub(crate) RunId, pub(crate) String);
pub(crate) struct RequireStageArtifact(pub(crate) RunId, pub(crate) StageId);
@ -74,6 +78,7 @@ impl RequestAuthContext {
auth_status: AuthStatus::Missing,
auth_error_code: None,
user_profile: None,
worker_scopes: WorkerScopeSet::default(),
}
}
@ -84,6 +89,18 @@ impl RequestAuthContext {
auth_status: AuthStatus::Authenticated,
auth_error_code: None,
user_profile,
worker_scopes: WorkerScopeSet::default(),
}
}
#[must_use]
pub(crate) fn authenticated_worker(run_id: RunId, scopes: WorkerScopeSet) -> Self {
Self {
principal: Principal::Worker { run_id },
auth_status: AuthStatus::Authenticated,
auth_error_code: None,
user_profile: None,
worker_scopes: scopes,
}
}
@ -110,6 +127,7 @@ impl RequestAuthContext {
auth_status: status,
auth_error_code: code,
user_profile: None,
worker_scopes: WorkerScopeSet::default(),
}
}
@ -197,6 +215,19 @@ impl<S: Send + Sync> FromRequestParts<S> for RequiredUser {
}
}
impl<S: Send + Sync> FromRequestParts<S> for RequiredRunToolActor {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
let slot = parts
.extensions
.get::<AuthContextSlot>()
.cloned()
.unwrap_or_else(AuthContextSlot::initial);
require_run_tool_actor(&slot).map(Self)
}
}
impl FromRequestParts<Arc<AppState>> for RequireRunScoped {
type Rejection = Response;
@ -214,6 +245,30 @@ impl FromRequestParts<Arc<AppState>> for RequireRunScoped {
}
}
impl FromRequestParts<Arc<AppState>> for RequireRunScopedOrRunTools {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path(params): Path<HashMap<String, String>> = Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let Some(id) = params.get("id") else {
return Err(
ApiError::new(StatusCode::BAD_REQUEST, "Run ID path parameter missing.")
.into_response(),
);
};
let run_id = parse_run_id_path(id)?;
let actor =
require_worker_or_user_for_run_or_run_tools(&auth_slot_from_parts(parts), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id, actor))
}
}
impl FromRequestParts<Arc<AppState>> for RequireRunBlob {
type Rejection = Response;
@ -339,6 +394,18 @@ pub(crate) fn require_authenticated_user(
}
}
pub(crate) fn require_run_tool_actor(slot: &AuthContextSlot) -> Result<Principal, ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(user) => Ok(Principal::User(user.clone())),
Principal::Worker { run_id } if context.worker_scopes.has_agent_run_tools() => {
Ok(Principal::Worker { run_id: *run_id })
}
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
fn require_worker_or_user_for_run(
slot: &AuthContextSlot,
route_run_id: &RunId,
@ -352,6 +419,23 @@ fn require_worker_or_user_for_run(
}
}
fn require_worker_or_user_for_run_or_run_tools(
slot: &AuthContextSlot,
route_run_id: &RunId,
) -> Result<Principal, ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::User(user) => Ok(Principal::User(user.clone())),
Principal::Worker { run_id }
if run_id == route_run_id || context.worker_scopes.has_agent_run_tools() =>
{
Ok(Principal::Worker { run_id: *run_id })
}
Principal::Worker { .. } => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
fn classify_request(req: &Request, state: &AppState) -> RequestAuthContext {
let AuthMode::Enabled(config) = req
.extensions()
@ -391,7 +475,7 @@ fn classify_request(req: &Request, state: &AppState) -> RequestAuthContext {
if header.kid.as_deref() == Some(WORKER_TOKEN_KID) {
return match worker_token::decode_worker_token(token, state.worker_token_keys()) {
Ok(run_id) => RequestAuthContext::authenticated(Principal::Worker { run_id }, None),
Ok(decoded) => RequestAuthContext::authenticated_worker(decoded.run_id, decoded.scopes),
Err(JwtError::AccessTokenExpired) => RequestAuthContext::rejected(
AuthStatus::Expired,
Some(AuthErrorCode::AccessTokenExpired),
@ -460,8 +544,8 @@ mod tests {
use super::*;
use crate::auth::{self, AuthErrorCode};
use crate::worker_token::{
WORKER_TOKEN_ISSUER, WORKER_TOKEN_SCOPE, WorkerTokenClaims, issue_worker_token,
worker_token_header,
WORKER_RUN_TOOLS_SCOPE, WORKER_TOKEN_ISSUER, WORKER_TOKEN_SCOPE, WorkerScopeSet,
WorkerTokenClaims, issue_worker_token, worker_token_header,
};
const TEST_JWT_ISSUER: &str = "https://fabro.example";
@ -604,6 +688,26 @@ mod tests {
assert_eq!(context.auth_status, AuthStatus::Authenticated);
assert_eq!(context.principal, Principal::Worker { run_id });
assert!(!context.worker_scopes.has_agent_run_tools());
}
#[test]
fn classifies_run_tools_worker_scope() {
let state = crate::test_support::test_app_state();
let run_id = RunId::new();
let token = issue_worker_claims(
state.as_ref(),
run_id,
u64::MAX / 2,
&format!("{WORKER_TOKEN_SCOPE} {WORKER_RUN_TOOLS_SCOPE}"),
);
let request = request_with_bearer(Some(&token), auth_mode_for_state(state.as_ref()));
let context = classify_request(&request, state.as_ref());
assert_eq!(context.auth_status, AuthStatus::Authenticated);
assert_eq!(context.principal, Principal::Worker { run_id });
assert!(context.worker_scopes.has_agent_run_tools());
}
#[test]
@ -713,4 +817,50 @@ mod tests {
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
assert_eq!(err.code(), Some("access_token_invalid"));
}
#[test]
fn run_tool_actor_rejects_base_worker_scope() {
let run_id = RunId::new();
let slot = AuthContextSlot::initial();
slot.replace(RequestAuthContext::authenticated(
Principal::Worker { run_id },
None,
));
let err = require_run_tool_actor(&slot).unwrap_err();
assert_eq!(err.status(), StatusCode::FORBIDDEN);
}
#[test]
fn run_tool_actor_accepts_worker_with_run_tools_scope() {
let run_id = RunId::new();
let slot = AuthContextSlot::initial();
slot.replace(RequestAuthContext::authenticated_worker(
run_id,
WorkerScopeSet::run_worker_with_agent_run_tools(),
));
assert_eq!(require_run_tool_actor(&slot).unwrap(), Principal::Worker {
run_id
},);
}
#[test]
fn run_scoped_or_run_tools_accepts_cross_run_with_run_tools_scope() {
let token_run_id = RunId::new();
let route_run_id = RunId::new();
let slot = AuthContextSlot::initial();
slot.replace(RequestAuthContext::authenticated_worker(
token_run_id,
WorkerScopeSet::run_worker_with_agent_run_tools(),
));
assert_eq!(
require_worker_or_user_for_run_or_run_tools(&slot, &route_run_id).unwrap(),
Principal::Worker {
run_id: token_run_id,
},
);
}
}

View file

@ -0,0 +1,119 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fabro_api::types;
use fabro_config::{CliLayer, RunLayer};
use fabro_manifest::{ManifestBuildInput, RunOverrideInput};
use fabro_model::Catalog;
use fabro_tool::{ToolError, ToolResult, ValidatedCreateRunSpec};
use crate::manifest_validation;
pub fn build_run_tool_manifest(
spec: &ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
catalog: Arc<Catalog>,
) -> ToolResult<types::RunManifest> {
let built = fabro_manifest::build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(&spec.workflow),
cwd: cwd.to_path_buf(),
run_overrides: run_tool_run_overrides(spec),
cli_overrides: Some(CliLayer::default()),
input_overrides: spec.inputs.clone(),
args: run_tool_manifest_args(spec),
run_id: spec.run_id,
user_settings_path: Some(user_settings_path.to_path_buf()),
})
.map_err(|err| ToolError::from_anyhow(&err))?;
let mut validation =
manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest, catalog)
.map_err(|err| ToolError::from_anyhow(&err))?;
manifest_validation::promote_template_undefined_variables_to_errors(&mut validation);
if !validation.ok {
return Err(ToolError::message("workflow manifest validation failed"));
}
Ok(built.manifest)
}
pub fn run_tool_manifest_args(spec: &ValidatedCreateRunSpec) -> Option<types::ManifestArgs> {
let mut input = spec
.inputs
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
input.sort();
let mut label = spec
.labels
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
label.sort();
let payload = types::ManifestArgs {
auto_approve: spec.auto_approve.filter(|value| *value),
docker_image: None,
dry_run: spec.dry_run.filter(|value| *value),
input,
label,
model: spec.model.clone(),
preserve_sandbox: spec.preserve_sandbox.filter(|value| *value),
provider: spec.provider.clone(),
sandbox: spec.sandbox.clone(),
verbose: None,
};
(!fabro_manifest::manifest_args_is_empty(&payload)).then_some(payload)
}
pub fn run_tool_run_overrides(spec: &ValidatedCreateRunSpec) -> Option<RunLayer> {
fabro_manifest::build_sparse_run_overrides(RunOverrideInput {
goal: spec.goal.as_deref(),
model: spec.model.as_deref(),
provider: spec.provider.as_deref(),
sandbox: spec.sandbox.as_deref(),
docker_image: None,
preserve_sandbox: spec.preserve_sandbox,
dry_run: spec.dry_run,
auto_approve: spec.auto_approve,
labels: spec.labels.clone(),
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_tool::CreateRunSpec;
use serde_json::json;
use super::*;
#[test]
fn manifest_args_preserve_input_provenance() {
let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec {
workflow: "simple".to_string(),
run_id: None,
parent_id: None,
cwd: None,
goal: None,
inputs: HashMap::from([
("count".to_string(), json!(3).into()),
("decision".to_string(), json!("approve").into()),
]),
labels: HashMap::new(),
model: None,
provider: None,
sandbox: None,
dry_run: None,
auto_approve: None,
preserve_sandbox: None,
start: None,
})
.expect("create spec should validate");
let args = run_tool_manifest_args(&spec).expect("input args should be present");
assert_eq!(args.input, vec![r"count=3", r#"decision="approve""#]);
}
}

View file

@ -132,13 +132,14 @@ use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware};
use crate::jwt_auth::{self, AuthMode};
use crate::principal_middleware::{
AuthContextSlot, RequestAuth, RequestAuthContext, RequireRunBlob, RequireRunScoped,
RequireRunStageScoped, RequireStageArtifact, RequiredUser, principal_middleware,
RequireRunScopedOrRunTools, RequireRunStageScoped, RequireStageArtifact, RequiredUser,
principal_middleware,
};
use crate::request_id::{self, RequestId};
use crate::run_files::{FilesInFlight, new_files_in_flight};
use crate::server_secrets::{LlmClientResult, ServerSecrets};
use crate::spawn_env::{apply_render_graph_env, apply_worker_env};
use crate::worker_token::{WorkerTokenKeys, issue_worker_token};
use crate::worker_token::{WorkerScopeSet, WorkerTokenKeys, issue_worker_token_with_scopes};
use crate::{
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files,
vault_legacy_migration, web_auth,
@ -2732,8 +2733,12 @@ fn worker_command(
)
})?;
let server_target = daemon.bind.to_target();
let worker_token = issue_worker_token(state.worker_token_keys(), &run_id)
.map_err(|_| anyhow::anyhow!("failed to sign worker token"))?;
let worker_token = issue_worker_token_with_scopes(
state.worker_token_keys(),
&run_id,
WorkerScopeSet::run_worker_with_agent_run_tools(),
)
.map_err(|_| anyhow::anyhow!("failed to sign worker token"))?;
let server_destination = resolved_log_destination(state)?;
let worker_stdout = match server_destination {
LogDestination::Stdout => Stdio::inherit(),
@ -3197,6 +3202,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
catalog: state.catalog(),
on_node: None,
registry_override,
fabro_run_tools: None,
};
let execution = async {

View file

@ -9,10 +9,10 @@ use fabro_workflow::event::build_redacted_event_payload;
use super::super::{
ApiError, AppState, AppendEventResponse, BroadcastStream, Event, EventBody, EventEnvelope,
EventPayload, HashSet, IntoResponse, Json, KeepAlive, PaginatedEventList, PaginationMeta, Path,
Query, RequireRunScoped, RequireRunStageScoped, RequiredUser, Response, Router, RunEvent,
RunId, Sse, State, StatusCode, StreamExt, UnboundedReceiverStream, broadcast, get, mpsc,
parse_run_id_path, parse_stage_id_path, redact_jsonl_line, reject_if_archived,
update_live_run_from_event,
Query, RequireRunScoped, RequireRunScopedOrRunTools, RequireRunStageScoped, RequiredUser,
Response, Router, RunEvent, RunId, Sse, State, StatusCode, StreamExt, UnboundedReceiverStream,
broadcast, get, mpsc, parse_run_id_path, parse_stage_id_path, redact_jsonl_line,
reject_if_archived, update_live_run_from_event,
};
pub(super) fn routes() -> Router<Arc<AppState>> {
@ -198,7 +198,7 @@ async fn append_run_event(
}
async fn list_run_events(
RequireRunScoped(id): RequireRunScoped,
RequireRunScopedOrRunTools(id, _actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Query(params): Query<EventListParams>,
) -> Response {

View file

@ -2,12 +2,12 @@ use std::sync::Arc;
use super::super::{
ApiError, AppState, FailureReason, ForkRequest, ForkResponse, IntoResponse, Json, Path,
Principal, RequiredUser, Response, RewindRequest, RewindResponse, Router, RunAnswerTransport,
RunControlAction, RunExecutionMode, RunId, RunStatus, StartRunRequest, State, StatusCode,
Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError, append_control_request,
durable_run_status, get, load_pending_control, managed_run, operations, parse_run_id_path,
persist_cancelled_run_status, post, reject_if_archived, sleep, update_live_run_from_event,
workflow_event,
Principal, RequireRunScopedOrRunTools, RequiredUser, Response, RewindRequest, RewindResponse,
Router, RunAnswerTransport, RunControlAction, RunExecutionMode, RunId, RunStatus,
StartRunRequest, State, StatusCode, Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE,
WorkflowError, append_control_request, durable_run_status, get, load_pending_control,
managed_run, operations, parse_run_id_path, persist_cancelled_run_status, post,
reject_if_archived, sleep, update_live_run_from_event, workflow_event,
};
pub(super) fn routes() -> Router<Arc<AppState>> {
@ -34,15 +34,10 @@ async fn run_response(state: &AppState, id: RunId, status: StatusCode) -> Respon
}
async fn start_run(
_auth: RequiredUser,
RequireRunScopedOrRunTools(id, _actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
body: Option<Json<StartRunRequest>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
@ -153,14 +148,9 @@ fn schedule_worker_kill(state: Arc<AppState>, run_id: RunId, worker_pid: u32) {
}
async fn cancel_run(
subject: RequiredUser,
RequireRunScopedOrRunTools(id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
@ -217,13 +207,8 @@ async fn cancel_run(
};
if pending_control != Some(RunControlAction::Cancel) {
if let Err(err) = append_control_request(
state.as_ref(),
id,
RunControlAction::Cancel,
Some(Principal::User(subject.0.clone())),
)
.await
if let Err(err) =
append_control_request(state.as_ref(), id, RunControlAction::Cancel, Some(actor)).await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
@ -456,19 +441,17 @@ async fn unpause_run(
}
async fn archive_run(
subject: RequiredUser,
RequireRunScopedOrRunTools(id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
run_archive_action(state, subject, id, ArchiveAction::Archive).await
run_archive_action(state, actor, id, ArchiveAction::Archive).await
}
async fn unarchive_run(
subject: RequiredUser,
RequireRunScopedOrRunTools(id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
run_archive_action(state, subject, id, ArchiveAction::Unarchive).await
run_archive_action(state, actor, id, ArchiveAction::Unarchive).await
}
async fn rewind_run(
@ -635,15 +618,11 @@ enum ArchiveAction {
async fn run_archive_action(
state: Arc<AppState>,
subject: RequiredUser,
id: String,
actor: Principal,
id: RunId,
action: ArchiveAction,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let actor = Some(Principal::User(subject.0.clone()));
let actor = Some(actor);
let result = match action {
ArchiveAction::Archive => operations::archive(&state.store, &id, actor)
.await

View file

@ -18,8 +18,7 @@ use fabro_api::types::{
use fabro_config::Storage;
use fabro_interview::AnswerSubmission;
use fabro_types::{
Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, UserPrincipal,
parse_blob_ref,
Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, parse_blob_ref,
};
use fabro_util::version::FABRO_VERSION;
use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice};
@ -36,7 +35,8 @@ use super::super::{
};
use crate::error::ApiError;
use crate::principal_middleware::{
RequestAuth, RequireCommandLog, RequireRunScoped, RequiredUser, require_user,
RequireCommandLog, RequireRunScoped, RequireRunScopedOrRunTools, RequiredRunToolActor,
RequiredUser,
};
use crate::run_files::{list_run_commits, list_run_files};
use crate::run_manifest;
@ -206,15 +206,10 @@ async fn list_board_runs(
}
async fn link_run_parent(
subject: RequiredUser,
RequireRunScopedOrRunTools(child_id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<UpdateRunParentRequest>,
) -> Response {
let child_id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
let parent_id = match req.parent_id.parse::<RunId>() {
Ok(parent_id) => parent_id,
Err(err) => {
@ -249,7 +244,7 @@ async fn link_run_parent(
&workflow_event::Event::RunParentLinked {
previous_parent_id: child.parent_id,
parent_id,
actor: Some(Principal::User(subject.0)),
actor: Some(actor),
},
)
.await
@ -260,14 +255,9 @@ async fn link_run_parent(
}
async fn unlink_run_parent(
subject: RequiredUser,
RequireRunScopedOrRunTools(child_id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let child_id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
let _parent_link_guard = state.parent_link_lock.lock().await;
let child = match state.store.get_cached_summary(&child_id).await {
Ok(Some(summary)) => summary,
@ -289,7 +279,7 @@ async fn unlink_run_parent(
&child_id,
&workflow_event::Event::RunParentUnlinked {
previous_parent_id,
actor: Some(Principal::User(subject.0)),
actor: Some(actor),
},
)
.await
@ -340,7 +330,7 @@ async fn updated_run_response(state: &AppState, run_id: &RunId) -> Response {
}
async fn list_runs(
_auth: RequiredUser,
_auth: RequiredRunToolActor,
State(state): State<Arc<AppState>>,
Query(params): Query<ListRunsParams>,
) -> Response {
@ -410,7 +400,7 @@ struct CommandLogResponseBody {
}
async fn resolve_run(
_auth: RequiredUser,
_auth: RequiredRunToolActor,
State(state): State<Arc<AppState>>,
Query(query): Query<ResolveRunQuery>,
) -> Response {
@ -527,15 +517,11 @@ async fn update_run(
}
async fn create_run(
RequestAuth(auth_slot): RequestAuth,
RequiredRunToolActor(actor): RequiredRunToolActor,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let subject = match require_user(&auth_slot) {
Ok(subject) => subject,
Err(err) => return err.into_response(),
};
let req = match serde_json::from_slice::<RunManifest>(&body) {
Ok(req) => req,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
@ -562,7 +548,7 @@ async fn create_run(
let mut create_input =
run_manifest::create_run_input(prepared.clone(), configured_providers, web_url.clone());
create_input.run_id = Some(run_id);
create_input.provenance = Some(run_provenance(&headers, &subject));
create_input.provenance = Some(run_provenance(&headers, &actor));
create_input.submitted_manifest_bytes = Some(body.to_vec());
let storage_root = match resolve_interp_string(&state.server_settings().server.storage.root) {
@ -622,13 +608,13 @@ async fn create_run(
(StatusCode::CREATED, Json(summary)).into_response()
}
fn run_provenance(headers: &HeaderMap, subject: &UserPrincipal) -> RunProvenance {
fn run_provenance(headers: &HeaderMap, subject: &Principal) -> RunProvenance {
RunProvenance {
server: Some(RunServerProvenance {
version: FABRO_VERSION.to_string(),
}),
client: run_client_provenance(headers),
subject: Some(Principal::User(subject.clone())),
subject: Some(subject.clone()),
}
}
@ -713,14 +699,9 @@ async fn validate_run_manifest(
}
async fn get_run_status(
_auth: RequiredUser,
RequireRunScopedOrRunTools(id, _actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
match state.store.get_cached_summary(&id).await {
Ok(Some(run)) => (StatusCode::OK, Json(run)).into_response(),
Ok(None) => ApiError::not_found("Run not found.").into_response(),
@ -755,14 +736,9 @@ async fn get_run_settings(
}
async fn get_questions(
_auth: RequiredUser,
RequireRunScopedOrRunTools(id, _actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
match state.store.get_cached_run(&id).await {
Ok(Some(cached)) => {
let questions = cached
@ -781,15 +757,11 @@ async fn get_questions(
}
async fn submit_answer(
auth: RequiredUser,
RequireRunScopedOrRunTools(id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path((id, qid)): Path<(String, String)>,
Path((_id, qid)): Path<(String, String)>,
Json(req): Json<SubmitAnswerRequest>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
@ -801,7 +773,7 @@ async fn submit_answer(
Ok(answer) => answer,
Err(response) => return response,
};
let submission = AnswerSubmission::new(answer, Principal::User(auth.0));
let submission = AnswerSubmission::new(answer, actor);
match submit_pending_interview_answer(state.as_ref(), &pending, submission).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(response) => response,
@ -809,7 +781,7 @@ async fn submit_answer(
}
async fn get_run_state(
RequireRunScoped(id): RequireRunScoped,
RequireRunScopedOrRunTools(id, _actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
) -> Response {
match state.store.get_cached_run(&id).await {

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
@ -9,11 +9,9 @@ use fabro_api::types::SteerRunRequest;
use fabro_types::Principal;
use fabro_workflow::run_status::RunStatus;
use super::super::{
AnswerTransportError, AppState, durable_run_status, parse_run_id_path, reject_if_archived,
};
use super::super::{AnswerTransportError, AppState, durable_run_status, reject_if_archived};
use crate::error::ApiError;
use crate::principal_middleware::RequiredUser;
use crate::principal_middleware::RequireRunScopedOrRunTools;
pub(super) fn routes() -> axum::Router<Arc<AppState>> {
axum::Router::new()
@ -34,9 +32,8 @@ impl RunControlRequest {
}
async fn steer_run(
auth: RequiredUser,
RequireRunScopedOrRunTools(id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<SteerRunRequest>,
) -> Response {
// OpenAPI enforces minLength=1/maxLength=8192 already; only whitespace-only
@ -52,27 +49,22 @@ async fn steer_run(
RunControlRequest::Steer { text }
};
control_run(auth, state, id, control).await
control_run(actor, state, id, control).await
}
async fn interrupt_run(
auth: RequiredUser,
RequireRunScopedOrRunTools(id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
control_run(auth, state, id, RunControlRequest::Interrupt).await
control_run(actor, state, id, RunControlRequest::Interrupt).await
}
async fn control_run(
auth: RequiredUser,
actor: Principal,
state: Arc<AppState>,
id: String,
id: fabro_types::RunId,
control: RunControlRequest,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
@ -155,7 +147,6 @@ async fn control_run(
.into_response();
};
let actor = Principal::User(auth.0);
let result = match control {
RunControlRequest::Steer { text } => answer_transport.steer(text, actor).await,
RunControlRequest::Interrupt => answer_transport.interrupt(actor).await,

View file

@ -573,7 +573,18 @@ fn issue_test_user_jwt() -> String {
fn issue_test_worker_token(run_id: &RunId) -> String {
let keys = WorkerTokenKeys::from_master_secret(TEST_SESSION_SECRET.as_bytes())
.expect("worker keys should derive");
issue_worker_token(&keys, run_id).expect("worker token should issue")
crate::worker_token::issue_worker_token(&keys, run_id).expect("worker token should issue")
}
fn issue_test_run_tools_worker_token(run_id: &RunId) -> String {
let keys = WorkerTokenKeys::from_master_secret(TEST_SESSION_SECRET.as_bytes())
.expect("worker keys should derive");
crate::worker_token::issue_worker_token_with_scopes(
&keys,
run_id,
crate::worker_token::WorkerScopeSet::run_worker_with_agent_run_tools(),
)
.expect("worker token should issue")
}
async fn create_run_with_bearer(app: &Router, bearer: &str) -> RunId {
@ -603,6 +614,21 @@ fn bearer_request(method: Method, path: &str, bearer: &str, body: Body) -> Reque
.unwrap()
}
fn json_bearer_request(
method: Method,
path: &str,
bearer: &str,
body: &serde_json::Value,
) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.header(header::AUTHORIZATION, format!("Bearer {bearer}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
fn canonical_origin_settings(url: &str) -> ServerSettings {
server_settings_from_toml(&format!(
r#"
@ -1535,6 +1561,10 @@ fn worker_command_always_sets_worker_token_env() {
.expect("github worker token should decode")
.claims;
assert_eq!(github_claims.run_id, github_run_id.to_string());
assert_eq!(
github_claims.scope.split_whitespace().collect::<Vec<_>>(),
vec!["run:worker", "agent:run_tools"]
);
let dev_token = tempfile::tempdir().unwrap();
let dev_token_state =
@ -1568,6 +1598,10 @@ fn worker_command_always_sets_worker_token_env() {
.expect("dev-token worker token should decode")
.claims;
assert_eq!(dev_claims.run_id, dev_token_run_id.to_string());
assert_eq!(
dev_claims.scope.split_whitespace().collect::<Vec<_>>(),
vec!["run:worker", "agent:run_tools"]
);
}
#[cfg(unix)]
@ -6940,6 +6974,23 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
.unwrap();
assert_status!(response, StatusCode::OK).await;
for path in [
format!("/runs/{run_id}"),
format!("/runs/{run_id}/questions"),
] {
let response = app
.clone()
.oneshot(bearer_request(
Method::GET,
&path,
&worker_token,
Body::empty(),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
}
let append_body = serde_json::to_vec(&serde_json::json!({
"id": "evt-run-notice",
"ts": "2026-04-23T12:00:00Z",
@ -7028,6 +7079,203 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
assert_status!(response, StatusCode::FORBIDDEN).await;
}
#[tokio::test]
async fn run_tool_worker_token_can_use_client_backend_routes_across_runs() {
let (state, app) = jwt_auth_app();
let user_jwt = issue_test_user_jwt();
let parent_run_id = create_run_with_bearer(&app, &user_jwt).await;
let target_run_id = create_run_with_bearer(&app, &user_jwt).await;
let run_tool_worker_token = issue_test_run_tools_worker_token(&parent_run_id);
let response = app
.clone()
.oneshot(bearer_request(
Method::GET,
"/runs",
&run_tool_worker_token,
Body::empty(),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
let response = app
.clone()
.oneshot(bearer_request(
Method::GET,
&format!("/runs/resolve?selector={target_run_id}"),
&run_tool_worker_token,
Body::empty(),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
for path in [
format!("/runs/{target_run_id}"),
format!("/runs/{target_run_id}/state"),
format!("/runs/{target_run_id}/events"),
format!("/runs/{target_run_id}/questions"),
] {
let response = app
.clone()
.oneshot(bearer_request(
Method::GET,
&path,
&run_tool_worker_token,
Body::empty(),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
}
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
&format!("/runs/{target_run_id}/start"),
&run_tool_worker_token,
&json!({ "resume": false }),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
&format!("/runs/{target_run_id}/cancel"),
&run_tool_worker_token,
Body::empty(),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
for path in [
format!("/runs/{target_run_id}/archive"),
format!("/runs/{target_run_id}/unarchive"),
format!("/runs/{target_run_id}/interrupt"),
] {
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
&path,
&run_tool_worker_token,
Body::empty(),
))
.await
.unwrap();
assert_ne!(response.status(), StatusCode::UNAUTHORIZED, "{path}");
assert_ne!(response.status(), StatusCode::FORBIDDEN, "{path}");
}
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
&format!("/runs/{target_run_id}/steer"),
&run_tool_worker_token,
&json!({ "text": "continue", "interrupt": false }),
))
.await
.unwrap();
assert_ne!(response.status(), StatusCode::UNAUTHORIZED);
assert_ne!(response.status(), StatusCode::FORBIDDEN);
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
&format!("/runs/{target_run_id}/questions/q-1/answer"),
&run_tool_worker_token,
&json!({ "kind": "yes" }),
))
.await
.unwrap();
assert_ne!(response.status(), StatusCode::UNAUTHORIZED);
assert_ne!(response.status(), StatusCode::FORBIDDEN);
let created_child = create_run_with_bearer(&app, &run_tool_worker_token).await;
let cached = state
.store
.get_cached_run(&created_child)
.await
.unwrap()
.expect("created run should be cached");
assert_eq!(
cached
.projection
.spec
.provenance
.as_ref()
.and_then(|provenance| provenance.subject.as_ref()),
Some(&Principal::Worker {
run_id: parent_run_id,
}),
);
let response = app
.clone()
.oneshot(json_bearer_request(
Method::PUT,
&format!("/runs/{created_child}/parent"),
&run_tool_worker_token,
&json!({ "parent_id": target_run_id.to_string() }),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
let response = app
.clone()
.oneshot(bearer_request(
Method::DELETE,
&format!("/runs/{created_child}/parent"),
&run_tool_worker_token,
Body::empty(),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
}
#[tokio::test]
async fn base_worker_token_is_rejected_by_run_tool_only_routes() {
let (_state, app) = jwt_auth_app();
let user_jwt = issue_test_user_jwt();
let run_id = create_run_with_bearer(&app, &user_jwt).await;
let worker_token = issue_test_worker_token(&run_id);
for (method, path) in [
(Method::GET, "/runs".to_string()),
(Method::POST, "/runs".to_string()),
(Method::GET, "/runs/resolve?selector=latest".to_string()),
] {
let response = app
.clone()
.oneshot(bearer_request(
method.clone(),
&path,
&worker_token,
Body::empty(),
))
.await
.unwrap();
assert!(
matches!(
response.status(),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
),
"{method} {path} unexpectedly accepted base worker token with status {}",
response.status()
);
}
}
#[tokio::test]
async fn worker_token_controls_stage_artifact_route() {
let (_state, app) = jwt_auth_app();
@ -7195,18 +7443,11 @@ async fn worker_token_is_rejected_on_user_only_routes() {
(Method::POST, "/graph/render".to_string()),
(Method::GET, "/attach".to_string()),
(Method::GET, "/boards/runs".to_string()),
(Method::GET, format!("/runs/{run_id}")),
(Method::DELETE, format!("/runs/{run_id}")),
(Method::GET, format!("/runs/{run_id}/questions")),
(Method::POST, format!("/runs/{run_id}/questions/q-1/answer")),
(Method::GET, format!("/runs/{run_id}/attach")),
(Method::GET, format!("/runs/{run_id}/checkpoint")),
(Method::POST, format!("/runs/{run_id}/cancel")),
(Method::POST, format!("/runs/{run_id}/start")),
(Method::POST, format!("/runs/{run_id}/pause")),
(Method::POST, format!("/runs/{run_id}/unpause")),
(Method::POST, format!("/runs/{run_id}/archive")),
(Method::POST, format!("/runs/{run_id}/unarchive")),
(Method::GET, format!("/runs/{run_id}/graph")),
(Method::GET, format!("/runs/{run_id}/graph/source")),
(Method::GET, format!("/runs/{run_id}/stages")),

View file

@ -98,6 +98,7 @@ mod tests {
("GITHUB_APP_CLIENT_SECRET".to_string(), "leak".to_string()),
("GITHUB_APP_WEBHOOK_SECRET".to_string(), "leak".to_string()),
("FABRO_DEV_TOKEN".to_string(), "garbage".to_string()),
("FABRO_WORKER_TOKEN".to_string(), "leak".to_string()),
("MY_API_KEY".to_string(), "blocked".to_string()),
]);
let mut cmd = env_command();
@ -132,6 +133,7 @@ mod tests {
assert!(!actual.contains_key("GITHUB_APP_PRIVATE_KEY"));
assert!(!actual.contains_key("GITHUB_APP_CLIENT_SECRET"));
assert!(!actual.contains_key("GITHUB_APP_WEBHOOK_SECRET"));
assert!(!actual.contains_key("FABRO_WORKER_TOKEN"));
assert!(!actual.contains_key("MY_API_KEY"));
}

View file

@ -14,6 +14,7 @@ use crate::auth::{self, JwtError, KeyDeriveError};
pub(crate) const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker";
pub(crate) const WORKER_TOKEN_KID: &str = "fabro-worker";
pub(crate) const WORKER_TOKEN_SCOPE: &str = "run:worker";
pub(crate) const WORKER_RUN_TOOLS_SCOPE: &str = "agent:run_tools";
pub(crate) const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60;
#[derive(Clone)]
@ -59,9 +60,59 @@ pub(crate) struct WorkerTokenClaims {
pub(crate) jti: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct WorkerScopeSet {
agent_run_tools: bool,
}
impl WorkerScopeSet {
#[cfg(test)]
#[must_use]
pub(crate) const fn run_worker() -> Self {
Self {
agent_run_tools: false,
}
}
#[must_use]
pub(crate) const fn run_worker_with_agent_run_tools() -> Self {
Self {
agent_run_tools: true,
}
}
#[must_use]
pub(crate) const fn has_agent_run_tools(self) -> bool {
self.agent_run_tools
}
fn claim(self) -> String {
if self.agent_run_tools {
[WORKER_TOKEN_SCOPE, WORKER_RUN_TOOLS_SCOPE].join(" ")
} else {
WORKER_TOKEN_SCOPE.to_string()
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct DecodedWorkerToken {
pub(crate) run_id: RunId,
pub(crate) scopes: WorkerScopeSet,
}
#[cfg(test)]
pub(crate) fn issue_worker_token(
keys: &WorkerTokenKeys,
run_id: &RunId,
) -> Result<String, ApiError> {
issue_worker_token_with_scopes(keys, run_id, WorkerScopeSet::run_worker())
}
pub(crate) fn issue_worker_token_with_scopes(
keys: &WorkerTokenKeys,
run_id: &RunId,
scopes: WorkerScopeSet,
) -> Result<String, ApiError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -71,7 +122,7 @@ pub(crate) fn issue_worker_token(
iat: now,
exp: now + WORKER_TOKEN_TTL_SECS,
run_id: run_id.to_string(),
scope: WORKER_TOKEN_SCOPE.to_string(),
scope: scopes.claim(),
jti: Uuid::new_v4().simple().to_string(),
};
jsonwebtoken::encode(&worker_token_header(), &claims, &keys.encoding).map_err(|err| {
@ -88,7 +139,10 @@ pub(crate) fn worker_token_header() -> Header {
header
}
pub(crate) fn decode_worker_token(token: &str, keys: &WorkerTokenKeys) -> Result<RunId, JwtError> {
pub(crate) fn decode_worker_token(
token: &str,
keys: &WorkerTokenKeys,
) -> Result<DecodedWorkerToken, JwtError> {
let claims = jsonwebtoken::decode::<WorkerTokenClaims>(token, &keys.decoding, &keys.validation)
.map_err(|err| match err.kind() {
ErrorKind::ExpiredSignature => JwtError::AccessTokenExpired,
@ -96,20 +150,39 @@ pub(crate) fn decode_worker_token(token: &str, keys: &WorkerTokenKeys) -> Result
})?
.claims;
if claims.scope != WORKER_TOKEN_SCOPE {
let scopes = parse_worker_scopes(&claims.scope).map_err(|()| {
warn!(
target: "worker_auth",
jti = %claims.jti,
reason = "wrong_scope",
"worker token rejected"
);
return Err(JwtError::AccessTokenInvalid);
}
JwtError::AccessTokenInvalid
})?;
claims
let run_id = claims
.run_id
.parse()
.map_err(|_| JwtError::AccessTokenInvalid)
.map_err(|_| JwtError::AccessTokenInvalid)?;
Ok(DecodedWorkerToken { run_id, scopes })
}
fn parse_worker_scopes(scope: &str) -> Result<WorkerScopeSet, ()> {
let mut has_run_worker = false;
let mut has_agent_run_tools = false;
for scope in scope.split_whitespace() {
match scope {
WORKER_TOKEN_SCOPE => has_run_worker = true,
WORKER_RUN_TOOLS_SCOPE => has_agent_run_tools = true,
_ => return Err(()),
}
}
if !has_run_worker {
return Err(());
}
Ok(WorkerScopeSet {
agent_run_tools: has_agent_run_tools,
})
}
#[cfg(test)]
@ -121,8 +194,9 @@ mod tests {
use uuid::Uuid;
use super::{
WORKER_TOKEN_ISSUER, WORKER_TOKEN_KID, WORKER_TOKEN_SCOPE, WorkerTokenClaims,
WorkerTokenKeys, decode_worker_token, issue_worker_token, worker_token_header,
WORKER_RUN_TOOLS_SCOPE, WORKER_TOKEN_ISSUER, WORKER_TOKEN_KID, WORKER_TOKEN_SCOPE,
WorkerScopeSet, WorkerTokenClaims, WorkerTokenKeys, decode_worker_token,
issue_worker_token, issue_worker_token_with_scopes, worker_token_header,
};
use crate::auth::{self, JwtError};
@ -138,12 +212,20 @@ mod tests {
}
fn wrong_scope_token(keys: &WorkerTokenKeys, run_id: &fabro_types::RunId) -> String {
worker_token_with_scope(keys, run_id, "wrong:scope")
}
fn worker_token_with_scope(
keys: &WorkerTokenKeys,
run_id: &fabro_types::RunId,
scope: &str,
) -> String {
let claims = WorkerTokenClaims {
iss: WORKER_TOKEN_ISSUER.to_string(),
iat: 1,
exp: u64::MAX / 2,
run_id: run_id.to_string(),
scope: "wrong:scope".to_string(),
scope: scope.to_string(),
jti: Uuid::new_v4().simple().to_string(),
};
jsonwebtoken::encode(&worker_token_header(), &claims, &keys.encoding)
@ -249,8 +331,53 @@ mod tests {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = issue_worker_token(&keys, &run_id).expect("worker token should issue");
let decoded = decode_worker_token(&token, &keys).unwrap();
assert_eq!(decode_worker_token(&token, &keys).unwrap(), run_id);
assert_eq!(decoded.run_id, run_id);
assert!(!decoded.scopes.has_agent_run_tools());
}
#[test]
fn decode_worker_token_exposes_run_tool_scope() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = issue_worker_token_with_scopes(
&keys,
&run_id,
WorkerScopeSet::run_worker_with_agent_run_tools(),
)
.expect("worker token should issue");
let decoded = decode_worker_token(&token, &keys).unwrap();
assert_eq!(decoded.run_id, run_id);
assert!(decoded.scopes.has_agent_run_tools());
}
#[test]
fn decode_worker_token_accepts_space_delimited_run_tool_scope_claim() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = worker_token_with_scope(
&keys,
&run_id,
&format!("{WORKER_TOKEN_SCOPE} {WORKER_RUN_TOOLS_SCOPE}"),
);
let decoded = decode_worker_token(&token, &keys).unwrap();
assert_eq!(decoded.run_id, run_id);
assert!(decoded.scopes.has_agent_run_tools());
}
#[test]
fn decode_worker_token_rejects_missing_base_scope() {
let run_id = run_id();
let keys = keys(TEST_SECRET);
let token = worker_token_with_scope(&keys, &run_id, WORKER_RUN_TOOLS_SCOPE);
assert_eq!(
decode_worker_token(&token, &keys).expect_err("base worker scope should be required"),
JwtError::AccessTokenInvalid,
);
}
#[test]

View file

@ -0,0 +1,31 @@
[package]
name = "fabro-tool"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Shared Fabro run-control tool behavior"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
chrono = { workspace = true, features = ["serde"] }
fabro-api = { path = "../fabro-api" }
fabro-client = { path = "../fabro-client" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
futures.workspace = true
schemars = "1.2.1"
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
toml.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,319 @@
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::{Run, RunId, RunStatus};
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<()>;
}
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_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";
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::FabroRunInteractParams>(
FABRO_RUN_INTERACT_TOOL_NAME,
"Get, start, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect questions, or answer a Fabro run.",
),
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,
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);
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")
}
}

View file

@ -0,0 +1,646 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fabro_types::RunId;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::common::{self, FabroToolBackend, ToolError, ToolResult};
use super::manifest;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FabroRunCreateParams {
pub runs: Vec<CreateRunSpec>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CreateRunSpec {
pub workflow: String,
pub cwd: Option<PathBuf>,
pub run_id: Option<String>,
pub parent_id: Option<String>,
pub goal: Option<String>,
#[serde(default)]
pub inputs: HashMap<String, RunInputValue>,
#[serde(default)]
pub labels: HashMap<String, String>,
pub dry_run: Option<bool>,
pub auto_approve: Option<bool>,
pub model: Option<String>,
pub provider: Option<String>,
pub sandbox: Option<String>,
pub preserve_sandbox: Option<bool>,
pub start: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub struct RunInputValue(Value);
impl From<Value> for RunInputValue {
fn from(value: Value) -> Self {
Self(value)
}
}
impl RunInputValue {
pub(crate) fn into_inner(self) -> Value {
self.0
}
}
impl JsonSchema for RunInputValue {
fn inline_schema() -> bool {
true
}
fn schema_name() -> Cow<'static, str> {
"RunInputValue".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Run input override value. Inputs are TOML-compatible scalar values: string, boolean, integer, or float.",
"anyOf": [
{ "type": "string" },
{ "type": "boolean" },
{ "type": "integer" },
{ "type": "number" }
]
})
}
}
#[derive(Debug)]
pub struct ValidatedCreateRuns {
pub runs: Vec<ValidatedCreateRunSpec>,
}
#[derive(Debug)]
pub struct ValidatedCreateRunSpec {
pub workflow: String,
pub cwd: Option<PathBuf>,
pub run_id: Option<RunId>,
pub parent_id: Option<String>,
pub goal: Option<String>,
pub inputs: HashMap<String, toml::Value>,
pub labels: HashMap<String, String>,
pub dry_run: Option<bool>,
pub auto_approve: Option<bool>,
pub model: Option<String>,
pub provider: Option<String>,
pub sandbox: Option<String>,
pub preserve_sandbox: Option<bool>,
pub start: Option<bool>,
}
impl TryFrom<FabroRunCreateParams> for ValidatedCreateRuns {
type Error = ToolError;
fn try_from(params: FabroRunCreateParams) -> Result<Self, Self::Error> {
common::validate_len("runs", params.runs.len(), 1, 50)?;
let runs = params
.runs
.into_iter()
.map(ValidatedCreateRunSpec::try_from)
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { runs })
}
}
impl TryFrom<CreateRunSpec> for ValidatedCreateRunSpec {
type Error = ToolError;
fn try_from(spec: CreateRunSpec) -> Result<Self, Self::Error> {
let run_id = spec
.run_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.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()
.map(|(key, value)| {
let value = value.into_inner();
manifest::json_to_toml_value(&key, &value).map(|value| (key, value))
})
.collect::<ToolResult<HashMap<_, _>>>()?;
Ok(Self {
workflow: spec.workflow,
cwd: spec.cwd,
run_id,
parent_id,
goal: spec.goal,
inputs,
labels: spec.labels,
dry_run: spec.dry_run,
auto_approve: spec.auto_approve,
model: spec.model,
provider: spec.provider,
sandbox: spec.sandbox,
preserve_sandbox: spec.preserve_sandbox,
start: spec.start,
})
}
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CreateRunsResult {
pub runs: Vec<CreatedRunResult>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CreatedRunResult {
pub run_id: String,
pub parent_id: Option<String>,
pub children_count: u64,
pub workflow: String,
pub started: bool,
pub status: String,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CreateRunOptions {
pub forced_parent_id: Option<RunId>,
}
pub async fn create_runs(
backend: Arc<dyn FabroToolBackend>,
base_cwd: &Path,
user_settings_path: &Path,
params: ValidatedCreateRuns,
) -> ToolResult<CreateRunsResult> {
create_runs_with_options(
backend,
base_cwd,
user_settings_path,
params,
CreateRunOptions::default(),
)
.await
}
pub async fn create_runs_with_options(
backend: Arc<dyn FabroToolBackend>,
base_cwd: &Path,
user_settings_path: &Path,
params: ValidatedCreateRuns,
options: CreateRunOptions,
) -> ToolResult<CreateRunsResult> {
let mut created = Vec::with_capacity(params.runs.len());
let mut parent_id_cache = HashMap::<String, RunId>::new();
for spec in params.runs {
let cwd = spec.cwd.clone().unwrap_or_else(|| base_cwd.to_path_buf());
let parent_id = if let Some(forced_parent_id) = options.forced_parent_id {
Some(forced_parent_id)
} else if let Some(parent_selector) = spec.parent_id.as_deref() {
Some(
resolve_parent_run_id(backend.as_ref(), &mut parent_id_cache, parent_selector)
.await?,
)
} else {
None
};
let run_id = backend
.create_run_from_spec(&spec, &cwd, user_settings_path, parent_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
let started = spec.start.unwrap_or(true);
let summary = if started {
backend
.start_run(&run_id, false)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
} else {
backend
.retrieve_run(&run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
};
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(),
});
}
Ok(CreateRunsResult { runs: created })
}
async fn resolve_parent_run_id(
backend: &dyn FabroToolBackend,
parent_id_cache: &mut HashMap<String, RunId>,
parent_selector: &str,
) -> ToolResult<RunId> {
if let Ok(parent_id) = parent_selector.parse::<RunId>() {
return Ok(parent_id);
}
if let Some(parent_id) = parent_id_cache.get(parent_selector) {
return Ok(*parent_id);
}
let parent_id = backend
.resolve_run(parent_selector)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
.id;
parent_id_cache.insert(parent_selector.to_string(), parent_id);
Ok(parent_id)
}
pub fn create_runs_text(result: &CreateRunsResult) -> String {
let started = result.runs.iter().filter(|run| run.started).count();
format!(
"created {} Fabro run(s), started {started}",
result.runs.len()
)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use fabro_api::types;
use fabro_types::{
EventEnvelope, Run, RunLifecycle, RunLinks, RunOrigin, RunProjection, RunStatus,
RunTimestamps, WorkflowRef,
};
use schemars::SchemaGenerator;
use serde_json::json;
use super::*;
#[test]
fn run_input_value_schema_allows_only_json_scalars() {
let mut generator = SchemaGenerator::default();
let schema = RunInputValue::json_schema(&mut generator);
let schema = serde_json::to_value(schema).expect("schema should serialize");
assert_eq!(
schema["anyOf"],
json!([
{ "type": "string" },
{ "type": "boolean" },
{ "type": "integer" },
{ "type": "number" },
])
);
}
#[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_to_backend() {
let temp = tempfile::tempdir().expect("tempdir should be created");
let settings = temp.path().join("settings.toml");
let child_id = run_id("01KRBZW5C00000000000000001");
let parent_id = run_id("01KRBZW4DW0000000000000002");
let backend = Arc::new(MockCreateBackend {
child_id,
parent_id,
created_parent_ids: Mutex::new(Vec::new()),
resolved_selectors: Mutex::new(Vec::new()),
});
let params = ValidatedCreateRuns::try_from(FabroRunCreateParams {
runs: vec![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: 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(backend.clone(), 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);
assert_eq!(backend.created_parent_ids.lock().unwrap().as_slice(), &[
Some(parent_id)
]);
assert_eq!(backend.resolved_selectors.lock().unwrap().as_slice(), &[
"nightly-parent".to_string()
]);
}
#[tokio::test]
async fn create_runs_reuses_parent_selector_resolution_within_batch() {
let temp = tempfile::tempdir().expect("tempdir should be created");
let settings = temp.path().join("settings.toml");
let child_id = run_id("01KRBZW5C00000000000000001");
let parent_id = run_id("01KRBZW4DW0000000000000002");
let backend = Arc::new(MockCreateBackend {
child_id,
parent_id,
created_parent_ids: Mutex::new(Vec::new()),
resolved_selectors: Mutex::new(Vec::new()),
});
let runs = (0..2)
.map(|_| 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: Some(true),
auto_approve: Some(true),
model: None,
provider: None,
sandbox: None,
preserve_sandbox: None,
start: Some(false),
})
.collect();
let params = ValidatedCreateRuns::try_from(FabroRunCreateParams { runs })
.expect("create params should validate");
create_runs(backend.clone(), temp.path(), &settings, params)
.await
.expect("runs should be created");
assert_eq!(backend.created_parent_ids.lock().unwrap().as_slice(), &[
Some(parent_id),
Some(parent_id),
]);
assert_eq!(backend.resolved_selectors.lock().unwrap().as_slice(), &[
"nightly-parent".to_string()
]);
}
#[tokio::test]
async fn create_runs_forced_parent_id_skips_selector_resolution() {
let temp = tempfile::tempdir().expect("tempdir should be created");
let settings = temp.path().join("settings.toml");
let child_id = run_id("01KRBZW5C00000000000000001");
let parent_id = run_id("01KRBZW4DW0000000000000002");
let backend = Arc::new(MockCreateBackend {
child_id,
parent_id,
created_parent_ids: Mutex::new(Vec::new()),
resolved_selectors: Mutex::new(Vec::new()),
});
let params = ValidatedCreateRuns::try_from(FabroRunCreateParams {
runs: vec![CreateRunSpec {
workflow: "simple.fabro".to_string(),
cwd: None,
run_id: None,
parent_id: Some(parent_id.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");
create_runs_with_options(
backend.clone(),
temp.path(),
&settings,
params,
CreateRunOptions {
forced_parent_id: Some(parent_id),
},
)
.await
.expect("run should be created");
assert_eq!(backend.created_parent_ids.lock().unwrap().as_slice(), &[
Some(parent_id)
]);
assert!(backend.resolved_selectors.lock().unwrap().is_empty());
}
fn run_id(raw: &str) -> RunId {
raw.parse().expect("test run id should parse")
}
fn run(run_id: RunId, parent_id: Option<RunId>, children_count: u64) -> Run {
Run {
id: run_id,
parent_id,
children_count,
title: "Test run".to_string(),
goal: "Test run".to_string(),
workflow: WorkflowRef {
slug: Some("simple".to_string()),
name: Some("Simple".to_string()),
graph_name: None,
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: Some("/srv/repo".to_string()),
timestamps: RunTimestamps {
created_at: Utc.with_ymd_and_hms(2026, 4, 5, 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 },
}
}
struct MockCreateBackend {
child_id: RunId,
parent_id: RunId,
created_parent_ids: Mutex<Vec<Option<RunId>>>,
resolved_selectors: Mutex<Vec<String>>,
}
#[async_trait]
impl FabroToolBackend for MockCreateBackend {
async fn create_run_from_spec(
&self,
_spec: &ValidatedCreateRunSpec,
_cwd: &Path,
_user_settings_path: &Path,
parent_id: Option<RunId>,
) -> anyhow::Result<RunId> {
self.created_parent_ids.lock().unwrap().push(parent_id);
Ok(self.child_id)
}
async fn resolve_run(&self, selector: &str) -> anyhow::Result<Run> {
assert_eq!(selector, "nightly-parent");
self.resolved_selectors
.lock()
.unwrap()
.push(selector.to_string());
Ok(run(self.parent_id, None, 1))
}
async fn retrieve_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
assert_eq!(*run_id, self.child_id);
Ok(run(self.child_id, Some(self.parent_id), 0))
}
async fn start_run(&self, _run_id: &RunId, _resume: bool) -> anyhow::Result<Run> {
unreachable!("test uses start=false")
}
async fn cancel_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
unreachable!()
}
async fn interrupt_run(&self, _run_id: &RunId) -> anyhow::Result<()> {
unreachable!()
}
async fn steer_run(
&self,
_run_id: &RunId,
_text: String,
_interrupt: bool,
) -> anyhow::Result<()> {
unreachable!()
}
async fn archive_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
unreachable!()
}
async fn unarchive_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
unreachable!()
}
async fn list_store_runs(&self) -> anyhow::Result<Vec<Run>> {
unreachable!()
}
async fn list_store_runs_by_parent(&self, _parent_id: RunId) -> anyhow::Result<Vec<Run>> {
unreachable!()
}
async fn link_run_parent(
&self,
_child_id: &RunId,
_parent_id: &RunId,
) -> anyhow::Result<Run> {
unreachable!()
}
async fn unlink_run_parent(&self, _child_id: &RunId) -> anyhow::Result<Run> {
unreachable!()
}
async fn get_run_state(&self, _run_id: &RunId) -> anyhow::Result<RunProjection> {
unreachable!()
}
async fn list_run_events(
&self,
_run_id: &RunId,
_after: Option<u32>,
_limit: Option<usize>,
) -> anyhow::Result<Vec<EventEnvelope>> {
unreachable!()
}
async fn list_run_events_until(
&self,
_run_id: &RunId,
_after: Option<u32>,
_limit: usize,
) -> anyhow::Result<Vec<EventEnvelope>> {
unreachable!()
}
async fn list_run_questions(
&self,
_run_id: &RunId,
) -> anyhow::Result<Vec<types::ApiQuestion>> {
unreachable!()
}
async fn submit_run_answer(
&self,
_run_id: &RunId,
_question_id: &str,
_body: types::SubmitAnswerRequest,
) -> anyhow::Result<()> {
unreachable!()
}
}
}

View file

@ -1,48 +1,47 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use fabro_client::Client;
use fabro_types::EventEnvelope;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::common;
use super::common::{ToolError, ToolResult};
use super::common::{FabroToolBackend, ToolError, ToolResult};
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RunEventsAction {
pub enum RunEventsAction {
List,
Details,
Search,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct FabroRunEventsParams {
pub(crate) action: RunEventsAction,
pub(crate) run_id: String,
pub(crate) event_types: Option<Vec<String>>,
pub(crate) categories: Option<Vec<String>>,
pub(crate) direction: Option<String>,
pub(crate) created_after: Option<String>,
pub(crate) created_before: Option<String>,
pub(crate) first: Option<usize>,
pub(crate) after: Option<u32>,
pub(crate) event_ids: Option<Vec<String>>,
pub(crate) offset: Option<usize>,
pub(crate) limit: Option<usize>,
pub(crate) max_content_length: Option<usize>,
pub(crate) query: Option<String>,
pub struct FabroRunEventsParams {
pub action: RunEventsAction,
pub run_id: String,
pub event_types: Option<Vec<String>>,
pub categories: Option<Vec<String>>,
pub direction: Option<String>,
pub created_after: Option<String>,
pub created_before: Option<String>,
pub first: Option<usize>,
pub after: Option<u32>,
pub event_ids: Option<Vec<String>>,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub max_content_length: Option<usize>,
pub query: Option<String>,
}
#[derive(Debug)]
pub(crate) struct ValidatedRunEvents {
pub(crate) raw: FabroRunEventsParams,
pub(crate) descending: bool,
pub(crate) first: usize,
pub(crate) created_after: Option<DateTime<Utc>>,
pub(crate) created_before: Option<DateTime<Utc>>,
pub struct ValidatedRunEvents {
pub raw: FabroRunEventsParams,
pub descending: bool,
pub first: usize,
pub created_after: Option<DateTime<Utc>>,
pub created_before: Option<DateTime<Utc>>,
}
impl TryFrom<FabroRunEventsParams> for ValidatedRunEvents {
@ -97,23 +96,23 @@ impl TryFrom<FabroRunEventsParams> for ValidatedRunEvents {
}
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct RunEventsResult {
pub(crate) run_id: String,
pub(crate) action: RunEventsAction,
pub(crate) events: Vec<RunEventResult>,
pub(crate) next_cursor: Option<u32>,
pub struct RunEventsResult {
pub run_id: String,
pub action: RunEventsAction,
pub events: Vec<RunEventResult>,
pub next_cursor: Option<u32>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct RunEventResult {
pub(crate) event_id: String,
pub(crate) sequence: u32,
pub(crate) event: Value,
pub(crate) truncated: bool,
pub struct RunEventResult {
pub event_id: String,
pub sequence: u32,
pub event: Value,
pub truncated: bool,
}
pub(crate) async fn run_events(
client: Arc<Client>,
pub async fn run_events(
backend: Arc<dyn FabroToolBackend>,
params: ValidatedRunEvents,
) -> ToolResult<RunEventsResult> {
let descending = params.descending;
@ -121,18 +120,18 @@ pub(crate) async fn run_events(
let created_after = params.created_after;
let created_before = params.created_before;
let raw = params.raw;
let run_id = client
let run_id = backend
.resolve_run(&raw.run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
.id;
let fetch_after = if descending { None } else { raw.after };
let mut events = if let Some(limit) = event_fetch_limit(&raw, first) {
client
backend
.list_run_events_until(&run_id, fetch_after, limit)
.await
} else {
client.list_run_events(&run_id, fetch_after, None).await
backend.list_run_events(&run_id, fetch_after, None).await
}
.map_err(|err| ToolError::from_anyhow(&err))?;
if descending {
@ -171,7 +170,7 @@ pub(crate) async fn run_events(
})
}
pub(crate) fn run_events_text(result: &RunEventsResult) -> String {
pub fn run_events_text(result: &RunEventsResult) -> String {
format!("returned {} Fabro event(s)", result.events.len())
}

View file

@ -0,0 +1,141 @@
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_api::types;
use fabro_types::{EventEnvelope, Run, RunId, RunProjection};
use crate::{FabroToolBackend, RunManifestBuilder, ToolError};
#[derive(Clone)]
pub struct ClientBackend {
client: Arc<::fabro_client::Client>,
manifest_builder: Option<Arc<dyn RunManifestBuilder>>,
}
impl ClientBackend {
#[must_use]
pub fn new(client: Arc<::fabro_client::Client>) -> Self {
Self {
client,
manifest_builder: None,
}
}
#[must_use]
pub fn with_manifest_builder(mut self, builder: Arc<dyn RunManifestBuilder>) -> Self {
self.manifest_builder = Some(builder);
self
}
}
#[async_trait]
impl FabroToolBackend for ClientBackend {
async fn create_run_from_spec(
&self,
spec: &crate::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
parent_id: Option<RunId>,
) -> anyhow::Result<RunId> {
let Some(builder) = self.manifest_builder.as_ref() else {
return Err(ToolError::message(format!(
"{} is not available",
crate::FABRO_RUN_CREATE_TOOL_NAME
))
.into());
};
let mut manifest = builder
.build_run_manifest(spec, cwd, user_settings_path)
.map_err(anyhow::Error::new)?;
manifest.parent_id = parent_id.map(|run_id| run_id.to_string());
self.client.create_run_from_manifest(manifest).await
}
async fn resolve_run(&self, selector: &str) -> anyhow::Result<Run> {
self.client.resolve_run(selector).await
}
async fn retrieve_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
self.client.retrieve_run(run_id).await
}
async fn start_run(&self, run_id: &RunId, resume: bool) -> anyhow::Result<Run> {
self.client.start_run(run_id, resume).await
}
async fn cancel_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
self.client.cancel_run(run_id).await
}
async fn interrupt_run(&self, run_id: &RunId) -> anyhow::Result<()> {
self.client.interrupt_run(run_id).await
}
async fn steer_run(&self, run_id: &RunId, text: String, interrupt: bool) -> anyhow::Result<()> {
self.client.steer_run(run_id, text, interrupt).await
}
async fn archive_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
self.client.archive_run(run_id).await
}
async fn unarchive_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
self.client.unarchive_run(run_id).await
}
async fn list_store_runs(&self) -> anyhow::Result<Vec<Run>> {
self.client.list_store_runs().await
}
async fn list_store_runs_by_parent(&self, parent_id: RunId) -> anyhow::Result<Vec<Run>> {
self.client.list_store_runs_by_parent(parent_id).await
}
async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> anyhow::Result<Run> {
self.client.link_run_parent(child_id, parent_id).await
}
async fn unlink_run_parent(&self, child_id: &RunId) -> anyhow::Result<Run> {
self.client.unlink_run_parent(child_id).await
}
async fn get_run_state(&self, run_id: &RunId) -> anyhow::Result<RunProjection> {
self.client.get_run_state(run_id).await
}
async fn list_run_events(
&self,
run_id: &RunId,
after: Option<u32>,
limit: Option<usize>,
) -> anyhow::Result<Vec<EventEnvelope>> {
self.client.list_run_events(run_id, after, limit).await
}
async fn list_run_events_until(
&self,
run_id: &RunId,
after: Option<u32>,
limit: usize,
) -> anyhow::Result<Vec<EventEnvelope>> {
self.client
.list_run_events_until(run_id, after, limit)
.await
}
async fn list_run_questions(&self, run_id: &RunId) -> anyhow::Result<Vec<types::ApiQuestion>> {
self.client.list_run_questions(run_id).await
}
async fn submit_run_answer(
&self,
run_id: &RunId,
question_id: &str,
body: types::SubmitAnswerRequest,
) -> anyhow::Result<()> {
self.client
.submit_run_answer(run_id, question_id, body)
.await
}
}

View file

@ -1,27 +1,26 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use fabro_client::Client;
use futures::future::try_join_all;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::time;
use super::common;
use super::common::{RunSummaryResult, ToolError, ToolResult};
use super::common::{FabroToolBackend, RunSummaryResult, ToolError, ToolResult};
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct FabroRunGatherParams {
pub(crate) run_ids: Vec<String>,
pub(crate) timeout_seconds: Option<u64>,
pub(crate) poll_interval_seconds: Option<u64>,
pub struct FabroRunGatherParams {
pub run_ids: Vec<String>,
pub timeout_seconds: Option<u64>,
pub poll_interval_seconds: Option<u64>,
}
#[derive(Debug)]
pub(crate) struct ValidatedGatherRuns {
pub(crate) run_ids: Vec<String>,
pub(crate) timeout_seconds: u64,
pub(crate) poll_interval_seconds: u64,
pub struct ValidatedGatherRuns {
pub run_ids: Vec<String>,
pub timeout_seconds: u64,
pub poll_interval_seconds: u64,
}
impl TryFrom<FabroRunGatherParams> for ValidatedGatherRuns {
@ -47,22 +46,22 @@ impl TryFrom<FabroRunGatherParams> for ValidatedGatherRuns {
}
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct GatherRunsResult {
pub(crate) runs: Vec<RunSummaryResult>,
pub(crate) timed_out: bool,
pub(crate) elapsed_seconds: u64,
pub struct GatherRunsResult {
pub runs: Vec<RunSummaryResult>,
pub timed_out: bool,
pub elapsed_seconds: u64,
}
pub(crate) async fn gather_runs(
client: Arc<Client>,
pub async fn gather_runs(
backend: Arc<dyn FabroToolBackend>,
params: ValidatedGatherRuns,
) -> ToolResult<GatherRunsResult> {
let start = Instant::now();
let deadline = start + Duration::from_secs(params.timeout_seconds);
let run_ids = try_join_all(params.run_ids.into_iter().map(|selector| {
let client = Arc::clone(&client);
let backend = Arc::clone(&backend);
async move {
client
backend
.resolve_run(&selector)
.await
.map(|run| run.id)
@ -73,8 +72,8 @@ pub(crate) async fn gather_runs(
loop {
let summaries = try_join_all(run_ids.iter().map(|run_id| {
let client = Arc::clone(&client);
async move { common::retrieve_run(&client, run_id).await }
let backend = Arc::clone(&backend);
async move { common::retrieve_run(backend.as_ref(), run_id).await }
}))
.await?;
if summaries
@ -100,7 +99,7 @@ pub(crate) async fn gather_runs(
}
}
pub(crate) fn gather_runs_text(result: &GatherRunsResult) -> String {
pub fn gather_runs_text(result: &GatherRunsResult) -> String {
format!(
"gathered {} Fabro run(s), timed_out={}",
result.runs.len(),

View file

@ -2,18 +2,17 @@ use std::borrow::Cow;
use std::sync::Arc;
use fabro_api::types;
use fabro_client::Client;
use fabro_types::RunId;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::common;
use super::common::{ToolError, ToolResult};
use super::common::{FabroToolBackend, ToolError, ToolResult};
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RunInteractAction {
pub enum RunInteractAction {
Get,
Start,
Message,
@ -32,19 +31,19 @@ pub(crate) enum RunInteractAction {
}
#[derive(Debug, Deserialize, JsonSchema)]
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>,
pub(crate) answer: Option<AnswerValue>,
pub struct FabroRunInteractParams {
pub action: RunInteractAction,
pub run_id: String,
pub parent_id: Option<String>,
pub message: Option<String>,
pub interrupt: Option<bool>,
pub question_id: Option<String>,
pub answer: Option<AnswerValue>,
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub(crate) struct AnswerValue(Value);
pub struct AnswerValue(Value);
impl From<Value> for AnswerValue {
fn from(value: Value) -> Self {
@ -53,7 +52,7 @@ impl From<Value> for AnswerValue {
}
impl AnswerValue {
fn into_inner(self) -> Value {
pub(crate) fn into_inner(self) -> Value {
self.0
}
}
@ -106,13 +105,13 @@ impl JsonSchema for AnswerValue {
}
#[derive(Debug)]
pub(crate) struct ValidatedInteractRun {
pub(crate) run_id: String,
pub(crate) action: ValidatedInteractAction,
pub struct ValidatedInteractRun {
pub run_id: String,
pub action: ValidatedInteractAction,
}
#[derive(Debug)]
pub(crate) enum ValidatedInteractAction {
pub enum ValidatedInteractAction {
Get,
Start,
Message {
@ -225,94 +224,94 @@ impl TryFrom<FabroRunInteractParams> for ValidatedInteractRun {
}
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct InteractRunResult {
pub(crate) run_id: String,
pub(crate) action: RunInteractAction,
pub(crate) result: Value,
pub struct InteractRunResult {
pub run_id: String,
pub action: RunInteractAction,
pub result: Value,
}
pub(crate) async fn interact_run(
client: Arc<Client>,
pub async fn interact_run(
backend: Arc<dyn FabroToolBackend>,
params: ValidatedInteractRun,
) -> ToolResult<InteractRunResult> {
let run_id = client
let run_id = backend
.resolve_run(&params.run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
.id;
let action = params.action.action();
let result = match params.action {
ValidatedInteractAction::Get => interact_get(&client, &run_id).await?,
ValidatedInteractAction::Get => interact_get(backend.as_ref(), &run_id).await?,
ValidatedInteractAction::Start => {
let summary = client
let summary = backend
.start_run(&run_id, false)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
json!({ "summary": common::run_summary_result(&summary) })
}
ValidatedInteractAction::Message { message, interrupt } => {
client
backend
.steer_run(&run_id, message.clone(), interrupt)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
json!({ "message": message, "interrupt": interrupt })
}
ValidatedInteractAction::Interrupt => {
client
backend
.interrupt_run(&run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
json!({ "interrupted": true })
}
ValidatedInteractAction::Cancel => {
let summary = client
let summary = backend
.cancel_run(&run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
json!({ "summary": common::run_summary_result(&summary) })
}
ValidatedInteractAction::Archive => {
let summary = client
let summary = backend
.archive_run(&run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
json!({ "summary": common::run_summary_result(&summary) })
}
ValidatedInteractAction::Unarchive => {
let summary = client
let summary = backend
.unarchive_run(&run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
json!({ "summary": common::run_summary_result(&summary) })
}
ValidatedInteractAction::LinkParent { parent_id } => {
let parent_id = client
let parent_id = backend
.resolve_run(&parent_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
.id;
let summary = client
let summary = backend
.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
let summary = backend
.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
let questions = backend
.list_run_questions(&run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
json!({ "questions": questions })
}
ValidatedInteractAction::Answer { question_id, body } => {
client
backend
.submit_run_answer(&run_id, &question_id, body)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
@ -327,16 +326,16 @@ pub(crate) async fn interact_run(
})
}
pub(crate) fn interact_run_text(result: &InteractRunResult) -> String {
pub fn interact_run_text(result: &InteractRunResult) -> String {
format!(
"completed {:?} for Fabro run {}",
result.action, result.run_id
)
}
async fn interact_get(client: &Client, run_id: &RunId) -> ToolResult<Value> {
let summary = common::retrieve_run(client, run_id).await?;
let projection = client
async fn interact_get(backend: &dyn FabroToolBackend, run_id: &RunId) -> ToolResult<Value> {
let summary = common::retrieve_run(backend, run_id).await?;
let projection = backend
.get_run_state(run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;

View file

@ -0,0 +1,41 @@
#![allow(
dead_code,
reason = "Tool DTO fields are consumed by serde and schema generation even when not read \
directly."
)]
mod common;
mod create;
mod events;
pub mod fabro_client;
mod gather;
mod interact;
mod manifest;
mod search;
pub use common::{
FABRO_RUN_CREATE_TOOL_NAME, FABRO_RUN_EVENTS_TOOL_NAME, FABRO_RUN_GATHER_TOOL_NAME,
FABRO_RUN_INTERACT_TOOL_NAME, FABRO_RUN_SEARCH_TOOL_NAME, FabroToolBackend, RunManifestBuilder,
RunSummaryResult, ToolDefinition, ToolError, ToolResult, tool_definitions,
};
pub use create::{
CreateRunOptions, CreateRunSpec, CreateRunsResult, CreatedRunResult, FabroRunCreateParams,
RunInputValue, ValidatedCreateRunSpec, ValidatedCreateRuns, create_runs, create_runs_text,
create_runs_with_options,
};
pub use events::{
FabroRunEventsParams, RunEventResult, RunEventsAction, RunEventsResult, ValidatedRunEvents,
run_events, run_events_text,
};
pub use gather::{
FabroRunGatherParams, GatherRunsResult, ValidatedGatherRuns, gather_runs, gather_runs_text,
};
pub use interact::{
AnswerValue, FabroRunInteractParams, InteractRunResult, RunInteractAction,
ValidatedInteractAction, ValidatedInteractRun, interact_run, interact_run_text,
};
pub use manifest::json_to_toml_value;
pub use search::{
FabroRunSearchParams, SearchRunSummaryResult, SearchRunsResult, ValidatedSearchRuns,
search_runs, search_runs_text,
};

View file

@ -0,0 +1,76 @@
use serde_json::Value;
use super::common::{ToolError, ToolResult};
pub fn json_to_toml_value(key: &str, value: &Value) -> ToolResult<toml::Value> {
match value {
Value::Null => Err(ToolError::message(format!(
"input `{key}` cannot be null; use a string, boolean, or number"
))),
Value::Bool(value) => Ok(toml::Value::Boolean(*value)),
Value::Number(value) => {
if let Some(integer) = value.as_i64() {
Ok(toml::Value::Integer(integer))
} else if let Some(float) = value.as_f64() {
Ok(toml::Value::Float(float))
} else {
Err(ToolError::message(format!(
"input `{key}` contains a number outside TOML's supported range"
)))
}
}
Value::String(value) => Ok(toml::Value::String(value.clone())),
Value::Array(_) => Err(ToolError::message(format!(
"input `{key}` does not support array values; use a string, boolean, or number",
))),
Value::Object(_) => Err(ToolError::message(format!(
"input `{key}` does not support object values; use a string, boolean, or number",
))),
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
#[test]
fn json_inputs_convert_scalar_values_to_toml_values() {
let cases = [
(json!("hello"), toml::Value::String("hello".to_string())),
(json!(true), toml::Value::Boolean(true)),
(json!(42), toml::Value::Integer(42)),
(json!(0.5), toml::Value::Float(0.5)),
];
for (json, expected) in cases {
assert_eq!(json_to_toml_value("input", &json).unwrap(), expected);
}
}
#[test]
fn json_input_arrays_and_objects_are_rejected() {
let array_err = json_to_toml_value("matrix", &json!(["a", 1])).unwrap_err();
assert_eq!(
array_err.as_str(),
"input `matrix` does not support array values; use a string, boolean, or number",
);
let object_err = json_to_toml_value("settings", &json!({ "enabled": true })).unwrap_err();
assert_eq!(
object_err.as_str(),
"input `settings` does not support object values; use a string, boolean, or number",
);
}
#[test]
fn json_input_null_is_rejected_with_key_name() {
let err = json_to_toml_value("goal", &Value::Null).unwrap_err();
assert_eq!(
err.as_str(),
"input `goal` cannot be null; use a string, boolean, or number",
);
}
}

View file

@ -1,35 +1,34 @@
use std::collections::HashMap;
use std::sync::Arc;
use fabro_client::Client;
use fabro_types::{Run, RunId, RunStatusKind};
use futures::future::try_join_all;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::common;
use super::common::{RunSummaryResult, ToolError, ToolResult};
use super::common::{FabroToolBackend, RunSummaryResult, ToolError, ToolResult};
const SEARCH_GOAL_PREVIEW_CHARS: usize = 240;
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct FabroRunSearchParams {
pub(crate) run_ids: Option<Vec<String>>,
pub(crate) workflow: Option<String>,
pub(crate) labels: Option<HashMap<String, String>>,
pub(crate) status: Option<Vec<String>>,
pub(crate) archived: Option<bool>,
pub(crate) created_after: Option<String>,
pub(crate) created_before: Option<String>,
pub(crate) first: Option<usize>,
pub(crate) after: Option<String>,
pub(crate) parent_id: Option<String>,
pub struct FabroRunSearchParams {
pub run_ids: Option<Vec<String>>,
pub workflow: Option<String>,
pub labels: Option<HashMap<String, String>>,
pub status: Option<Vec<String>>,
pub archived: Option<bool>,
pub created_after: Option<String>,
pub created_before: Option<String>,
pub first: Option<usize>,
pub after: Option<String>,
pub parent_id: Option<String>,
}
#[derive(Debug)]
pub(crate) struct ValidatedSearchRuns {
pub(crate) raw: FabroRunSearchParams,
pub(crate) status: Option<Vec<RunStatusKind>>,
pub struct ValidatedSearchRuns {
pub raw: FabroRunSearchParams,
pub status: Option<Vec<RunStatusKind>>,
}
impl TryFrom<FabroRunSearchParams> for ValidatedSearchRuns {
@ -77,40 +76,40 @@ impl TryFrom<FabroRunSearchParams> for ValidatedSearchRuns {
}
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct SearchRunsResult {
pub(crate) runs: Vec<SearchRunSummaryResult>,
pub(crate) next_cursor: Option<String>,
pub struct SearchRunsResult {
pub runs: Vec<SearchRunSummaryResult>,
pub next_cursor: Option<String>,
}
#[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: Option<String>,
pub(crate) workflow_graph_name: Option<String>,
pub(crate) workflow_slug: Option<String>,
pub(crate) status: String,
pub(crate) archived: bool,
pub(crate) created_at: String,
pub(crate) started_at: Option<String>,
pub(crate) completed_at: Option<String>,
pub(crate) labels: HashMap<String, String>,
pub(crate) source_directory: Option<String>,
pub(crate) repo_origin_url: Option<String>,
pub(crate) goal_preview: String,
pub(crate) goal_truncated: bool,
pub struct SearchRunSummaryResult {
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_preview: String,
pub goal_truncated: bool,
}
pub(crate) async fn search_runs(
client: Arc<Client>,
pub async fn search_runs(
backend: Arc<dyn FabroToolBackend>,
params: ValidatedSearchRuns,
) -> 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
backend
.resolve_run(parent_selector)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
@ -120,14 +119,14 @@ pub(crate) async fn search_runs(
None
};
let runs = if let Some(run_ids) = raw.run_ids.as_ref() {
resolve_requested_runs(&client, run_ids).await?
resolve_requested_runs(&backend, run_ids).await?
} else if let Some(parent_id) = parent_id {
client
backend
.list_store_runs_by_parent(parent_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?
} else {
client
backend
.list_store_runs()
.await
.map_err(|err| ToolError::from_anyhow(&err))?
@ -263,15 +262,18 @@ fn filter_sort_and_page_runs(
})
}
pub(crate) fn search_runs_text(result: &SearchRunsResult) -> String {
pub fn search_runs_text(result: &SearchRunsResult) -> String {
format!("found {} Fabro run(s)", result.runs.len())
}
async fn resolve_requested_runs(client: &Arc<Client>, run_ids: &[String]) -> ToolResult<Vec<Run>> {
async fn resolve_requested_runs(
backend: &Arc<dyn FabroToolBackend>,
run_ids: &[String],
) -> ToolResult<Vec<Run>> {
let runs = try_join_all(run_ids.iter().map(|run_id| {
let client = Arc::clone(client);
let backend = Arc::clone(backend);
async move {
client
backend
.resolve_run(run_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))

View file

@ -32,6 +32,7 @@ fabro-mcp = { path = "../fabro-mcp" }
fabro-github = { path = "../fabro-github" }
fabro-interview = { path = "../fabro-interview" }
fabro-template = { path = "../fabro-template" }
fabro-tool = { path = "../fabro-tool" }
fabro-util = { path = "../fabro-util" }
fabro-redact.workspace = true
fabro-checkpoint = { path = "../fabro-checkpoint" }
@ -73,6 +74,7 @@ fabro-vault = { path = "../fabro-vault" }
[dev-dependencies]
base64.workspace = true
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
fabro-api = { path = "../fabro-api" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker", "test-support"] }
fabro-mcp = { path = "../fabro-mcp" }
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_agent::subagent::{SessionFactory, SubAgentManager};
use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry};
use fabro_agent::{
AgentEvent, AgentProfile, AnthropicProfile, CompletionCoordinator, GeminiProfile,
Message as AgentMessage, OpenAiProfile, Sandbox, Session, SessionOptions, StaticEnvProvider,
@ -11,13 +12,16 @@ use fabro_agent::{
use fabro_auth::{CredentialSource, EnvCredentialSource};
use fabro_graphviz::graph::{AttrValue, Node};
use fabro_llm::client::Client;
use fabro_llm::types::{Message, ReasoningEffort, Request, Speed, TokenCounts};
use fabro_llm::types::{
Message, ReasoningEffort, Request, Speed, TokenCounts, ToolDefinition as LlmToolDefinition,
};
use fabro_mcp::config::McpServerSettings;
#[cfg(test)]
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ModelRef, ProviderId};
use fabro_types::settings::run::RunModelControls;
use fabro_types::{SessionCapability, StageId};
use fabro_types::{RunId, SessionCapability, StageId};
use serde::de::DeserializeOwned;
use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
@ -31,6 +35,7 @@ use crate::context::keys::Fidelity;
use crate::error::Error;
use crate::event::{Emitter, Event, StageScope};
use crate::outcome::billed_model_usage_from_llm;
use crate::services::FabroRunToolServices;
use crate::steering_hub::{ActiveControlHandle, SteeringHub};
/// Spawn a task that, when the run-level token cancels, sets the agent
@ -186,6 +191,149 @@ fn build_profile(
}
}
pub(crate) fn register_fabro_run_tools(
registry: &mut ToolRegistry,
services: &FabroRunToolServices,
) {
for definition in fabro_tool::tool_definitions() {
registry.register(fabro_run_tool(definition, services.clone()));
}
}
fn fabro_run_tool(
definition: &fabro_tool::ToolDefinition,
services: FabroRunToolServices,
) -> RegisteredTool {
let name = definition.name.to_string();
RegisteredTool {
definition: LlmToolDefinition {
name: name.clone(),
description: definition.description.to_string(),
parameters: definition.parameters.clone(),
},
executor: Arc::new(move |args, _context: ToolContext| {
let name = name.clone();
let services = services.clone();
Box::pin(async move {
execute_fabro_run_tool(&name, args, services)
.await
.map_err(|err| err.to_string())
})
}),
}
}
async fn execute_fabro_run_tool(
name: &str,
args: serde_json::Value,
services: FabroRunToolServices,
) -> fabro_tool::ToolResult<String> {
match name {
fabro_tool::FABRO_RUN_CREATE_TOOL_NAME => {
let params = parse_fabro_tool_args::<fabro_tool::FabroRunCreateParams>(name, args)?;
ensure_current_run_parent(&params, services.current_run_id)?;
let validated = fabro_tool::ValidatedCreateRuns::try_from(params)?;
let result = fabro_tool::create_runs_with_options(
Arc::clone(&services.backend),
&services.base_cwd,
&services.user_settings_path,
validated,
fabro_tool::CreateRunOptions {
forced_parent_id: Some(services.current_run_id),
},
)
.await?;
let summary = fabro_tool::create_runs_text(&result);
render_fabro_tool_result(&summary, &result)
}
fabro_tool::FABRO_RUN_SEARCH_TOOL_NAME => {
let params = parse_fabro_tool_args::<fabro_tool::FabroRunSearchParams>(name, args)?;
let result = fabro_tool::search_runs(
Arc::clone(&services.backend),
fabro_tool::ValidatedSearchRuns::try_from(params)?,
)
.await?;
let summary = fabro_tool::search_runs_text(&result);
render_fabro_tool_result(&summary, &result)
}
fabro_tool::FABRO_RUN_INTERACT_TOOL_NAME => {
let params = parse_fabro_tool_args::<fabro_tool::FabroRunInteractParams>(name, args)?;
let result = fabro_tool::interact_run(
Arc::clone(&services.backend),
fabro_tool::ValidatedInteractRun::try_from(params)?,
)
.await?;
let summary = fabro_tool::interact_run_text(&result);
render_fabro_tool_result(&summary, &result)
}
fabro_tool::FABRO_RUN_GATHER_TOOL_NAME => {
let params = parse_fabro_tool_args::<fabro_tool::FabroRunGatherParams>(name, args)?;
let result = fabro_tool::gather_runs(
Arc::clone(&services.backend),
fabro_tool::ValidatedGatherRuns::try_from(params)?,
)
.await?;
let summary = fabro_tool::gather_runs_text(&result);
render_fabro_tool_result(&summary, &result)
}
fabro_tool::FABRO_RUN_EVENTS_TOOL_NAME => {
let params = parse_fabro_tool_args::<fabro_tool::FabroRunEventsParams>(name, args)?;
let result = fabro_tool::run_events(
Arc::clone(&services.backend),
fabro_tool::ValidatedRunEvents::try_from(params)?,
)
.await?;
let summary = fabro_tool::run_events_text(&result);
render_fabro_tool_result(&summary, &result)
}
_ => Err(fabro_tool::ToolError::message(format!(
"unknown Fabro run tool `{name}`"
))),
}
}
fn parse_fabro_tool_args<T>(name: &str, args: serde_json::Value) -> fabro_tool::ToolResult<T>
where
T: DeserializeOwned,
{
serde_json::from_value(args)
.map_err(|err| fabro_tool::ToolError::message(format!("invalid {name} arguments: {err}")))
}
fn ensure_current_run_parent(
params: &fabro_tool::FabroRunCreateParams,
current_run_id: RunId,
) -> fabro_tool::ToolResult<()> {
let current_parent = current_run_id.to_string();
for run in &params.runs {
match run.parent_id.as_deref().map(str::trim) {
None => {}
Some("") => {
return Err(fabro_tool::ToolError::message(
"parent_id must be omitted or match the current run; blank parent_id is invalid",
));
}
Some(parent_id) if parent_id == current_parent => {}
Some(parent_id) => {
return Err(fabro_tool::ToolError::message(format!(
"parent_id must be omitted or match the current run {current_parent}; got {parent_id}"
)));
}
}
}
Ok(())
}
fn render_fabro_tool_result<T>(summary: &str, result: &T) -> fabro_tool::ToolResult<String>
where
T: serde::Serialize,
{
let json = serde_json::to_string_pretty(result).map_err(|err| {
fabro_tool::ToolError::message(format!("failed to serialize tool result: {err}"))
})?;
Ok(format!("{summary}\n{json}"))
}
pub(super) fn effective_request_controls(
run_model_controls: &RunModelControls,
node: &Node,
@ -342,6 +490,7 @@ pub struct AgentApiBackend {
source: Arc<dyn CredentialSource>,
steering_hub: Arc<SteeringHub>,
catalog: Arc<Catalog>,
fabro_run_tools: Option<FabroRunToolServices>,
}
impl AgentApiBackend {
@ -384,6 +533,7 @@ impl AgentApiBackend {
source,
steering_hub,
catalog,
fabro_run_tools: None,
}
}
@ -427,6 +577,12 @@ impl AgentApiBackend {
self
}
#[must_use]
pub fn with_fabro_run_tools(mut self, services: FabroRunToolServices) -> Self {
self.fabro_run_tools = Some(services);
self
}
fn effective_request_controls(&self, node: &Node) -> Result<EffectiveRequestControls, Error> {
effective_request_controls(&self.run_model_controls, node)
}
@ -468,6 +624,7 @@ impl AgentApiBackend {
self.tool_env.as_ref(),
tool_hooks,
self.mcp_servers.clone(),
self.fabro_run_tools.clone(),
)
.await
}
@ -483,6 +640,7 @@ impl AgentApiBackend {
tool_env: Option<&Arc<dyn ToolEnvProvider>>,
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
mcp_servers: Vec<McpServerSettings>,
fabro_run_tools: Option<FabroRunToolServices>,
) -> Result<Session, Error> {
let controls = effective_request_controls(run_model_controls, node)?;
let client = Client::from_source(source, Arc::clone(&catalog))
@ -517,13 +675,18 @@ impl AgentApiBackend {
let factory_catalog = Arc::clone(&catalog);
let factory_env = Arc::clone(sandbox);
let factory_tool_env = tool_env.cloned();
let factory_fabro_run_tools = fabro_run_tools.clone();
let factory: SessionFactory = Arc::new(move || {
let child_profile: Arc<dyn AgentProfile> = Arc::from(build_profile(
let mut child_profile = build_profile(
&factory_model,
factory_provider.provider_id.clone(),
factory_provider.profile_kind,
Arc::clone(&factory_catalog),
));
);
if let Some(services) = factory_fabro_run_tools.clone() {
register_fabro_run_tools(child_profile.tool_registry_mut(), &services);
}
let child_profile: Arc<dyn AgentProfile> = Arc::from(child_profile);
let mut session = Session::new(
factory_client.clone(),
child_profile,
@ -542,6 +705,9 @@ impl AgentApiBackend {
});
profile.register_subagent_tools(manager, factory, 0);
if let Some(services) = fabro_run_tools {
register_fabro_run_tools(profile.tool_registry_mut(), &services);
}
let profile: Arc<dyn AgentProfile> = Arc::from(profile);
let mut session = Session::new(
@ -950,6 +1116,7 @@ impl CodergenBackend for AgentApiBackend {
self.tool_env.as_ref(),
tool_hooks.clone(),
self.mcp_servers.clone(),
self.fabro_run_tools.clone(),
)
.await;
if cancel_token.is_cancelled() {
@ -1150,16 +1317,27 @@ impl CompletionCoordinator for SteeringCompletionCoordinator {
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use chrono::TimeZone;
use fabro_agent::subagent::SessionFactory;
use fabro_agent::{AgentProfile, ToolRegistry};
use fabro_agent::{AgentProfile, LocalSandbox, ToolRegistry};
use fabro_api::types;
use fabro_auth::{EnvCredentialSource, VaultCredentialSource};
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::{Error as LlmError, ProviderErrorDetail, ProviderErrorKind};
use fabro_tool::FabroToolBackend;
use fabro_types::{
EventEnvelope, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunProjection, RunStatus,
RunTimestamps, SuccessReason, WorkflowRef,
};
use fabro_vault::{SecretType, Vault};
use futures::stream;
use tokio::sync::RwLock as AsyncRwLock;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::services::FabroRunToolServices;
struct ShutdownTestProfile {
registry: ToolRegistry,
@ -1249,6 +1427,342 @@ mod tests {
assert!(backend.sessions.lock().unwrap().is_empty());
}
#[test]
fn agent_run_tools_register_exact_shared_definitions() {
let mut registry = ToolRegistry::new();
let (services, _backend) = fabro_run_tool_services();
register_fabro_run_tools(&mut registry, &services);
let mut registered = registry
.names()
.into_iter()
.filter(|name| name.starts_with("fabro_run_"))
.collect::<Vec<_>>();
registered.sort();
assert_eq!(registered, vec![
fabro_tool::FABRO_RUN_CREATE_TOOL_NAME,
fabro_tool::FABRO_RUN_EVENTS_TOOL_NAME,
fabro_tool::FABRO_RUN_GATHER_TOOL_NAME,
fabro_tool::FABRO_RUN_INTERACT_TOOL_NAME,
fabro_tool::FABRO_RUN_SEARCH_TOOL_NAME,
]);
for definition in fabro_tool::tool_definitions() {
let registered = registry
.get(definition.name)
.expect("shared Fabro run tool should be registered");
assert_eq!(registered.definition.description, definition.description);
assert_eq!(registered.definition.parameters, definition.parameters);
}
}
#[tokio::test]
async fn agent_run_create_injects_current_run_as_parent() {
let (services, backend) = fabro_run_tool_services();
let mut registry = ToolRegistry::new();
register_fabro_run_tools(&mut registry, &services);
let tool = registry
.get(fabro_tool::FABRO_RUN_CREATE_TOOL_NAME)
.expect("create tool should be registered");
let output = (tool.executor)(
serde_json::json!({
"runs": [{
"workflow": "child.fabro",
"start": false
}]
}),
tool_context(),
)
.await
.expect("create tool should succeed");
assert!(output.contains("created 1 Fabro run(s)"));
assert_eq!(backend.created_parent_ids.lock().unwrap().as_slice(), &[
Some(current_run_id())
]);
}
#[tokio::test]
async fn agent_run_create_rejects_conflicting_parent_id() {
let mut registry = ToolRegistry::new();
let (services, _backend) = fabro_run_tool_services();
register_fabro_run_tools(&mut registry, &services);
let tool = registry
.get(fabro_tool::FABRO_RUN_CREATE_TOOL_NAME)
.expect("create tool should be registered");
let err = (tool.executor)(
serde_json::json!({
"runs": [{
"workflow": "child.fabro",
"parent_id": "01KRBZW4DW0000000000000002",
"start": false
}]
}),
tool_context(),
)
.await
.expect_err("conflicting parent should be rejected");
assert!(err.contains("parent_id"));
assert!(err.contains("current run"));
}
#[tokio::test]
async fn agent_run_tools_share_create_gather_and_events_backend() {
let (services, backend) = fabro_run_tool_services();
let mut registry = ToolRegistry::new();
register_fabro_run_tools(&mut registry, &services);
let create = registry
.get(fabro_tool::FABRO_RUN_CREATE_TOOL_NAME)
.unwrap();
(create.executor)(
serde_json::json!({
"runs": [{
"workflow": "child.fabro",
"start": false
}]
}),
tool_context(),
)
.await
.expect("create should succeed");
let gather = registry
.get(fabro_tool::FABRO_RUN_GATHER_TOOL_NAME)
.unwrap();
let gathered = (gather.executor)(
serde_json::json!({
"run_ids": [child_run_id().to_string()],
"timeout_seconds": 0
}),
tool_context(),
)
.await
.expect("gather should succeed");
let events = registry
.get(fabro_tool::FABRO_RUN_EVENTS_TOOL_NAME)
.unwrap();
let listed = (events.executor)(
serde_json::json!({
"action": "list",
"run_id": child_run_id().to_string(),
"first": 5
}),
tool_context(),
)
.await
.expect("events should succeed");
assert!(gathered.contains("gathered 1 Fabro run(s)"));
assert!(listed.contains("returned 0 Fabro event(s)"));
assert_eq!(backend.created_parent_ids.lock().unwrap().as_slice(), &[
Some(current_run_id())
]);
}
fn fabro_run_tool_services() -> (FabroRunToolServices, Arc<MockRunToolBackend>) {
let backend = Arc::new(MockRunToolBackend {
child_id: child_run_id(),
created_parent_ids: Mutex::new(Vec::new()),
});
let services = FabroRunToolServices {
backend: backend.clone(),
current_run_id: current_run_id(),
base_cwd: PathBuf::from("/tmp/fabro-test"),
user_settings_path: PathBuf::from("/tmp/fabro-test/settings.toml"),
};
(services, backend)
}
fn tool_context() -> ToolContext {
ToolContext {
env: Arc::new(LocalSandbox::new(PathBuf::from("."))),
cancel: CancellationToken::new(),
tool_env_provider: None,
}
}
fn current_run_id() -> RunId {
run_id("01KRBZW5C00000000000000001")
}
fn child_run_id() -> RunId {
run_id("01KRBZW5C00000000000000002")
}
fn run_id(raw: &str) -> RunId {
raw.parse().expect("test run id should parse")
}
fn run(run_id: RunId, parent_id: Option<RunId>, children_count: u64) -> Run {
Run {
id: run_id,
parent_id,
children_count,
title: "Test run".to_string(),
goal: "Test run".to_string(),
workflow: WorkflowRef {
slug: Some("simple".to_string()),
name: Some("Simple".to_string()),
graph_name: None,
node_count: 0,
edge_count: 0,
},
automation: None,
repository: None,
created_by: None,
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {
status: RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
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: chrono::Utc.with_ymd_and_hms(2026, 5, 21, 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 },
}
}
struct MockRunToolBackend {
child_id: RunId,
created_parent_ids: Mutex<Vec<Option<RunId>>>,
}
#[async_trait]
impl FabroToolBackend for MockRunToolBackend {
async fn create_run_from_spec(
&self,
_spec: &fabro_tool::ValidatedCreateRunSpec,
_cwd: &Path,
_user_settings_path: &Path,
parent_id: Option<RunId>,
) -> anyhow::Result<RunId> {
self.created_parent_ids.lock().unwrap().push(parent_id);
Ok(self.child_id)
}
async fn resolve_run(&self, selector: &str) -> anyhow::Result<Run> {
let run_id = selector.parse::<RunId>()?;
Ok(run(run_id, None, 0))
}
async fn retrieve_run(&self, run_id: &RunId) -> anyhow::Result<Run> {
assert_eq!(*run_id, self.child_id);
Ok(run(self.child_id, Some(current_run_id()), 0))
}
async fn start_run(&self, _run_id: &RunId, _resume: bool) -> anyhow::Result<Run> {
unreachable!("agent create test uses start=false")
}
async fn cancel_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
unreachable!()
}
async fn interrupt_run(&self, _run_id: &RunId) -> anyhow::Result<()> {
unreachable!()
}
async fn steer_run(
&self,
_run_id: &RunId,
_text: String,
_interrupt: bool,
) -> anyhow::Result<()> {
unreachable!()
}
async fn archive_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
unreachable!()
}
async fn unarchive_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {
unreachable!()
}
async fn list_store_runs(&self) -> anyhow::Result<Vec<Run>> {
unreachable!()
}
async fn list_store_runs_by_parent(&self, _parent_id: RunId) -> anyhow::Result<Vec<Run>> {
unreachable!()
}
async fn link_run_parent(
&self,
_child_id: &RunId,
_parent_id: &RunId,
) -> anyhow::Result<Run> {
unreachable!()
}
async fn unlink_run_parent(&self, _child_id: &RunId) -> anyhow::Result<Run> {
unreachable!()
}
async fn get_run_state(&self, _run_id: &RunId) -> anyhow::Result<RunProjection> {
unreachable!()
}
async fn list_run_events(
&self,
_run_id: &RunId,
_after: Option<u32>,
_limit: Option<usize>,
) -> anyhow::Result<Vec<EventEnvelope>> {
Ok(Vec::new())
}
async fn list_run_events_until(
&self,
_run_id: &RunId,
_after: Option<u32>,
_limit: usize,
) -> anyhow::Result<Vec<EventEnvelope>> {
Ok(Vec::new())
}
async fn list_run_questions(
&self,
_run_id: &RunId,
) -> anyhow::Result<Vec<types::ApiQuestion>> {
unreachable!()
}
async fn submit_run_answer(
&self,
_run_id: &RunId,
_question_id: &str,
_body: types::SubmitAnswerRequest,
) -> anyhow::Result<()> {
unreachable!()
}
}
fn new_file_tracking() -> FileTracking {
FileTracking {
pending: HashMap::new(),

View file

@ -49,6 +49,7 @@ use crate::run_metadata::metadata_branch_name;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::run_status::{FailureReason, RunStatus};
use crate::runtime_store::RunStoreHandle;
use crate::services::FabroRunToolServices;
use crate::steering_hub::SteeringHub;
use crate::workflow_bundle::{RunDefinition, WorkflowBundle};
@ -82,6 +83,7 @@ struct RunSession {
run_control: Option<Arc<RunControlState>>,
vault: Option<Arc<AsyncRwLock<Vault>>>,
catalog: Arc<Catalog>,
fabro_run_tools: Option<FabroRunToolServices>,
}
struct ResolvedStartLlm {
@ -108,6 +110,7 @@ pub struct StartServices {
pub catalog: Arc<Catalog>,
pub on_node: crate::OnNodeCallback,
pub registry_override: Option<Arc<HandlerRegistry>>,
pub fabro_run_tools: Option<FabroRunToolServices>,
}
pub struct Started {
@ -434,6 +437,7 @@ impl RunSession {
workflow_bundle,
vault: services.vault,
catalog,
fabro_run_tools: services.fabro_run_tools,
})
}
}
@ -887,6 +891,7 @@ impl RunSession {
run_control: self.run_control,
checkpoint,
seed_context: self.seed_context,
fabro_run_tools: self.fabro_run_tools,
};
let mut initialized = Box::pin(pipeline::initialize(persisted, init_options)).await?;
initialized.on_node = on_node;
@ -1402,6 +1407,7 @@ reasoning = false
catalog: test_catalog(),
on_node: None,
registry_override: Some(registry),
fabro_run_tools: None,
}
}

View file

@ -281,6 +281,7 @@ async fn execute_test_run_with_options(
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
},
)
.await
@ -346,6 +347,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
},
)
.await
@ -418,6 +420,7 @@ async fn run_with_lifecycle(
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
},
)
.await?;

View file

@ -33,7 +33,9 @@ use crate::handler::{HandlerRegistry, default_registry};
use crate::run_metadata::{RunMetadataRuntime, build_metadata_writer, metadata_branch_name};
use crate::run_options::{GitCheckpointOptions, RunOptions};
use crate::sandbox_git_runtime::SandboxGitRuntime;
use crate::services::{EngineServices, RunLocations, RunServices, WorkflowToolEnvProvider};
use crate::services::{
EngineServices, FabroRunToolServices, RunLocations, RunServices, WorkflowToolEnvProvider,
};
use crate::steering_hub::SteeringHub;
type BuiltSandboxEnv = (HashMap<String, String>, Option<Arc<GitHubTokenSource>>);
@ -123,6 +125,7 @@ async fn build_registry(
graph: &graph::Graph,
llm_source: Arc<dyn CredentialSource>,
catalog: Arc<Catalog>,
fabro_run_tools: Option<FabroRunToolServices>,
) -> Result<(Arc<HandlerRegistry>, bool), Error> {
let no_backend_interviewer = Arc::clone(&interviewer);
let build_no_backend = move || {
@ -155,9 +158,10 @@ async fn build_registry(
let catalog_for_api = Arc::clone(&catalog);
let steering_hub_for_api = Arc::clone(&steering_hub);
let tool_env_provider_for_backend = Arc::clone(&tool_env_provider);
let fabro_run_tools_for_api = fabro_run_tools.clone();
Arc::new(default_registry(interviewer, move || {
let tool_env_provider = Arc::clone(&tool_env_provider_for_backend);
let api = AgentApiBackend::new_with_catalog(
let mut api = AgentApiBackend::new_with_catalog(
model.clone(),
provider_id.clone(),
fallback_chain.clone(),
@ -168,6 +172,9 @@ async fn build_registry(
.with_run_model_controls(model_controls.clone())
.with_tool_env_provider(tool_env_provider.clone())
.with_mcp_servers(mcp_servers.clone());
if let Some(services) = fabro_run_tools_for_api.clone() {
api = api.with_fabro_run_tools(services);
}
let acp = AgentAcpBackend::new()
.with_tool_env_provider(tool_env_provider.clone(), github_token_refresh_managed)
.with_steering_hub(Arc::clone(&steering_hub));
@ -502,6 +509,7 @@ pub async fn initialize(
&graph,
Arc::clone(&llm_source),
Arc::clone(&catalog),
options.fabro_run_tools.clone(),
)
.await?
};
@ -899,6 +907,7 @@ mod tests {
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
})
.await;
let events = seen.lock().unwrap().clone();
@ -960,6 +969,7 @@ mod tests {
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
})
.await
.unwrap();
@ -1042,6 +1052,7 @@ mod tests {
&graph,
Arc::new(VaultCredentialSource::new(Arc::clone(&vault))),
test_catalog(),
None,
)
.await
.unwrap();
@ -1158,6 +1169,7 @@ mod tests {
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
})
.await
.unwrap();
@ -1256,6 +1268,7 @@ mod tests {
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
})
.await
.unwrap();
@ -1372,6 +1385,7 @@ mod tests {
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
})
.await;
@ -1439,6 +1453,7 @@ mod tests {
artifact_sink: None,
checkpoint: None,
seed_context: None,
fabro_run_tools: None,
})
.await;

View file

@ -24,7 +24,7 @@ use crate::records::{Checkpoint, Conclusion, RunSpec};
use crate::run_control::RunControlState;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::runtime_store::RunStoreHandle;
use crate::services::{EngineServices, RunServices};
use crate::services::{EngineServices, FabroRunToolServices, RunServices};
use crate::steering_hub::SteeringHub;
use crate::transforms::{RenderMode, Transform};
use crate::workflow_bundle::WorkflowBundle;
@ -275,6 +275,7 @@ pub struct InitOptions {
pub run_control: Option<Arc<RunControlState>>,
pub checkpoint: Option<Checkpoint>,
pub seed_context: Option<Context>,
pub fabro_run_tools: Option<FabroRunToolServices>,
}
/// Output of the INITIALIZE phase.

View file

@ -10,7 +10,7 @@ use fabro_auth::CredentialSource;
use fabro_auth::ResolvedCredentials;
use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner};
use fabro_model::{Catalog, ProviderId};
use fabro_types::ManifestPath;
use fabro_types::{ManifestPath, RunId};
use tokio_util::sync::CancellationToken;
use crate::event::Emitter;
@ -73,6 +73,14 @@ impl RunLocations {
}
}
#[derive(Clone)]
pub struct FabroRunToolServices {
pub backend: Arc<dyn fabro_tool::FabroToolBackend>,
pub current_run_id: RunId,
pub base_cwd: PathBuf,
pub user_settings_path: PathBuf,
}
/// Services shared across workflow phases.
///
/// Production construction is expected to happen from pipeline initialization