fabro/lib/components/fabro-workflow/src/lib.rs
Bryan Helmkamp 18a3c4741e
Run agent stages, Ask Fabro, and fabro exec on pebble's CodingAgent
Replace fabro's hand-written agent loop with pebble's `CodingAgent` and
delete the `fabro-agent` crate.

Workflow: `PebbleBackend` builds one agent per stage over `RunSandbox`,
binds the stage's hooks as tool middleware, the interviewer as the
human-input provider, and a durable `EventSink` that writes every agent
event through the run event log before the agent goes on. Full-fidelity
threads continue across stages through `export`/`resume_from_export`.
Model failover takes the session record after the failed prompt and
continues it on the next route with `ResumeMode::UseModel`, so no tool
effect repeats. The steering hub targets pebble's control handle, with
a steering lease holding completion open while a human is paired.

Events: `EventBody::Agent` carries pebble's `CodingAgentEvent` envelope;
the per-variant bodies, the transcript projection, and the fabro-only
context-window, tool-summary, and skill types are gone in favor of
pebble's. The OpenAPI schemas, generated Rust and TypeScript clients,
and web readers follow.

Ask Fabro: the session runs a `CodingAgent` under a read-only permission
policy and a system prompt transform. Its conversation lives in a new
`run_session_records` table and resumes on the recorded model with the
event cursor advanced past the run log.

`fabro exec` builds the same agent over a local sandbox with pebble's
permission middleware and an interactive approval service.

The catalog fills in `metadata.agent.profile` for operator providers
that declare none, so pebble's lookup is the one resolution path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:19:15 -06:00

342 lines
12 KiB
Rust

