fabro/lib/components/fabro-tool/src/common.rs
Bryan Helmkamp bc31b8d23a
Serve the run stream as the only run event API
Step 4 of the legacy executor deletion, third commit: the legacy event
API and every reader of it go, so that the next commits can delete the
event log, its reducer and the types beneath them.

The API:
- `GET /runs/{id}/events` pages the run stream only
  (`PaginatedRunStreamList` by `after`); the legacy `since_seq`,
  `before_seq` and `order` cursors, the `oneOf` envelope, the legacy
  `EventEnvelope`, `PaginatedEventList`, `RunEvent`, `EventSeq`,
  `AppendEventResponse` and `RunEventDetailResponse` schemas,
  `POST /runs/{id}/events`, `GET /runs/{id}/events/{seq}` and
  `GET /runs/{id}/stages/{stageId}/events` are deleted. `GET
  /runs/{id}/attach` and `GET /attach` frame `RunStreamItem`s only.
- The Rust and TypeScript clients regenerate; the removed models leave
  the TypeScript package.

The readers:
- `fabro-client` drops the legacy run event listing, tail and attach
  methods and `RunEventStream`; `list_run_stream_until` bounds a stream
  read.
- `fabro-tool`'s `fabro_run_events` lists, searches and details the run
  stream: `after` is the exclusive `stream_seq` cursor, `event_id` the
  item's id, filters match the item's name and `recorded_at`.
- `fabro-dump` writes the stream to `events.jsonl`; `fabro dump` reads
  it.
- The CLI's progress renderer keeps only what the run stream drives:
  the legacy event conversion, the sandbox and setup displays and their
  styles go. `fabro system events` prints stream items.
- The server's demo mode folds its agent fixture straight into the
  session projection and answers the attach stub with a stream item;
  the demo stage events endpoint is gone.
- The web app: every run is a Petri run. The legacy event hooks,
  renderer props, stage popover summary, run phases derivation and
  live-event payload handling are deleted or ported to `RunStreamItem`;
  toasts and board refreshes read the stream's platform records.
- Tests: the legacy API round trips and pagination tests are deleted;
  the CLI's MCP, attach and system event mocks serve stream pages; the
  CLI test helpers read stream items.

Still failing until the later commits: the CLI tests seeded through
`POST /runs/{id}/events`, the server tests over the legacy store, and
the legacy type tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:36:00 -04:00

479 lines
17 KiB
Rust