#![cfg_attr(
test,
allow(
clippy::absolute_paths,
clippy::get_unwrap,
clippy::large_futures,
clippy::needless_borrows_for_generic_args,
clippy::option_option,
clippy::ptr_as_ptr,
clippy::ref_as_ptr,
clippy::cast_ptr_alignment,
clippy::uninlined_format_args,
clippy::unnecessary_literal_bound,
reason = "Test-only workflow helpers favor explicit fixtures over pedantic style lints."
)
)]
use std::collections::HashMap;
use std::sync::Arc;
use fabro_store::EventEnvelope;
use fabro_types::{EventBody, StageId};
/// Callback invoked when a workflow node starts executing.
pub type OnNodeCallback = Option<Arc<dyn Fn(&str) + Send + Sync>>;
/// Convert a Duration's milliseconds to u64, saturating on overflow.
pub(crate) fn millis_u64(d: std::time::Duration) -> u64 {
u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
}
/// Extract the timing breakdown from a `stage.completed` / `stage.failed`
/// event body, or `None` for any other variant.
fn stage_completion_timing(body: &EventBody) -> Option<fabro_types::StageTiming> {
match body {
EventBody::StageCompleted(props) => Some(props.timing),
EventBody::StageFailed(props) => Some(props.timing),
_ => None,
}
}
/// Extract per-stage (node_id, visit) timing from `stage.completed` /
/// `stage.failed` events. Keys on the full [`StageId`] so multi-visit stages
/// (e.g. a looped `verify` node) keep distinct timings.
///
/// This is the canonical primitive; [`total_stage_timing_by_node`] and
/// [`latest_stage_timing_by_node`] are explicit rollups built on top of it.
pub fn extract_stage_timings_by_stage_id(
events: &[EventEnvelope],
) -> HashMap<StageId, fabro_types::StageTiming> {
let mut timings = HashMap::new();
for envelope in events {
let Some(timing) = stage_completion_timing(&envelope.event.body) else {
continue;
};
let Some(stage_id) = envelope.event.stage_id.as_ref() else {
continue;
};
timings.insert(stage_id.clone(), timing);
}
timings
}
/// Sum of timing in each node across every visit. Use for billing/usage
/// where a retried node should count its full time. `wall_time_ms`,
/// `inference_time_ms`, `tool_time_ms`, and `active_time_ms` are all summed
/// per node.
pub fn total_stage_timing_by_node(
events: &[EventEnvelope],
) -> HashMap<String, fabro_types::StageTiming> {
let mut totals: HashMap<String, fabro_types::StageTiming> = HashMap::new();
for (stage_id, timing) in extract_stage_timings_by_stage_id(events) {
let entry = totals.entry(stage_id.node_id().to_string()).or_default();
*entry = entry.saturating_add(&timing);
}
totals
}
/// Timing of each node's most recent visit (the highest visit number). Use
/// for run summaries where the table shows one row per node and "the last
/// attempt" is the right representative.
pub fn latest_stage_timing_by_node(
events: &[EventEnvelope],
) -> HashMap<String, fabro_types::StageTiming> {
let mut entries: Vec<(StageId, fabro_types::StageTiming)> =
extract_stage_timings_by_stage_id(events)
.into_iter()
.collect();
entries.sort_by_key(|(stage_id, _)| stage_id.visit());
let mut latest = HashMap::new();
for (stage_id, timing) in entries {
latest.insert(stage_id.node_id().to_string(), timing);
}
latest
}
#[cfg(test)]
mod duration_tests {
use chrono::{TimeZone, Utc};
use fabro_store::EventEnvelope;
use fabro_types::run_event::{StageCompletedProps, StageFailedProps};
use fabro_types::{EventBody, RunEvent, StageId, StageOutcome, StageTiming, fixtures};
use super::{
extract_stage_timings_by_stage_id, latest_stage_timing_by_node, total_stage_timing_by_node,
};
fn completed_event(seq: u32, node: &str, visit: u32, wall_time_ms: u64) -> EventEnvelope {
let event = RunEvent {
id: format!("evt_{seq}"),
ts: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
run_id: fixtures::RUN_1,
node_id: Some(node.to_string()),
node_label: None,
stage_id: Some(StageId::new(node, visit)),
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::StageCompleted(StageCompletedProps {
index: 0,
timing: StageTiming::wall_only(wall_time_ms),
status: StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: vec![],
billing: None,
failure: None,
notes: None,
files_touched: vec![],
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: None,
loop_failure_signatures: None,
restart_failure_signatures: None,
response: None,
attempt: 1,
max_attempts: 1,
}),
};
EventEnvelope { seq, event }
}
fn failed_event(seq: u32, node: &str, visit: u32, wall_time_ms: u64) -> EventEnvelope {
let event = RunEvent {
id: format!("evt_{seq}"),
ts: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
run_id: fixtures::RUN_1,
node_id: Some(node.to_string()),
node_label: None,
stage_id: Some(StageId::new(node, visit)),
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::StageFailed(StageFailedProps {
index: 0,
failure: None,
will_retry: true,
timing: StageTiming::wall_only(wall_time_ms),
billing: None,
}),
};
EventEnvelope { seq, event }
}
#[test]
fn extract_keys_timings_by_full_stage_id() {
let events = vec![
completed_event(1, "verify", 1, 100),
completed_event(2, "verify", 2, 200),
];
let timings = extract_stage_timings_by_stage_id(&events);
assert_eq!(
timings
.get(&StageId::new("verify", 1))
.map(|t| t.wall_time_ms),
Some(100)
);
assert_eq!(
timings
.get(&StageId::new("verify", 2))
.map(|t| t.wall_time_ms),
Some(200)
);
}
#[test]
fn total_sums_wall_time_across_visits_per_node() {
let events = vec![
completed_event(1, "verify", 1, 100),
completed_event(2, "verify", 2, 200),
completed_event(3, "build", 1, 50),
];
let totals = total_stage_timing_by_node(&events);
assert_eq!(totals.get("verify").map(|t| t.wall_time_ms), Some(300));
assert_eq!(totals.get("build").map(|t| t.wall_time_ms), Some(50));
}
#[test]
fn latest_picks_highest_visit_regardless_of_input_order() {
// Visit 2 appears in the events vector before visit 1; the result
// must still reflect visit 2's timing (the latest visit).
let events = vec![
completed_event(1, "verify", 2, 999),
completed_event(2, "verify", 1, 100),
];
let latest = latest_stage_timing_by_node(&events);
assert_eq!(latest.get("verify").map(|t| t.wall_time_ms), Some(999));
}
#[test]
fn stage_failed_timings_are_included() {
let events = vec![failed_event(1, "verify", 1, 75)];
let timings = extract_stage_timings_by_stage_id(&events);
assert_eq!(
timings
.get(&StageId::new("verify", 1))
.map(|t| t.wall_time_ms),
Some(75)
);
}
#[test]
fn total_sums_active_breakdown_across_visits() {
// Same node visited twice with different inference/tool breakdowns:
// the rollup must add inference, tool, and active fields, not just
// wall time. This guards against accidentally summing wall only.
fn timed_completed(seq: u32, visit: u32, timing: StageTiming) -> EventEnvelope {
let event = RunEvent {
id: format!("evt_{seq}"),
ts: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
run_id: fixtures::RUN_1,
node_id: Some("agent".to_string()),
node_label: None,
stage_id: Some(StageId::new("agent", visit)),
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::StageCompleted(StageCompletedProps {
index: 0,
timing,
status: StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: vec![],
billing: None,
failure: None,
notes: None,
files_touched: vec![],
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: None,
loop_failure_signatures: None,
restart_failure_signatures: None,
response: None,
attempt: 1,
max_attempts: 1,
}),
};
EventEnvelope { seq, event }
}
let events = vec![
timed_completed(1, 1, StageTiming::new(1000, 600, 300)),
timed_completed(2, 2, StageTiming::new(700, 400, 200)),
];
let totals = total_stage_timing_by_node(&events);
let agent = totals.get("agent").copied().unwrap();
assert_eq!(agent.wall_time_ms, 1700);
assert_eq!(agent.inference_time_ms, 1000);
assert_eq!(agent.tool_time_ms, 500);
assert_eq!(agent.active_time_ms, 1500);
}
}
#[doc(hidden)]
pub mod agent_memory;
pub mod artifact;
pub mod artifact_snapshot;
pub mod artifact_upload;
pub mod billing_rollup;
pub mod command_log;
pub(crate) mod condition;
pub mod context;
pub mod error;
pub mod event;
pub mod file_resolver;
pub mod git;
pub(crate) mod git_bridge;
pub(crate) mod graph;
pub mod handler;
mod hook_context;
mod interview_runtime;
#[allow(
dead_code,
reason = "The lifecycle module remains crate-visible for tests and pending integrations."
)]
pub(crate) mod lifecycle;
pub mod model_fallback;
pub(crate) mod node_handler;
pub mod operations;
pub mod outcome;
pub mod pipeline;
pub mod pull_request;
pub mod records;
mod retry;
pub mod run_control;
pub(crate) mod run_dir;
pub mod run_lookup;
pub use billing_rollup::{
ProjectionBillingByModel, ProjectionBillingRollup, ProjectionBillingStage,
billing_rollup_from_projection,
};
pub use error::{Error, FailureCategory, FailureSignature, FailureSignatureExt, Result};
pub use fabro_types::ManifestPath;
pub use steering_hub::{PairControlError, SteeringHub};
pub mod run_materialization;
pub(crate) mod run_metadata;
pub mod run_options;
pub mod run_status;
pub mod runtime_store;
pub mod sandbox_git;
pub(crate) mod sandbox_git_runtime;
pub mod services;
pub(crate) mod stage_execution;
mod stage_scope;
pub mod steering_hub;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
#[doc(hidden)]
pub mod transforms;
pub mod web_search;
pub mod workflow_bundle;