use std::collections::HashMap;
use std::sync::LazyLock;
use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, Utc};
use fabro_api::types;
use fabro_types::{
PairId, PairMessageRecord, PairMessageRequest, PairRecord, PairTranscriptResponse, Run, RunId,
RunPairStatusResponse, StageId,
};
use fabro_util::exit::{self, ExitClass};
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug)]
pub struct ToolError {
message: String,
}
impl ToolError {
pub fn message(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn from_anyhow(err: &anyhow::Error) -> Self {
Self::message(format_tool_error(err))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.message
}
}
impl std::fmt::Display for ToolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ToolError {}
pub type ToolResult<T> = Result<T, ToolError>;
#[async_trait]
pub trait FabroToolBackend: Send + Sync {
async fn create_workflow_version(
&self,
_source: crate::ValidatedWorkflowVersionCreate,
) -> anyhow::Result<fabro_types::WorkflowVersionId> {
Err(workflow_version_tool_unavailable_error())
}
async fn create_run_from_intent(&self, intent: fabro_types::RunIntent)
-> 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 approve_run(&self, run_id: &RunId) -> anyhow::Result<Run>;
async fn deny_run(&self, run_id: &RunId, reason: Option<String>) -> 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>;
/// The run's stream past `after` (the last `stream_seq` seen; `0` from
/// the start), at most `limit` items when a limit is given.
async fn list_run_stream(
&self,
run_id: &RunId,
after: u64,
limit: Option<usize>,
) -> anyhow::Result<Vec<fabro_types::RunStreamItem>>;
async fn list_run_questions(&self, run_id: &RunId) -> anyhow::Result<Vec<types::ApiQuestion>>;
async fn submit_run_answer(
&self,
run_id: &RunId,
question_id: &str,
body: types::SubmitAnswerRequest,
) -> anyhow::Result<()>;
async fn get_run_pair_status(&self, _run_id: &RunId) -> anyhow::Result<RunPairStatusResponse> {
Err(pair_tool_unavailable_error())
}
async fn start_run_pair(
&self,
_run_id: &RunId,
_stage_id: StageId,
) -> anyhow::Result<PairRecord> {
Err(pair_tool_unavailable_error())
}
async fn get_run_pair(&self, _run_id: &RunId, _pair_id: &PairId) -> anyhow::Result<PairRecord> {
Err(pair_tool_unavailable_error())
}
async fn end_run_pair(&self, _run_id: &RunId, _pair_id: &PairId) -> anyhow::Result<PairRecord> {
Err(pair_tool_unavailable_error())
}
async fn send_run_pair_message(
&self,
_run_id: &RunId,
_pair_id: &PairId,
_request: PairMessageRequest,
) -> anyhow::Result<PairMessageRecord> {
Err(pair_tool_unavailable_error())
}
async fn get_run_pair_transcript(
&self,
_run_id: &RunId,
_pair_id: &PairId,
_since_seq: Option<u32>,
_limit: Option<u32>,
) -> anyhow::Result<PairTranscriptResponse> {
Err(pair_tool_unavailable_error())
}
}
fn pair_tool_unavailable_error() -> anyhow::Error {
ToolError::message(format!("{FABRO_RUN_PAIR_TOOL_NAME} is not available")).into()
}
pub(crate) fn workflow_version_tool_unavailable_error() -> anyhow::Error {
ToolError::message(format!(
"{FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME} is not available"
))
.into()
}
#[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_WORKFLOW_VERSION_CREATE_TOOL_NAME: &str = "fabro_workflow_version_create";
pub const FABRO_RUN_CREATE_TOOL_NAME: &str = "fabro_run_create";
pub const FABRO_RUN_SEARCH_TOOL_NAME: &str = "fabro_run_search";
pub const FABRO_RUN_GET_TOOL_NAME: &str = "fabro_run_get";
pub const FABRO_RUN_INTERACT_TOOL_NAME: &str = "fabro_run_interact";
pub const FABRO_RUN_GATHER_TOOL_NAME: &str = "fabro_run_gather";
pub const FABRO_RUN_EVENTS_TOOL_NAME: &str = "fabro_run_events";
pub const FABRO_RUN_PAIR_TOOL_NAME: &str = "fabro_run_pair";
static TOOL_DEFINITIONS: LazyLock<Vec<ToolDefinition>> = LazyLock::new(|| {
vec![
tool_definition::<crate::FabroWorkflowVersionCreateParams>(
FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME,
"Register supplied workflow file contents and all local dependencies as a reusable immutable workflow version ID. Obtain files with shell/read tools first; this does not create or start a run.",
),
tool_definition::<crate::FabroRunCreateParams>(
FABRO_RUN_CREATE_TOOL_NAME,
"Create runs from registered workflow_version_id values and canonical RunIntent settings. Register contents with fabro_workflow_version_create first. Standalone calls require an explicit target; native workers may inherit the parent target. Starts runs by default.",
),
tool_definition::<crate::FabroRunSearchParams>(
FABRO_RUN_SEARCH_TOOL_NAME,
"Search Fabro workflow runs by id, parent, workflow, labels, status, archival state, and creation time.",
),
tool_definition::<crate::FabroRunGetParams>(
FABRO_RUN_GET_TOOL_NAME,
"Read-only inspection of a Fabro run: returns its summary, projection, and pending questions without mutating state.",
),
tool_definition::<crate::FabroRunInteractParams>(
FABRO_RUN_INTERACT_TOOL_NAME,
"Control a Fabro run: start, approve, deny, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect or answer questions. Use fabro_run_get for read-only inspection.",
),
tool_definition::<crate::FabroRunGatherParams>(
FABRO_RUN_GATHER_TOOL_NAME,
"Wait for Fabro runs to reach terminal states, returning current state on timeout.",
),
tool_definition::<crate::FabroRunPairParams>(
FABRO_RUN_PAIR_TOOL_NAME,
"Inspect, start, message, end, or read transcript for a live Fabro run pairing session.",
),
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.lifecycle.status.kind().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))
}
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_api::types::Usage;
use fabro_types::{
RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef, test_support,
};
use super::*;
fn shared_tool_names() -> Vec<&'static str> {
tool_definitions()
.iter()
.map(|definition| definition.name)
.collect()
}
#[test]
fn shared_tool_definitions_include_run_management_catalog() {
assert_eq!(shared_tool_names(), vec![
FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME,
FABRO_RUN_CREATE_TOOL_NAME,
FABRO_RUN_SEARCH_TOOL_NAME,
FABRO_RUN_GET_TOOL_NAME,
FABRO_RUN_INTERACT_TOOL_NAME,
FABRO_RUN_GATHER_TOOL_NAME,
FABRO_RUN_PAIR_TOOL_NAME,
FABRO_RUN_EVENTS_TOOL_NAME,
]);
}
#[test]
fn workflow_version_create_has_strict_content_schema() {
let definition = tool_definitions()
.iter()
.find(|definition| definition.name == FABRO_WORKFLOW_VERSION_CREATE_TOOL_NAME)
.expect("workflow version creation should be in the shared catalog");
let schema = &definition.parameters;
assert_eq!(schema["additionalProperties"], false);
assert_eq!(schema["properties"].as_object().unwrap().len(), 2);
assert_eq!(
schema["required"],
serde_json::json!(["entrypoint", "files"])
);
}
#[test]
fn pair_tool_definition_exposes_pair_schema() {
let definition = tool_definitions()
.iter()
.find(|definition| definition.name == FABRO_RUN_PAIR_TOOL_NAME)
.expect("pair tool should be in the shared catalog");
let schema = &definition.parameters;
let schema_text = schema.to_string();
assert_eq!(
definition.description,
"Inspect, start, message, end, or read transcript for a live Fabro run pairing session."
);
for field in [
"action",
"run_id",
"pair_id",
"stage_id",
"text",
"client_message_id",
"since_seq",
"limit",
] {
assert!(
schema.pointer(&format!("/properties/{field}")).is_some(),
"pair schema should expose {field}: {schema}"
);
}
for action in ["status", "start", "get", "message", "end", "transcript"] {
assert!(
schema_text.contains(&format!("\"{action}\"")),
"pair schema should expose action {action}: {schema}"
);
}
}
#[test]
fn interact_tool_definition_exposes_approval_schema() {
let definition = tool_definitions()
.iter()
.find(|definition| definition.name == FABRO_RUN_INTERACT_TOOL_NAME)
.expect("interact tool should be in the shared catalog");
let schema = &definition.parameters;
let schema_text = schema.to_string();
assert!(
definition.description.contains("approve") && definition.description.contains("deny"),
"interact description should include approval actions: {}",
definition.description
);
for field in ["action", "run_id", "reason"] {
assert!(
schema.pointer(&format!("/properties/{field}")).is_some(),
"interact schema should expose {field}: {schema}"
);
}
for action in ["approve", "deny"] {
assert!(
schema_text.contains(&format!("\"{action}\"")),
"interact schema should expose action {action}: {schema}"
);
}
}
#[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: test_support::test_principal(),
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {
status: RunStatus::Submitted,
approval: None,
pending_control: None,
queue_position: None,
error: None,
archived: false,
archived_at: None,
},
sandbox: None,
models: Vec::new(),
source_directory: None,
timestamps: RunTimestamps {
created_at: Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, 0).unwrap(),
started_at: None,
last_event_at: None,
completed_at: None,
},
timing: None,
usage: Usage::default(),
size: fabro_types::RunSize::default(),
ask_fabro: fabro_types::AskFabro::default(),
diff: None,
pull_request: None,
current_question: None,
superseded_by: None,
retried_from: 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")
}
}