From 8fba42ed186b9d894d42efb5ebb6acd6f356391f Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 19:15:49 +0000 Subject: [PATCH 01/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): toolchain (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 2 Fabro-Checkpoint: 07b27f2f75a56f0e60745a3c3bc7ed221c82db9a ⚒️ Generated with [Fabro](https://fabro.sh) From d23a9dd17a82f32fc80c2c17911cfbeca7a787d5 Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 19:18:16 +0000 Subject: [PATCH 02/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): preflight_compile (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 3 Fabro-Checkpoint: 6ce5170d04594e631beea2237b1122ccd6946e7a ⚒️ Generated with [Fabro](https://fabro.sh) From ed59858bd6b316a4266b6e2338eea0101b917830 Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 19:20:56 +0000 Subject: [PATCH 03/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): preflight_lint (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 4 Fabro-Checkpoint: e8a60ed97b8ed74a94032532e30217181c6d0856 ⚒️ Generated with [Fabro](https://fabro.sh) From 4746d143fd6acf225395b05888c5f344bfeb4eab Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 20:35:54 +0000 Subject: [PATCH 04/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): implement (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 5 Fabro-Checkpoint: c84d4147ba05325d00cc637865210f56e0de634b ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/components/fabro-store/src/error.rs | 2 + .../fabro-store/src/run_summary_store.rs | 5 + lib/components/fabro-store/src/slate/mod.rs | 205 ++++++++++++++++++ .../fabro-store/src/slate/projection_cache.rs | 69 +----- .../fabro-store/src/slate/run_store.rs | 175 +++++++-------- lib/components/fabro-store/src/types.rs | 2 +- 6 files changed, 295 insertions(+), 163 deletions(-) diff --git a/lib/components/fabro-store/src/error.rs b/lib/components/fabro-store/src/error.rs index 43c26b44a..5f2fdee80 100644 --- a/lib/components/fabro-store/src/error.rs +++ b/lib/components/fabro-store/src/error.rs @@ -14,6 +14,8 @@ pub enum Error { Io(#[from] std::io::Error), #[error("Invalid event payload: {0}")] InvalidEvent(String), + #[error("event rejected by run projection: {reason}")] + EventRejected { reason: String }, #[error("Run not found: {0}")] RunNotFound(String), #[error("Run already exists: {0}")] diff --git a/lib/components/fabro-store/src/run_summary_store.rs b/lib/components/fabro-store/src/run_summary_store.rs index d80936dc7..c1a339b99 100644 --- a/lib/components/fabro-store/src/run_summary_store.rs +++ b/lib/components/fabro-store/src/run_summary_store.rs @@ -154,6 +154,11 @@ impl RunSummaryStore { Ok(()) } + #[cfg(test)] + pub(crate) async fn close_pool(&self) { + self.pool.close().await; + } + pub(crate) async fn reconcile(&self, entries: &[CachedRunProjection]) -> Result<()> { let mut transaction = self.pool.begin().await?; let stored_seqs: HashMap = diff --git a/lib/components/fabro-store/src/slate/mod.rs b/lib/components/fabro-store/src/slate/mod.rs index 275084dbb..c9750c35a 100644 --- a/lib/components/fabro-store/src/slate/mod.rs +++ b/lib/components/fabro-store/src/slate/mod.rs @@ -684,6 +684,57 @@ mod tests { .unwrap(); } + async fn append_runnable(run: &RunDatabase, label: &str, created_at: DateTime) { + append_created(run, label, created_at).await; + run.append_event(&event_payload( + label, + "2026-03-27T12:00:01Z", + "run.submitted", + &serde_json::json!({}), + )) + .await + .unwrap(); + run.append_event(&event_payload( + label, + "2026-03-27T12:00:02Z", + "run.start_requested", + &serde_json::json!({ "resume": false }), + )) + .await + .unwrap(); + run.append_event(&event_payload( + label, + "2026-03-27T12:00:03Z", + "run.runnable", + &serde_json::json!({ "source": "start_requested" }), + )) + .await + .unwrap(); + } + + fn workflow_failure_payload(label: &str) -> EventPayload { + event_payload( + label, + "2026-03-27T12:00:04Z", + "run.failed", + &serde_json::json!({ + "failure": { + "reason": "workflow_error", + "detail": { + "message": "workflow failed", + "category": "deterministic" + } + }, + "timing": { + "wall_time_ms": 1, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + }), + ) + } + async fn append_completed(run: &RunDatabase, label: &str, created_at: DateTime) { append_running(run, label, created_at).await; run.append_event(&event_payload( @@ -849,6 +900,160 @@ mod tests { assert_eq!(run.list_events().await.unwrap().len(), 2); } + #[tokio::test] + async fn rejected_transition_writes_nothing_and_preserves_projection_cache() { + let (_object_store, store) = make_store(); + let run_id = test_run_id("run-1"); + let run = store.create_run(&run_id).await.unwrap(); + append_runnable(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; + let events_before = run.list_events().await.unwrap(); + + let err = run + .append_event(&workflow_failure_payload("run-1")) + .await + .unwrap_err(); + + let Error::EventRejected { reason } = err else { + panic!("expected event rejection"); + }; + assert_eq!( + reason, + "invalid status transition: runnable -> failed(workflow_error)" + ); + assert_eq!(run.list_events().await.unwrap(), events_before); + assert_eq!(run.state().await.unwrap().status, RunStatus::Runnable); + let cached = store.get_cached_run(&run_id).await.unwrap().unwrap(); + assert_eq!(cached.last_seq, 4); + assert_eq!(cached.projection.status, RunStatus::Runnable); + } + + #[tokio::test] + async fn rejected_transition_leaves_reconciled_summary_present() { + let (_object_store, store) = make_store(); + let (_directory, summaries) = make_summary_store().await; + store.attach_run_summary_store(Arc::clone(&summaries)); + let run_id = test_run_id("run-1"); + let run = store.create_run(&run_id).await.unwrap(); + append_runnable(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; + + let err = run + .append_event(&workflow_failure_payload("run-1")) + .await + .unwrap_err(); + assert!(matches!(err, Error::EventRejected { .. })); + + let entries = store + .list_cached_runs(&ListRunsQuery::default(), Utc::now()) + .await + .unwrap(); + summaries.reconcile(&entries).await.unwrap(); + let summary = summaries.get(&run_id, Utc::now()).await.unwrap().unwrap(); + assert_eq!(summary.lifecycle.status, RunStatus::Runnable); + } + + #[tokio::test] + async fn committed_append_succeeds_when_summary_update_fails_and_is_repairable() { + let (_object_store, store) = make_store(); + let (directory, summaries) = make_summary_store().await; + store.attach_run_summary_store(Arc::clone(&summaries)); + let run_id = test_run_id("run-1"); + let run = store.create_run(&run_id).await.unwrap(); + append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; + summaries.close_pool().await; + + let result = run + .append_event_envelope(&event_payload( + "run-1", + "2026-03-27T12:00:01Z", + "run.title.updated", + &serde_json::json!({ "title": "Committed title" }), + )) + .await; + + assert!(result.is_ok(), "committed append returned {result:?}"); + assert_eq!(run.list_events().await.unwrap().len(), 2); + let cached = store.get_cached_run(&run_id).await.unwrap().unwrap(); + assert_eq!(cached.last_seq, 2); + assert_eq!(cached.summary.title, "Committed title"); + let stored = run.get_event(2).await.unwrap().unwrap(); + assert_eq!(stored.event, result.unwrap().event); + + let repaired_database = fabro_db::Database::connect(directory.path().join("fabro.sqlite3")) + .await + .unwrap(); + repaired_database.migrate().await.unwrap(); + let repaired_summaries = RunSummaryStore::new(repaired_database.clone_pool()); + let stale = repaired_summaries + .get(&run_id, Utc::now()) + .await + .unwrap() + .unwrap(); + assert_ne!(stale.title, "Committed title"); + + let entries = store + .list_cached_runs(&ListRunsQuery::default(), Utc::now()) + .await + .unwrap(); + repaired_summaries.reconcile(&entries).await.unwrap(); + let repaired = repaired_summaries + .get(&run_id, Utc::now()) + .await + .unwrap() + .unwrap(); + assert_eq!(repaired.title, "Committed title"); + } + + #[tokio::test] + async fn first_event_is_validated_before_write() { + let (_object_store, store) = make_store(); + let run_id = test_run_id("run-1"); + let run = store.create_run(&run_id).await.unwrap(); + let invalid_first = event_payload( + "run-1", + "2026-03-27T12:00:00Z", + "run.title.updated", + &serde_json::json!({ "title": "Too early" }), + ); + + let err = run.append_event(&invalid_first).await.unwrap_err(); + + assert!(matches!(err, Error::EventRejected { .. })); + assert!(run.list_events().await.unwrap().is_empty()); + + append_created(&run, "run-1", dt("2026-03-27T12:00:01Z")).await; + assert_eq!(run.list_events().await.unwrap().len(), 1); + assert!(run.state().await.is_ok()); + } + + #[tokio::test] + async fn malformed_optional_envelope_field_is_rejected_before_write() { + let (_object_store, store) = make_store(); + let run_id = test_run_id("run-1"); + let run = store.create_run(&run_id).await.unwrap(); + let malformed = EventPayload::new( + serde_json::json!({ + "id": "evt-created", + "ts": "2026-03-27T12:00:00Z", + "run_id": run_id.to_string(), + "event": "run.created", + "node_id": 42, + "properties": { + "settings": WorkflowSettings::default(), + "graph": Graph::new("test"), + "run_dir": "/tmp/test", + "provenance": test_support::test_run_provenance(), + }, + }), + &run_id, + ) + .unwrap(); + + let err = run.append_event(&malformed).await.unwrap_err(); + + assert!(matches!(err, Error::InvalidEvent(_))); + assert!(run.list_events().await.unwrap().is_empty()); + } + #[tokio::test] async fn control_request_events_set_pending_control_without_overwriting_status() { let (_object_store, store) = make_store(); diff --git a/lib/components/fabro-store/src/slate/projection_cache.rs b/lib/components/fabro-store/src/slate/projection_cache.rs index 7cec9a204..79ff0c03b 100644 --- a/lib/components/fabro-store/src/slate/projection_cache.rs +++ b/lib/components/fabro-store/src/slate/projection_cache.rs @@ -5,8 +5,8 @@ use chrono::{DateTime, Utc}; use fabro_types::{Run, RunId, RunProjection}; use tokio::sync::Mutex; -use crate::run_state::{RunProjectionReducer, build_summary}; -use crate::{Error, EventEnvelope, ListRunsQuery, Result}; +use crate::ListRunsQuery; +use crate::run_state::build_summary; #[derive(Debug, Clone)] pub struct CachedRunProjection { @@ -85,26 +85,6 @@ impl RunProjectionCacheState { } } - fn update_parent_index( - &mut self, - run_id: RunId, - previous_parent_id: Option, - parent_id: Option, - ) { - if previous_parent_id == parent_id { - return; - } - if let Some(previous_parent_id) = previous_parent_id { - self.remove_parent_link(&previous_parent_id, &run_id); - } - if let Some(parent_id) = parent_id { - self.children_by_parent - .entry(parent_id) - .or_default() - .insert(run_id); - } - } - fn count_children(&self, run_id: &RunId) -> u64 { self.children_by_parent .get(run_id) @@ -222,51 +202,6 @@ impl RunProjectionCache { Some(entry.summary) } - pub(crate) async fn apply_event( - &self, - run_id: &RunId, - event: &EventEnvelope, - ) -> Result { - let mut state = self.state.lock().await; - let Some(entry) = state.entries.get(run_id) else { - if event.seq == 1 { - let projection = RunProjection::apply_events(std::slice::from_ref(event))?; - let entry = CachedRunProjection::from_projection(*run_id, projection, event.seq); - state.insert(entry.clone()); - return Ok(entry); - } - return Err(Error::InvalidEvent(format!( - "projection cache cannot initialize run {run_id} from event seq {}", - event.seq - ))); - }; - - let last_seq = entry.last_seq; - if event.seq <= last_seq { - return Ok(entry.clone()); - } - if event.seq != last_seq.saturating_add(1) { - return Err(Error::Other(format!( - "projection cache sequence gap for run {run_id}: last_seq={}, event_seq={}", - last_seq, event.seq - ))); - } - - let (previous_parent_id, parent_id, entry) = { - let entry = state - .entries - .get_mut(run_id) - .expect("entry was read from the same locked map"); - let previous_parent_id = entry.summary.parent_id; - Arc::make_mut(&mut entry.projection).apply_event(event)?; - entry.summary = build_summary(&entry.projection, run_id); - entry.last_seq = event.seq; - (previous_parent_id, entry.summary.parent_id, entry.clone()) - }; - state.update_parent_index(*run_id, previous_parent_id, parent_id); - Ok(entry) - } - pub(crate) async fn remove(&self, run_id: &RunId) { self.state.lock().await.remove(run_id); } diff --git a/lib/components/fabro-store/src/slate/run_store.rs b/lib/components/fabro-store/src/slate/run_store.rs index 9058627a6..48cd69249 100644 --- a/lib/components/fabro-store/src/slate/run_store.rs +++ b/lib/components/fabro-store/src/slate/run_store.rs @@ -9,7 +9,7 @@ use futures::Stream; use slatedb::{Db, DbIterator, DbRead}; use tokio::sync::{Mutex, broadcast, mpsc}; use tokio_stream::wrappers::UnboundedReceiverStream; -use tracing::{error, warn}; +use tracing::warn; use super::blob_store::BlobStore; use super::projection_cache::{CachedRunProjection, RunProjectionCache}; @@ -192,6 +192,15 @@ impl RunDatabase { } async fn projected_state_locked(&self) -> Result> { + self.projected_state_option_locked().await?.ok_or_else(|| { + Error::InvalidEvent(format!( + "run {} has no run.created event", + self.inner.run_id + )) + }) + } + + async fn projected_state_option_locked(&self) -> Result>> { let next_seq = { let cache = self.inner.projection_cache.lock().await; cache.last_seq.saturating_add(1) @@ -202,55 +211,42 @@ impl RunDatabase { apply_cached_projection_event(&mut cache.state, event)?; cache.last_seq = event.seq; } - cache.state.clone().ok_or_else(|| { - Error::InvalidEvent(format!( - "run {} has no run.created event", - self.inner.run_id - )) - }) + Ok(cache.state.clone()) } - async fn cache_event(&self, event: &EventEnvelope) -> Result<()> { + async fn install_derived_state_after_append( + &self, + event: &EventEnvelope, + cached: CachedRunProjection, + ) { { let mut projection_cache = self.inner.projection_cache.lock().await; - if projection_cache.state.is_none() && event.seq > 1 { - drop(projection_cache); - self.rebuild_local_projection_cache_through(event.seq) - .await?; - } else { - apply_cached_projection_event(&mut projection_cache.state, event)?; - projection_cache.last_seq = event.seq; - } + projection_cache.state = Some(Arc::clone(&cached.projection)); + projection_cache.last_seq = event.seq; } + self.inner + .shared_projection_cache + .replace(cached.clone()) + .await; + let mut recent_events = self.inner.recent_events.lock().await; recent_events.push_back(event.clone()); while recent_events.len() > self.inner.recent_event_limit { recent_events.pop_front(); } + drop(recent_events); let _ = self.inner.event_tx.send(event.clone()); - Ok(()) - } - async fn rebuild_local_projection_cache_through(&self, seq: u32) -> Result<()> { - let events = list_events_from(&self.inner.db, &self.inner.run_id, 1).await?; - let Some(last_seq) = events.last().map(|event| event.seq) else { - return Err(Error::InvalidEvent(format!( - "run {} has no events while rebuilding projection cache", - self.inner.run_id - ))); - }; - if last_seq < seq { - return Err(Error::InvalidEvent(format!( - "run {} projection cache rebuild stopped at seq {last_seq}, before appended seq {seq}", - self.inner.run_id - ))); + if let Some(store) = self.inner.run_summary_store.get() { + if let Err(err) = store.upsert_projection(&cached).await { + warn!( + run_id = %self.inner.run_id, + source_last_seq = event.seq, + error = ?err, + "failed to update SQLite run summary after committed append" + ); + } } - - let state = RunProjection::apply_events(&events)?; - let mut projection_cache = self.inner.projection_cache.lock().await; - projection_cache.state = Some(Arc::new(state)); - projection_cache.last_seq = last_seq; - Ok(()) } async fn cached_events_from(&self, start_seq: u32, limit: usize) -> Option> { @@ -270,12 +266,26 @@ impl RunDatabase { } impl RunDatabase { + /// Appends an event after validating it against the current run projection. + /// + /// A rejected event writes nothing. Every returned error means the event + /// was not committed and is safe to retry. Once the SlateDB write succeeds, + /// the append returns success even if a derived cache or SQLite summary + /// update fails; those failures are logged and repaired by later updates or + /// startup reconciliation. pub async fn append_event(&self, payload: &EventPayload) -> Result { Ok(self.append_event_envelope(payload).await?.seq) } /// Atomically appends `payload` when `predicate` matches the latest run /// projection. + /// + /// `Ok(None)` means the predicate rejected the append and nothing was + /// written. An invalid transition is also rejected before write, and every + /// returned error means the event was not committed and is safe to retry. + /// After the SlateDB write succeeds, derived cache and SQLite summary + /// updates are best-effort and cannot turn the committed append into an + /// error. pub async fn append_event_if( &self, payload: &EventPayload, @@ -293,6 +303,12 @@ impl RunDatabase { Ok(Some(self.append_event_envelope_locked(payload).await?.seq)) } + /// Appends and returns the stored event envelope after pre-write reduction. + /// + /// A rejected event writes nothing. Every returned error means the event + /// was not committed and is safe to retry. Once the SlateDB write succeeds, + /// derived cache and SQLite summary updates are best-effort: failures are + /// logged, and this method still returns the committed envelope. pub async fn append_event_envelope(&self, payload: &EventPayload) -> Result { if self.read_only { return Err(Error::ReadOnly); @@ -309,73 +325,32 @@ impl RunDatabase { seq, event: RunEvent::try_from(payload)?, }; + let current_projection = self.projected_state_option_locked().await?; + let next_projection = match current_projection { + Some(projection) => { + let mut projection = (*projection).clone(); + projection.apply_event(&event).map_err(event_rejected)?; + projection + } + None => { + RunProjection::apply_events(std::slice::from_ref(&event)).map_err(event_rejected)? + } + }; + let cached = CachedRunProjection::from_projection(self.inner.run_id, next_projection, seq); + let event_bytes = serde_json::to_vec(payload)?; self.inner .db .put( keys::run_event_key(&self.inner.run_id, seq, Utc::now().timestamp_millis()), - serde_json::to_vec(payload)?, + event_bytes, ) .await?; - self.cache_event(&event).await?; - // Box::pin keeps append_event_envelope's future small enough for the - // clippy::large_futures budget of its many callers. - Box::pin(self.update_summary_projection_after_append(&event)).await?; + // Box the derived-update future so this frequently awaited append API + // does not pass a large state machine into every caller. + Box::pin(self.install_derived_state_after_append(&event, cached)).await; Ok(event) } - async fn update_summary_projection_after_append(&self, event: &EventEnvelope) -> Result<()> { - let cached = match self - .inner - .shared_projection_cache - .apply_event(&self.inner.run_id, event) - .await - { - Ok(entry) => entry, - Err(err) => { - match Self::build_cached_projection(&self.inner.db, &self.inner.run_id).await { - Ok(Some(entry)) => { - self.inner - .shared_projection_cache - .replace(entry.clone()) - .await; - entry - } - rebuild => { - self.inner - .shared_projection_cache - .remove(&self.inner.run_id) - .await; - if let Err(rebuild_err) = rebuild { - warn!( - run_id = %self.inner.run_id, - error = %rebuild_err, - "Failed to rebuild run projection cache after append" - ); - } - warn!( - run_id = %self.inner.run_id, - error = %err, - "Failed to update run projection cache after append" - ); - return Err(err); - } - } - } - }; - if let Some(store) = self.inner.run_summary_store.get() { - if let Err(err) = store.upsert_projection(&cached).await { - error!( - run_id = %self.inner.run_id, - source_last_seq = cached.last_seq, - error = %err, - "Failed to update SQLite run summary after append" - ); - return Err(err); - } - } - Ok(()) - } - pub async fn list_events(&self) -> Result> { self.list_events_from_with_limit(1, usize::MAX).await } @@ -589,6 +564,14 @@ impl RunDatabase { } } +fn event_rejected(error: Error) -> Error { + let reason = match error { + Error::InvalidTransition(transition) => transition.to_string(), + error => error.to_string(), + }; + Error::EventRejected { reason } +} + fn allocate_event_seq(event_seq: &AtomicU32) -> Result { event_seq .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |seq| { @@ -1304,6 +1287,7 @@ mod tests { .unwrap(); assert_eq!(seq, keys::MAX_EVENT_SEQ); + let events_before_error = run.list_events().await.unwrap(); let err = run .append_event(&stage_prompt_payload(&run_id, 2, Some("beta"))) .await @@ -1313,6 +1297,7 @@ mod tests { Error::EventSequenceExhausted { max_seq } if max_seq == keys::MAX_EVENT_SEQ )); + assert_eq!(run.list_events().await.unwrap(), events_before_error); assert!( run.get_event(keys::MAX_EVENT_SEQ + 1) .await diff --git a/lib/components/fabro-store/src/types.rs b/lib/components/fabro-store/src/types.rs index 65235a55b..c91bbde56 100644 --- a/lib/components/fabro-store/src/types.rs +++ b/lib/components/fabro-store/src/types.rs @@ -57,7 +57,7 @@ impl TryFrom<&EventPayload> for RunEvent { type Error = Error; fn try_from(value: &EventPayload) -> Result { - Self::from_ref(value.as_value()) + Self::from_value(value.as_value().clone()) .map_err(|err| Error::InvalidEvent(format!("invalid stored event: {err}"))) } } From 6ce5ea76a25327679600670dd5dfc30c8878d865 Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 22:31:26 +0000 Subject: [PATCH 05/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): simplify_fable (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 6 Fabro-Checkpoint: b9c14c247e58b34aeca871a05e151796b52d3e88 ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/apps/fabro-server/Cargo.toml | 1 + lib/apps/fabro-server/src/server/tests.rs | 53 +++++---- lib/components/fabro-store/Cargo.toml | 3 + lib/components/fabro-store/src/error.rs | 2 +- lib/components/fabro-store/src/slate/mod.rs | 103 +++++++++--------- .../fabro-store/src/slate/run_store.rs | 61 +++++++---- lib/components/fabro-store/src/test_util.rs | 11 +- .../fabro-types/src/run_event/mod.rs | 53 +-------- 8 files changed, 136 insertions(+), 151 deletions(-) diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index e24dd114b..f0acde539 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -121,5 +121,6 @@ tokio-util.workspace = true tokio-tungstenite.workspace = true fabro-macros = { path = "../../foundation/fabro-macros" } fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] } +fabro-store = { path = "../../components/fabro-store", features = ["test-support"] } fabro-test = { workspace = true } fabro-types = { path = "../../foundation/fabro-types", features = ["test-support"] } diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index b3da5be63..7735c5e99 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -6366,31 +6366,38 @@ async fn create_unreadable_durable_run(state: &Arc, run_id: RunId) { workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunRunning) .await .unwrap(); - let payload = fabro_store::EventPayload::new( - json!({ - "id": "evt-unreadable-run-completed", - "ts": "2026-05-05T20:46:33Z", - "run_id": run_id, - "event": "run.completed", - "properties": { - "timing": { - "wall_time_ms": 1, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 + // Appends now validate before write, so a corrupted log can only exist if + // it predates that check. Write the poison event directly to simulate one. + state + .stores + .runs + .test_put_unvalidated_run_event( + &run_id, + 5, + &json!({ + "id": "evt-unreadable-run-completed", + "ts": "2026-05-05T20:46:33Z", + "run_id": run_id, + "event": "run.completed", + "properties": { + "timing": { + "wall_time_ms": 1, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "artifact_count": 0, + "status": "legacy-status", + "reason": "completed", }, - "artifact_count": 0, - "status": "legacy-status", - "reason": "completed", - }, - }), - &run_id, - ) - .unwrap(); - let err = run_store - .append_event(&payload) + }), + ) .await - .expect_err("invalid projection event should be persisted but rejected by projection"); + .unwrap(); + let err = run_store + .state() + .await + .expect_err("poison event should make the run projection unreadable"); assert!( err.to_string().contains("invalid completed stage status"), "unexpected projection error: {err}" diff --git a/lib/components/fabro-store/Cargo.toml b/lib/components/fabro-store/Cargo.toml index 796119e18..588e285a0 100644 --- a/lib/components/fabro-store/Cargo.toml +++ b/lib/components/fabro-store/Cargo.toml @@ -11,6 +11,9 @@ doctest = false [lints] workspace = true +[features] +test-support = [] + [dependencies] fabro-types = { path = "../../foundation/fabro-types" } fabro-util = { path = "../../foundation/fabro-util" } diff --git a/lib/components/fabro-store/src/error.rs b/lib/components/fabro-store/src/error.rs index 5f2fdee80..ff78213d0 100644 --- a/lib/components/fabro-store/src/error.rs +++ b/lib/components/fabro-store/src/error.rs @@ -37,7 +37,7 @@ pub enum Error { run_id: String, field: &'static str, }, - #[error("invalid status transition: {0}")] + #[error(transparent)] InvalidTransition(#[from] fabro_types::InvalidTransition), #[error("{0}")] Other(String), diff --git a/lib/components/fabro-store/src/slate/mod.rs b/lib/components/fabro-store/src/slate/mod.rs index c9750c35a..d9f542498 100644 --- a/lib/components/fabro-store/src/slate/mod.rs +++ b/lib/components/fabro-store/src/slate/mod.rs @@ -322,6 +322,24 @@ impl Database { Ok(unreadable) } + /// Writes a raw event record without append validation, simulating a + /// pre-existing poison event in the log for unreadable-run tests. + #[cfg(any(test, feature = "test-support"))] + pub async fn test_put_unvalidated_run_event( + &self, + run_id: &RunId, + seq: u32, + payload: &serde_json::Value, + ) -> Result<()> { + let db = self.open_db().await?; + db.put( + keys::run_event_key(run_id, seq, 0), + serde_json::to_vec(payload)?, + ) + .await?; + Ok(()) + } + pub async fn get_cached_run(&self, run_id: &RunId) -> Result> { self.warm_projection_cache().await?; Ok(self.projection_cache.get(run_id).await) @@ -978,11 +996,7 @@ mod tests { let stored = run.get_event(2).await.unwrap().unwrap(); assert_eq!(stored.event, result.unwrap().event); - let repaired_database = fabro_db::Database::connect(directory.path().join("fabro.sqlite3")) - .await - .unwrap(); - repaired_database.migrate().await.unwrap(); - let repaired_summaries = RunSummaryStore::new(repaired_database.clone_pool()); + let repaired_summaries = test_util::sqlite_summary_store_at(directory.path()).await; let stale = repaired_summaries .get(&run_id, Utc::now()) .await @@ -1526,13 +1540,14 @@ mod tests { .add(&bad_run_id) .await .unwrap(); - let db = store.open_db().await.unwrap(); - db.put( - keys::run_event_key(&bad_run_id, 1, 0), - br#"{"not":"a valid run event"}"#, - ) - .await - .unwrap(); + store + .test_put_unvalidated_run_event( + &bad_run_id, + 1, + &serde_json::json!({ "not": "a valid run event" }), + ) + .await + .unwrap(); let reopened = Database::new(object_store, "runs", Duration::from_millis(1), None); reopened.warm_projection_cache().await.unwrap(); @@ -1574,28 +1589,28 @@ mod tests { .and_then(serde_json::Value::as_object_mut) .unwrap(); run_settings.remove("integrations"); - let db = store.open_db().await.unwrap(); - db.put( - keys::run_event_key(&bad_run_id, 1, 0), - serde_json::to_vec(&serde_json::json!({ - "id": "evt-run-2-run.created", - "ts": "2026-03-27T12:00:10Z", - "run_id": bad_run_id, - "event": "run.created", - "properties": { - "settings": run_spec["settings"], - "graph": run_spec["graph"], - "workflow_slug": run_spec["workflow_slug"], - "source_directory": run_spec["source_directory"], - "run_dir": "/tmp/run-2", - "git": run_spec["git"], - "labels": run_spec["labels"], - }, - })) - .unwrap(), - ) - .await - .unwrap(); + store + .test_put_unvalidated_run_event( + &bad_run_id, + 1, + &serde_json::json!({ + "id": "evt-run-2-run.created", + "ts": "2026-03-27T12:00:10Z", + "run_id": bad_run_id, + "event": "run.created", + "properties": { + "settings": run_spec["settings"], + "graph": run_spec["graph"], + "workflow_slug": run_spec["workflow_slug"], + "source_directory": run_spec["source_directory"], + "run_dir": "/tmp/run-2", + "git": run_spec["git"], + "labels": run_spec["labels"], + }, + }), + ) + .await + .unwrap(); let reopened = Database::new(object_store, "runs", Duration::from_millis(1), None); let unreadable = reopened.list_unreadable_runs().await.unwrap(); @@ -1880,23 +1895,9 @@ mod tests { )) .await .unwrap(); - run.append_event(&event_payload( - "run-1", - "2026-03-27T12:00:04Z", - "run.failed", - &serde_json::json!({ - "failure": { - "reason": "workflow_error", - "detail": { - "message": "workflow failed", - "category": "deterministic" - } - }, - "timing": {"wall_time_ms": 1, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}, - }), - )) - .await - .unwrap(); + run.append_event(&workflow_failure_payload("run-1")) + .await + .unwrap(); let reopened = Database::new( Arc::clone(&object_store), diff --git a/lib/components/fabro-store/src/slate/run_store.rs b/lib/components/fabro-store/src/slate/run_store.rs index 48cd69249..57f54a311 100644 --- a/lib/components/fabro-store/src/slate/run_store.rs +++ b/lib/components/fabro-store/src/slate/run_store.rs @@ -214,6 +214,23 @@ impl RunDatabase { Ok(cache.state.clone()) } + /// Current projection for validating an append allocated at `seq`. In the + /// steady state the local cache already sits at `seq - 1` because + /// `state_lock` serializes appends, so this skips the storage scan that + /// `projected_state_option_locked` issues. + async fn projected_state_for_append_locked( + &self, + seq: u32, + ) -> Result>> { + { + let cache = self.inner.projection_cache.lock().await; + if cache.last_seq.saturating_add(1) == seq { + return Ok(cache.state.clone()); + } + } + self.projected_state_option_locked().await + } + async fn install_derived_state_after_append( &self, event: &EventEnvelope, @@ -242,8 +259,8 @@ impl RunDatabase { warn!( run_id = %self.inner.run_id, source_last_seq = event.seq, - error = ?err, - "failed to update SQLite run summary after committed append" + error = %err, + "Failed to update SQLite run summary after committed append" ); } } @@ -325,18 +342,19 @@ impl RunDatabase { seq, event: RunEvent::try_from(payload)?, }; - let current_projection = self.projected_state_option_locked().await?; - let next_projection = match current_projection { - Some(projection) => { - let mut projection = (*projection).clone(); - projection.apply_event(&event).map_err(event_rejected)?; - projection - } - None => { - RunProjection::apply_events(std::slice::from_ref(&event)).map_err(event_rejected)? - } - }; - let cached = CachedRunProjection::from_projection(self.inner.run_id, next_projection, seq); + // Validation reduces through the exact code replay uses, so an event + // is written iff replay can reduce it. `Arc::make_mut` copy-on-writes, + // leaving the local projection cache untouched on rejection. + let mut next_state = self.projected_state_for_append_locked(seq).await?; + apply_cached_projection_event(&mut next_state, &event) + .map_err(|err| event_rejected(&err))?; + let next_projection = + next_state.expect("apply_cached_projection_event sets the state on success"); + let cached = CachedRunProjection::from_projection( + self.inner.run_id, + Arc::unwrap_or_clone(next_projection), + seq, + ); let event_bytes = serde_json::to_vec(payload)?; self.inner .db @@ -345,8 +363,9 @@ impl RunDatabase { event_bytes, ) .await?; - // Box the derived-update future so this frequently awaited append API - // does not pass a large state machine into every caller. + // Box::pin keeps this future small enough for the + // clippy::large_futures budget of append_event_envelope's many + // callers. Box::pin(self.install_derived_state_after_append(&event, cached)).await; Ok(event) } @@ -564,12 +583,10 @@ impl RunDatabase { } } -fn event_rejected(error: Error) -> Error { - let reason = match error { - Error::InvalidTransition(transition) => transition.to_string(), - error => error.to_string(), - }; - Error::EventRejected { reason } +fn event_rejected(error: &Error) -> Error { + Error::EventRejected { + reason: error.to_string(), + } } fn allocate_event_seq(event_seq: &AtomicU32) -> Result { diff --git a/lib/components/fabro-store/src/test_util.rs b/lib/components/fabro-store/src/test_util.rs index bbc0b8717..a5b7e17f9 100644 --- a/lib/components/fabro-store/src/test_util.rs +++ b/lib/components/fabro-store/src/test_util.rs @@ -1,10 +1,17 @@ +use std::path::Path; + use crate::RunSummaryStore; pub(crate) async fn sqlite_summary_store() -> (tempfile::TempDir, RunSummaryStore) { let directory = tempfile::tempdir().unwrap(); - let database = fabro_db::Database::connect(directory.path().join("fabro.sqlite3")) + let store = sqlite_summary_store_at(directory.path()).await; + (directory, store) +} + +pub(crate) async fn sqlite_summary_store_at(directory: &Path) -> RunSummaryStore { + let database = fabro_db::Database::connect(directory.join("fabro.sqlite3")) .await .unwrap(); database.migrate().await.unwrap(); - (directory, RunSummaryStore::new(database.clone_pool())) + RunSummaryStore::new(database.clone_pool()) } diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index 6680fff2e..34cc88ed8 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -12,7 +12,7 @@ pub use fabro_model::BilledTokenCounts; pub use infra::*; pub use misc::*; pub use run::*; -use serde::de::Error as DeError; +use serde::de::Error as _; use serde::ser::Error as SerError; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Map, Value, json}; @@ -764,57 +764,6 @@ impl RunEvent { }) } - pub fn from_ref(value: &Value) -> serde_json::Result { - fn opt_field Deserialize<'a>>( - obj: &Map, - key: &str, - ) -> serde_json::Result> { - match obj.get(key) { - Some(value) if !value.is_null() => Ok(Some(T::deserialize(value)?)), - _ => Ok(None), - } - } - - let obj = value.as_object().ok_or_else(|| { - ::custom("run event must be a JSON object") - })?; - let opt_str = |key: &str| obj.get(key).and_then(Value::as_str).map(str::to_string); - let id = obj.get("id").and_then(Value::as_str).ok_or_else(|| { - ::custom("missing or non-string field: id") - })?; - let ts = obj - .get("ts") - .ok_or_else(|| ::custom("missing field: ts")) - .and_then(DateTime::::deserialize)?; - let run_id = obj - .get("run_id") - .ok_or_else(|| ::custom("missing field: run_id")) - .and_then(RunId::deserialize)?; - let event = obj.get("event").and_then(Value::as_str).ok_or_else(|| { - ::custom("missing or non-string field: event") - })?; - let properties = obj - .get("properties") - .cloned() - .unwrap_or_else(default_properties); - Self::from_parts(RunEventParts { - id: id.to_string(), - ts, - run_id, - node_id: opt_str("node_id"), - node_label: opt_str("node_label"), - stage_id: opt_field(obj, "stage_id")?, - parallel_group_id: opt_field(obj, "parallel_group_id")?, - parallel_branch_id: opt_field(obj, "parallel_branch_id")?, - session_id: opt_str("session_id"), - parent_session_id: opt_str("parent_session_id"), - tool_call_id: opt_str("tool_call_id"), - actor: opt_field(obj, "actor")?, - event, - properties: &properties, - }) - } - fn from_parts(parts: RunEventParts<'_>) -> serde_json::Result { let body_payload = json!({ "event": parts.event, From a94233407b4a2705ab776a7c150f35cfb0fa0c23 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 00:22:06 +0000 Subject: [PATCH 06/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): simplify_sol (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 7 Fabro-Checkpoint: edcf7467e90f0195f72367b5c69a1b257bc72d1d ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/apps/fabro-server/src/server/tests.rs | 54 ++++----- lib/components/fabro-store/src/error.rs | 7 +- lib/components/fabro-store/src/lib.rs | 4 +- .../fabro-store/src/run_summary_store.rs | 4 +- lib/components/fabro-store/src/slate/mod.rs | 40 +++--- .../fabro-store/src/slate/run_store.rs | 114 ++++++++++++------ .../src/{test_util.rs => test_support/mod.rs} | 22 +++- .../fabro-types/src/run_event/mod.rs | 53 +++++++- 8 files changed, 209 insertions(+), 89 deletions(-) rename lib/components/fabro-store/src/{test_util.rs => test_support/mod.rs} (55%) diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 7735c5e99..063b48f36 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -6366,34 +6366,32 @@ async fn create_unreadable_durable_run(state: &Arc, run_id: RunId) { workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunRunning) .await .unwrap(); - // Appends now validate before write, so a corrupted log can only exist if - // it predates that check. Write the poison event directly to simulate one. - state - .stores - .runs - .test_put_unvalidated_run_event( - &run_id, - 5, - &json!({ - "id": "evt-unreadable-run-completed", - "ts": "2026-05-05T20:46:33Z", - "run_id": run_id, - "event": "run.completed", - "properties": { - "timing": { - "wall_time_ms": 1, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, - "artifact_count": 0, - "status": "legacy-status", - "reason": "completed", - }, - }), - ) - .await - .unwrap(); + let seq = run_store.last_event_seq().await.unwrap().unwrap() + 1; + let completed = workflow_event::to_run_event_at( + &run_id, + &workflow_event::Event::WorkflowRunCompleted { + timing: fabro_types::RunTiming::wall_only(1), + artifact_count: 0, + status: "legacy-status".to_string(), + reason: SuccessReason::Completed, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + diff_summary: None, + billing: None, + }, + "2026-05-05T20:46:33Z".parse().unwrap(), + None, + ); + let payload = workflow_event::build_redacted_event_payload(&completed, &run_id).unwrap(); + fabro_store::test_support::put_unvalidated_run_event( + &state.stores.runs, + &run_id, + seq, + payload.as_value(), + ) + .await + .unwrap(); let err = run_store .state() .await diff --git a/lib/components/fabro-store/src/error.rs b/lib/components/fabro-store/src/error.rs index ff78213d0..df12e5994 100644 --- a/lib/components/fabro-store/src/error.rs +++ b/lib/components/fabro-store/src/error.rs @@ -14,8 +14,11 @@ pub enum Error { Io(#[from] std::io::Error), #[error("Invalid event payload: {0}")] InvalidEvent(String), - #[error("event rejected by run projection: {reason}")] - EventRejected { reason: String }, + #[error("event rejected by run projection: {source}")] + EventRejected { + #[source] + source: Box, + }, #[error("Run not found: {0}")] RunNotFound(String), #[error("Run already exists: {0}")] diff --git a/lib/components/fabro-store/src/lib.rs b/lib/components/fabro-store/src/lib.rs index 0eb246d04..e99eadd0f 100644 --- a/lib/components/fabro-store/src/lib.rs +++ b/lib/components/fabro-store/src/lib.rs @@ -10,8 +10,8 @@ mod run_state; mod run_summary_store; mod serializable_projection; mod slate; -#[cfg(test)] -mod test_util; +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; mod types; pub use artifact_store::{ diff --git a/lib/components/fabro-store/src/run_summary_store.rs b/lib/components/fabro-store/src/run_summary_store.rs index c1a339b99..5db1a49b1 100644 --- a/lib/components/fabro-store/src/run_summary_store.rs +++ b/lib/components/fabro-store/src/run_summary_store.rs @@ -576,7 +576,7 @@ mod tests { RunSummaryVisibility, }; use crate::slate::CachedRunProjection; - use crate::test_util; + use crate::test_support as store_test_support; fn dt(value: &str) -> DateTime { value.parse().unwrap() @@ -613,7 +613,7 @@ mod tests { } async fn store() -> (tempfile::TempDir, RunSummaryStore) { - test_util::sqlite_summary_store().await + store_test_support::sqlite_summary_store().await } fn sample_status(kind: RunStatusKind) -> RunStatus { diff --git a/lib/components/fabro-store/src/slate/mod.rs b/lib/components/fabro-store/src/slate/mod.rs index d9f542498..893b0d8f0 100644 --- a/lib/components/fabro-store/src/slate/mod.rs +++ b/lib/components/fabro-store/src/slate/mod.rs @@ -322,10 +322,8 @@ impl Database { Ok(unreadable) } - /// Writes a raw event record without append validation, simulating a - /// pre-existing poison event in the log for unreadable-run tests. #[cfg(any(test, feature = "test-support"))] - pub async fn test_put_unvalidated_run_event( + pub(crate) async fn put_unvalidated_run_event( &self, run_id: &RunId, seq: u32, @@ -541,7 +539,7 @@ mod tests { use object_store::path::Path; use super::*; - use crate::{EventPayload, keys, test_util}; + use crate::{EventPayload, keys, test_support as store_test_support}; fn dt(value: &str) -> DateTime { value.parse().unwrap() @@ -590,7 +588,7 @@ mod tests { } async fn make_summary_store() -> (tempfile::TempDir, Arc) { - let (directory, store) = test_util::sqlite_summary_store().await; + let (directory, store) = store_test_support::sqlite_summary_store().await; (directory, Arc::new(store)) } @@ -931,13 +929,18 @@ mod tests { .await .unwrap_err(); - let Error::EventRejected { reason } = err else { + let Error::EventRejected { source } = err else { panic!("expected event rejection"); }; - assert_eq!( - reason, - "invalid status transition: runnable -> failed(workflow_error)" - ); + assert!(matches!( + *source, + Error::InvalidTransition(fabro_types::InvalidTransition { + from: RunStatus::Runnable, + to: RunStatus::Failed { + reason: FailureReason::WorkflowError, + }, + }) + )); assert_eq!(run.list_events().await.unwrap(), events_before); assert_eq!(run.state().await.unwrap().status, RunStatus::Runnable); let cached = store.get_cached_run(&run_id).await.unwrap().unwrap(); @@ -971,7 +974,7 @@ mod tests { #[tokio::test] async fn committed_append_succeeds_when_summary_update_fails_and_is_repairable() { - let (_object_store, store) = make_store(); + let (object_store, store) = make_store(); let (directory, summaries) = make_summary_store().await; store.attach_run_summary_store(Arc::clone(&summaries)); let run_id = test_run_id("run-1"); @@ -996,7 +999,8 @@ mod tests { let stored = run.get_event(2).await.unwrap().unwrap(); assert_eq!(stored.event, result.unwrap().event); - let repaired_summaries = test_util::sqlite_summary_store_at(directory.path()).await; + let repaired_summaries = + Arc::new(store_test_support::sqlite_summary_store_at(directory.path()).await); let stale = repaired_summaries .get(&run_id, Utc::now()) .await @@ -1004,11 +1008,9 @@ mod tests { .unwrap(); assert_ne!(stale.title, "Committed title"); - let entries = store - .list_cached_runs(&ListRunsQuery::default(), Utc::now()) - .await - .unwrap(); - repaired_summaries.reconcile(&entries).await.unwrap(); + let reopened = Database::new(object_store, "runs/", Duration::from_millis(1), None); + reopened.attach_run_summary_store(Arc::clone(&repaired_summaries)); + reopened.warm_projection_cache().await.unwrap(); let repaired = repaired_summaries .get(&run_id, Utc::now()) .await @@ -1541,7 +1543,7 @@ mod tests { .await .unwrap(); store - .test_put_unvalidated_run_event( + .put_unvalidated_run_event( &bad_run_id, 1, &serde_json::json!({ "not": "a valid run event" }), @@ -1590,7 +1592,7 @@ mod tests { .unwrap(); run_settings.remove("integrations"); store - .test_put_unvalidated_run_event( + .put_unvalidated_run_event( &bad_run_id, 1, &serde_json::json!({ diff --git a/lib/components/fabro-store/src/slate/run_store.rs b/lib/components/fabro-store/src/slate/run_store.rs index 57f54a311..be9ca78bf 100644 --- a/lib/components/fabro-store/src/slate/run_store.rs +++ b/lib/components/fabro-store/src/slate/run_store.rs @@ -231,10 +231,10 @@ impl RunDatabase { self.projected_state_option_locked().await } - async fn install_derived_state_after_append( + async fn install_in_memory_state_after_append( &self, event: &EventEnvelope, - cached: CachedRunProjection, + cached: &CachedRunProjection, ) { { let mut projection_cache = self.inner.projection_cache.lock().await; @@ -253,14 +253,16 @@ impl RunDatabase { } drop(recent_events); let _ = self.inner.event_tx.send(event.clone()); + } + async fn update_summary_after_committed_append(&self, cached: &CachedRunProjection) { if let Some(store) = self.inner.run_summary_store.get() { - if let Err(err) = store.upsert_projection(&cached).await { + if let Err(err) = store.upsert_projection(cached).await { warn!( run_id = %self.inner.run_id, - source_last_seq = event.seq, - error = %err, - "Failed to update SQLite run summary after committed append" + source_last_seq = cached.last_seq, + error = ?err, + "failed to update SQLite run summary after committed append" ); } } @@ -312,12 +314,19 @@ impl RunDatabase { return Err(Error::ReadOnly); } payload.validate(&self.inner.run_id)?; - let _state_guard = self.inner.state_lock.lock().await; - let projection = self.projected_state_locked().await?; - if !predicate(&projection) { - return Ok(None); - } - Ok(Some(self.append_event_envelope_locked(payload).await?.seq)) + let (envelope, cached) = { + let _state_guard = self.inner.state_lock.lock().await; + let projection = self.projected_state_locked().await?; + if !predicate(&projection) { + return Ok(None); + } + let event = RunEvent::try_from(payload)?; + let event_bytes = serde_json::to_vec(payload)?; + self.append_event_envelope_locked(event, event_bytes) + .await? + }; + self.update_summary_after_committed_append(&cached).await; + Ok(Some(envelope.seq)) } /// Appends and returns the stored event envelope after pre-write reduction. @@ -331,23 +340,30 @@ impl RunDatabase { return Err(Error::ReadOnly); } payload.validate(&self.inner.run_id)?; - let _state_guard = self.inner.state_lock.lock().await; - self.append_event_envelope_locked(payload).await + let event = RunEvent::try_from(payload)?; + let event_bytes = serde_json::to_vec(payload)?; + let (envelope, cached) = { + let _state_guard = self.inner.state_lock.lock().await; + self.append_event_envelope_locked(event, event_bytes) + .await? + }; + self.update_summary_after_committed_append(&cached).await; + Ok(envelope) } - async fn append_event_envelope_locked(&self, payload: &EventPayload) -> Result { + async fn append_event_envelope_locked( + &self, + event: RunEvent, + event_bytes: Vec, + ) -> Result<(EventEnvelope, CachedRunProjection)> { let event_seq = self.inner.event_seq.as_ref().ok_or(Error::ReadOnly)?; - let seq = allocate_event_seq(event_seq)?; - let event = EventEnvelope { - seq, - event: RunEvent::try_from(payload)?, - }; + let seq = next_event_seq(event_seq)?; + let envelope = EventEnvelope { seq, event }; // Validation reduces through the exact code replay uses, so an event // is written iff replay can reduce it. `Arc::make_mut` copy-on-writes, // leaving the local projection cache untouched on rejection. let mut next_state = self.projected_state_for_append_locked(seq).await?; - apply_cached_projection_event(&mut next_state, &event) - .map_err(|err| event_rejected(&err))?; + apply_cached_projection_event(&mut next_state, &envelope).map_err(event_rejected)?; let next_projection = next_state.expect("apply_cached_projection_event sets the state on success"); let cached = CachedRunProjection::from_projection( @@ -355,7 +371,7 @@ impl RunDatabase { Arc::unwrap_or_clone(next_projection), seq, ); - let event_bytes = serde_json::to_vec(payload)?; + reserve_event_seq(event_seq, seq)?; self.inner .db .put( @@ -366,8 +382,8 @@ impl RunDatabase { // Box::pin keeps this future small enough for the // clippy::large_futures budget of append_event_envelope's many // callers. - Box::pin(self.install_derived_state_after_append(&event, cached)).await; - Ok(event) + Box::pin(self.install_in_memory_state_after_append(&envelope, &cached)).await; + Ok((envelope, cached)) } pub async fn list_events(&self) -> Result> { @@ -583,20 +599,27 @@ impl RunDatabase { } } -fn event_rejected(error: &Error) -> Error { +fn event_rejected(error: Error) -> Error { Error::EventRejected { - reason: error.to_string(), + source: Box::new(error), } } -fn allocate_event_seq(event_seq: &AtomicU32) -> Result { - event_seq - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |seq| { - (seq <= keys::MAX_EVENT_SEQ).then_some(seq + 1) - }) - .map_err(|_| Error::EventSequenceExhausted { +fn next_event_seq(event_seq: &AtomicU32) -> Result { + let seq = event_seq.load(Ordering::SeqCst); + if seq > keys::MAX_EVENT_SEQ { + return Err(Error::EventSequenceExhausted { max_seq: keys::MAX_EVENT_SEQ, - }) + }); + } + Ok(seq) +} + +fn reserve_event_seq(event_seq: &AtomicU32, seq: u32) -> Result<()> { + event_seq + .compare_exchange(seq, seq + 1, Ordering::SeqCst, Ordering::SeqCst) + .map(|_| ()) + .map_err(|_| Error::Other("event sequence changed while append lock was held".to_string())) } fn apply_cached_projection_event( @@ -1288,6 +1311,29 @@ mod tests { assert_eq!(seqs, vec![5, 4, 3]); } + #[tokio::test] + async fn rejected_event_does_not_consume_last_available_sequence() { + let run = fresh_run().await; + let run_id = run.run_id(); + run.inner + .event_seq + .as_ref() + .unwrap() + .store(keys::MAX_EVENT_SEQ, Ordering::SeqCst); + + let err = run + .append_event(&run_created_payload(&run_id)) + .await + .unwrap_err(); + assert!(matches!(err, Error::EventRejected { .. })); + + let seq = run + .append_event(&stage_prompt_payload(&run_id, 1, Some("alpha"))) + .await + .unwrap(); + assert_eq!(seq, keys::MAX_EVENT_SEQ); + } + #[tokio::test] async fn append_event_rejects_sequences_beyond_key_order_limit() { let run = fresh_run().await; diff --git a/lib/components/fabro-store/src/test_util.rs b/lib/components/fabro-store/src/test_support/mod.rs similarity index 55% rename from lib/components/fabro-store/src/test_util.rs rename to lib/components/fabro-store/src/test_support/mod.rs index a5b7e17f9..7ae613652 100644 --- a/lib/components/fabro-store/src/test_util.rs +++ b/lib/components/fabro-store/src/test_support/mod.rs @@ -1,13 +1,33 @@ +#[cfg(test)] use std::path::Path; -use crate::RunSummaryStore; +use fabro_types::RunId; +#[cfg(test)] +use crate::RunSummaryStore; +use crate::{Database, Result}; + +/// Writes an event without append validation to model a log corrupted by an +/// older Fabro version. +pub async fn put_unvalidated_run_event( + database: &Database, + run_id: &RunId, + seq: u32, + payload: &serde_json::Value, +) -> Result<()> { + database + .put_unvalidated_run_event(run_id, seq, payload) + .await +} + +#[cfg(test)] pub(crate) async fn sqlite_summary_store() -> (tempfile::TempDir, RunSummaryStore) { let directory = tempfile::tempdir().unwrap(); let store = sqlite_summary_store_at(directory.path()).await; (directory, store) } +#[cfg(test)] pub(crate) async fn sqlite_summary_store_at(directory: &Path) -> RunSummaryStore { let database = fabro_db::Database::connect(directory.join("fabro.sqlite3")) .await diff --git a/lib/foundation/fabro-types/src/run_event/mod.rs b/lib/foundation/fabro-types/src/run_event/mod.rs index 34cc88ed8..6680fff2e 100644 --- a/lib/foundation/fabro-types/src/run_event/mod.rs +++ b/lib/foundation/fabro-types/src/run_event/mod.rs @@ -12,7 +12,7 @@ pub use fabro_model::BilledTokenCounts; pub use infra::*; pub use misc::*; pub use run::*; -use serde::de::Error as _; +use serde::de::Error as DeError; use serde::ser::Error as SerError; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Map, Value, json}; @@ -764,6 +764,57 @@ impl RunEvent { }) } + pub fn from_ref(value: &Value) -> serde_json::Result { + fn opt_field Deserialize<'a>>( + obj: &Map, + key: &str, + ) -> serde_json::Result> { + match obj.get(key) { + Some(value) if !value.is_null() => Ok(Some(T::deserialize(value)?)), + _ => Ok(None), + } + } + + let obj = value.as_object().ok_or_else(|| { + ::custom("run event must be a JSON object") + })?; + let opt_str = |key: &str| obj.get(key).and_then(Value::as_str).map(str::to_string); + let id = obj.get("id").and_then(Value::as_str).ok_or_else(|| { + ::custom("missing or non-string field: id") + })?; + let ts = obj + .get("ts") + .ok_or_else(|| ::custom("missing field: ts")) + .and_then(DateTime::::deserialize)?; + let run_id = obj + .get("run_id") + .ok_or_else(|| ::custom("missing field: run_id")) + .and_then(RunId::deserialize)?; + let event = obj.get("event").and_then(Value::as_str).ok_or_else(|| { + ::custom("missing or non-string field: event") + })?; + let properties = obj + .get("properties") + .cloned() + .unwrap_or_else(default_properties); + Self::from_parts(RunEventParts { + id: id.to_string(), + ts, + run_id, + node_id: opt_str("node_id"), + node_label: opt_str("node_label"), + stage_id: opt_field(obj, "stage_id")?, + parallel_group_id: opt_field(obj, "parallel_group_id")?, + parallel_branch_id: opt_field(obj, "parallel_branch_id")?, + session_id: opt_str("session_id"), + parent_session_id: opt_str("parent_session_id"), + tool_call_id: opt_str("tool_call_id"), + actor: opt_field(obj, "actor")?, + event, + properties: &properties, + }) + } + fn from_parts(parts: RunEventParts<'_>) -> serde_json::Result { let body_payload = json!({ "event": parts.event, From d4815423a15067c408132e5c7be2965d2c04784d Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 00:25:35 +0000 Subject: [PATCH 07/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): verify (failed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 8 Fabro-Checkpoint: 7425261f116bc71cb8904e201ffb40cd420d2aab ⚒️ Generated with [Fabro](https://fabro.sh) From 84d3ad5be44427feb04915ec7d077db18bbf08e3 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 00:46:06 +0000 Subject: [PATCH 08/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): fixup (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 9 Fabro-Checkpoint: d05213a2b30a6a907fdd1883a230eb83231e9710 ⚒️ Generated with [Fabro](https://fabro.sh) From 22e869c0294d0bb1a00fa82c564522fa56c2c18c Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 00:51:46 +0000 Subject: [PATCH 09/42] fabro(01KYQMV1VW6139EGNHEM1RGF2G): verify (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQMV1VW6139EGNHEM1RGF2G Fabro-Completed: 10 Fabro-Checkpoint: e01dab2d26d66727cd74ca9d96a429864c4475ae ⚒️ Generated with [Fabro](https://fabro.sh) From 900396e7fca13e4b2888f304606027d7b6cd52fe Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 12:22:08 -0400 Subject: [PATCH 10/42] fix(daytona): accept newer permission scopes --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- lib/components/fabro-sandbox/src/daytona/mod.rs | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a485c5cc4..37159d3f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1854,7 +1854,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -1868,7 +1868,7 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8" dependencies = [ "daytona-api-client", "daytona-toolbox-client", @@ -1888,7 +1888,7 @@ dependencies = [ [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", diff --git a/Cargo.toml b/Cargo.toml index 78ab08a99..48bf8b914 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,8 +96,8 @@ twin-openai = { path = "test/twin/openai" } twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fc58e22f7f25183df6264276ee186bbc32635738", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fc58e22f7f25183df6264276ee186bbc32635738", package = "daytona-api-client" } +daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-sdk" } +daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-api-client" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/lib/components/fabro-sandbox/src/daytona/mod.rs b/lib/components/fabro-sandbox/src/daytona/mod.rs index c157e7d52..6371f23d2 100644 --- a/lib/components/fabro-sandbox/src/daytona/mod.rs +++ b/lib/components/fabro-sandbox/src/daytona/mod.rs @@ -3305,7 +3305,7 @@ mod tests { } #[tokio::test] - async fn check_daytona_api_key_with_accepts_full_scopes() { + async fn check_daytona_api_key_with_accepts_full_scopes_and_new_scopes() { let server = MockServer::start_async().await; let auth = mock_auth_probe(&server, 200).await; let current_key = mock_current_key(&server, vec![ @@ -3313,6 +3313,9 @@ mod tests { "delete:snapshots", "write:sandboxes", "delete:sandboxes", + "manage:secrets", + "read:limits", + "manage:sso", ]) .await; From e9694644bce534992f370993b9285e279f44f203 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 12:35:36 -0400 Subject: [PATCH 11/42] Fix canceled parallel stage duration --- .../stage-renderers/parallel-children.test.tsx | 14 ++++++++++++++ .../stage-renderers/parallel-children.tsx | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx index 0800fddb0..4354d7fb6 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx @@ -144,6 +144,20 @@ describe("ParallelChildren", () => { expect(statValue(renderer, "Failed")).toBe("0"); }); + test("uses the stage duration when cancellation interrupts the parallel summary", () => { + const renderer = renderParallel( + [startedEvent(2)], + [], + { + ...parallelStage, + status: StageState.CANCELLED, + duration: "53m 29s", + }, + ); + + expect(statValue(renderer, "Duration")).toBe("53m 29s"); + }); + test("keeps looped fork links scoped to the selected fork visit", () => { const renderer = renderParallel( [startedEvent(1)], diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx index 21c7c9271..02f939d30 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx @@ -5,7 +5,12 @@ import { StageState } from "@qltysh/fabro-api-client"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import type { Stage } from "../stage-sidebar"; -import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar"; +import { + ACTIVE_STAGE_STATES, + formatStageLabel, + stageStatusLabel, + stageStatusTone, +} from "../../lib/stage-sidebar"; import { formatDurationMs } from "../../lib/format"; import { StageMetaBar } from "./meta-bar"; import { parseParallelOverview } from "./helpers"; @@ -182,6 +187,13 @@ export function ParallelChildren({ else if (row.status === StageState.FAILED) failureCount += 1; } + let duration = stage.duration === "--" ? "—" : stage.duration; + if (overview.durationMs != null) { + duration = formatDurationMs(overview.durationMs); + } else if (ACTIVE_STAGE_STATES.has(stage.status)) { + duration = "running"; + } + return (
@@ -200,7 +212,7 @@ export function ParallelChildren({ /> From 8881bcab3ebaf78ea78a7d8964421a9d5dd2c0de Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 12:44:24 -0400 Subject: [PATCH 12/42] Log run event persistence failures as errors --- lib/components/fabro-workflow/src/event/sink.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/fabro-workflow/src/event/sink.rs b/lib/components/fabro-workflow/src/event/sink.rs index eb987f620..52eb229d1 100644 --- a/lib/components/fabro-workflow/src/event/sink.rs +++ b/lib/components/fabro-workflow/src/event/sink.rs @@ -167,7 +167,7 @@ impl RunEventLogger { match command { RunEventCommand::Event(event) => { if let Err(err) = sink.write_run_event(&event).await { - tracing::warn!(error = %err, "Failed to write run event"); + tracing::error!(error = %err, "Failed to write run event"); } } RunEventCommand::Flush(tx) => { From 8bae35398fd216ad26a9764a9e27b87d60ab8467 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 12:59:00 -0400 Subject: [PATCH 13/42] fix(models): point DeepSeek aliases to V4 Flash --- docs/public/integrations/openrouter.mdx | 2 +- lib/foundation/fabro-model/src/catalog.rs | 4 ++-- .../fabro-model/src/catalog/providers/openrouter.toml | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/public/integrations/openrouter.mdx b/docs/public/integrations/openrouter.mdx index c3bf7ff2c..a1fc26775 100644 --- a/docs/public/integrations/openrouter.mdx +++ b/docs/public/integrations/openrouter.mdx @@ -53,7 +53,7 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug | `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default | | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4`, `gpt-5.5` | Matching `openai/...` API IDs | | `gemini-3.1-pro-preview`, `gemini-3.5-flash` | `google/...` API IDs | -| `deepseek-v4-pro` (`deepseek`, `deepseek-v4`), `deepseek-v4-flash` (`deepseek-flash`) | `deepseek/...` API IDs | +| `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `deepseek/...` API IDs | | `kimi-k3`, `kimi-k2.6`, `qwen3-coder`, `qwen3.6-flash` | Vendor-prefixed API IDs | | `laguna-s-2.1`, `laguna-xs-2.1` | `poolside/...`; native reasoning and tool use | | `glm-5.2` (`glm`, `glm5`, `glm52`, `glm5.2`), `glm-4.6` | `z-ai/...` API IDs | diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 21aa04888..b640da4ba 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3316,8 +3316,8 @@ enabled = true for (selector, canonical_id) in [ ("deepseek-v4-pro", "deepseek-v4-pro"), - ("deepseek-v4", "deepseek-v4-pro"), - ("deepseek", "deepseek-v4-pro"), + ("deepseek-v4", "deepseek-v4-flash"), + ("deepseek", "deepseek-v4-flash"), ("deepseek-v4-flash", "deepseek-v4-flash"), ("deepseek-flash", "deepseek-v4-flash"), ] { diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml index f57361b0f..2e438770e 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml @@ -416,7 +416,6 @@ output_cost_per_mtok = 1.20 api_id = "deepseek/deepseek-v4-pro" display_name = "DeepSeek V4 Pro" family = "deepseek-v4" -aliases = ["deepseek-v4", "deepseek"] [providers.openrouter.models."deepseek-v4-pro".limits] context_window = 1050000 @@ -435,7 +434,7 @@ output_cost_per_mtok = 0.87 api_id = "deepseek/deepseek-v4-flash" display_name = "DeepSeek V4 Flash" family = "deepseek-v4" -aliases = ["deepseek-flash"] +aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] [providers.openrouter.models."deepseek-v4-flash".limits] context_window = 1050000 From 0afd3a43b614f1ecd41233b3d686d82348360485 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 12:59:40 -0400 Subject: [PATCH 14/42] feat(models): add portable DeepSeek aliases to Fireworks --- docs/public/integrations/fireworks.mdx | 2 +- lib/foundation/fabro-model/src/catalog.rs | 10 ++++++++++ .../fabro-model/src/catalog/providers/fireworks.toml | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/public/integrations/fireworks.mdx b/docs/public/integrations/fireworks.mdx index 7c3331de6..26e873705 100644 --- a/docs/public/integrations/fireworks.mdx +++ b/docs/public/integrations/fireworks.mdx @@ -50,7 +50,7 @@ The built-in catalog gives Fireworks offerings the same human-facing model slugs | --- | --- | | `kimi-k2.7-code` | `accounts/fireworks/models/kimi-k2p7-code`; provider default | | `kimi-k2.6` | `accounts/fireworks/models/kimi-k2p6` | -| `deepseek-v4-pro`, `deepseek-v4-flash` | `accounts/fireworks/models/deepseek-v4-...` | +| `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `accounts/fireworks/models/deepseek-v4-...` | | `glm-5.2` | `accounts/fireworks/models/glm-5p2` | | `minimax-m2.7` | `accounts/fireworks/models/minimax-m2p7` | | `qwen3.7-plus` | `accounts/fireworks/models/qwen3p7-plus` | diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index b640da4ba..5054bbdb3 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -4025,6 +4025,16 @@ enabled = true assert_eq!(model.id, id, "{provider}/{id}"); assert_eq!(model.provider, provider, "{provider}/{id}"); } + + for alias in ["deepseek", "deepseek-v4", "deepseek-flash"] { + let model = catalog + .resolve_on_provider(&provider, alias) + .unwrap_or_else(|error| { + panic!("'{alias}' should resolve on provider '{provider}': {error}") + }); + assert_eq!(model.id, "deepseek-v4-flash", "{provider}/{alias}"); + assert_eq!(model.provider, provider, "{provider}/{alias}"); + } } } diff --git a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml index 38b2a5231..25be6b8a7 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml @@ -92,6 +92,7 @@ cache_input_cost_per_mtok = 0.145 api_id = "accounts/fireworks/models/deepseek-v4-flash" display_name = "DeepSeek V4 Flash" family = "deepseek-v4" +aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] [providers.fireworks.models."deepseek-v4-flash".limits] context_window = 1048576 From 7e6d758fa064a7f0e8202212818a462df73b9b34 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 12:59:59 -0400 Subject: [PATCH 15/42] fix(llm): classify exceeded quota errors --- lib/components/fabro-llm/src/error.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/components/fabro-llm/src/error.rs b/lib/components/fabro-llm/src/error.rs index 753c12e6f..b6fc67661 100644 --- a/lib/components/fabro-llm/src/error.rs +++ b/lib/components/fabro-llm/src/error.rs @@ -316,6 +316,9 @@ pub fn error_from_status_code( }; } 413 => ProviderErrorKind::ContextLength, + 429 if detail.error_code.as_deref() == Some("exceeded_current_quota_error") => { + ProviderErrorKind::QuotaExceeded + } 429 => ProviderErrorKind::RateLimit, 500..=599 => ProviderErrorKind::Server, // For ambiguous status codes (400, 422, etc.), use message-based classification @@ -603,6 +606,22 @@ mod tests { assert!(err.retryable()); } + #[test] + fn exceeded_current_quota_error_is_non_retryable_quota_failure() { + let err = error_from_status_code( + 429, + "Your account is suspended due to insufficient balance".into(), + "kimi".into(), + Some("exceeded_current_quota_error".into()), + None, + None, + ); + + assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded)); + assert!(!err.retryable()); + assert!(err.failover_eligible()); + } + #[test] fn error_message_classification_context_length() { let err = error_from_status_code( From 8864e0f0cf3cc13701db632c6f7f4d4284b26418 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 13:02:58 -0400 Subject: [PATCH 16/42] feat(models): refresh DeepSeek V4 Flash metadata --- docs/public/integrations/openrouter.mdx | 2 +- lib/components/fabro-llm/tests/integration.rs | 2 +- lib/foundation/fabro-model/src/catalog.rs | 14 +++++++++++++- .../src/catalog/providers/fireworks.toml | 4 ++-- .../src/catalog/providers/openrouter.toml | 10 ++++++---- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/public/integrations/openrouter.mdx b/docs/public/integrations/openrouter.mdx index a1fc26775..d82dbc4e7 100644 --- a/docs/public/integrations/openrouter.mdx +++ b/docs/public/integrations/openrouter.mdx @@ -53,7 +53,7 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug | `claude-haiku-4-5` | `anthropic/claude-haiku-4.5`; provider small default | | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4`, `gpt-5.5` | Matching `openai/...` API IDs | | `gemini-3.1-pro-preview`, `gemini-3.5-flash` | `google/...` API IDs | -| `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `deepseek/...` API IDs | +| `deepseek-v4-pro`, `deepseek-v4-flash` (`deepseek`, `deepseek-v4`, `deepseek-flash`) | `deepseek/...` API IDs; Flash uses the `deepseek-v4-flash-0731` release | | `kimi-k3`, `kimi-k2.6`, `qwen3-coder`, `qwen3.6-flash` | Vendor-prefixed API IDs | | `laguna-s-2.1`, `laguna-xs-2.1` | `poolside/...`; native reasoning and tool use | | `glm-5.2` (`glm`, `glm5`, `glm52`, `glm5.2`), `glm-4.6` | `z-ai/...` API IDs | diff --git a/lib/components/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs index 3c80ef796..d543374eb 100644 --- a/lib/components/fabro-llm/tests/integration.rs +++ b/lib/components/fabro-llm/tests/integration.rs @@ -373,7 +373,7 @@ async fn openrouter_complete() { std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") .with_name("openrouter"); - let request = make_request("deepseek/deepseek-v4-flash"); + let request = make_request("deepseek/deepseek-v4-flash-0731"); let response = adapter.complete(&request).await.unwrap(); assert!( diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 5054bbdb3..83d1a04bd 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3071,6 +3071,18 @@ enabled = true .billing_policy, BillingPolicy::OpenAi ); + let deepseek = catalog + .get_on_provider(&openrouter, "deepseek-v4-flash") + .expect("DeepSeek V4 Flash should be present on OpenRouter"); + assert_eq!(deepseek.limits.max_output, Some(384_000)); + assert!(deepseek.features.prompt_cache); + assert_eq!(deepseek.costs.input_cost_per_mtok, Some(0.14)); + assert_eq!(deepseek.costs.output_cost_per_mtok, Some(0.28)); + assert_eq!(deepseek.costs.cache_input_cost_per_mtok, Some(0.0028)); + assert_eq!( + catalog.settings_for(deepseek).unwrap().api_id, + "deepseek/deepseek-v4-flash-0731" + ); assert_eq!( catalog .default_for_provider(&openrouter) @@ -3877,7 +3889,7 @@ enabled = true "accounts/fireworks/models/deepseek-v4-flash", "deepseek-v4", 1_048_576, - 16_384, + 384_000, false, false, 0.14, diff --git a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml index 25be6b8a7..6701af919 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml @@ -25,7 +25,7 @@ credentials = ["env:FIREWORKS_API_KEY", "vault:FIREWORKS_API_KEY"] # Prompt caching is automatic prefix caching (no cache_control breakpoints); # serverless responses report prompt_tokens_details.cached_tokens in the # usage body. Costs below are from docs.fireworks.ai/serverless/pricing -# (standard tier), verified 2026-07-24. +# (standard tier), verified 2026-07-31. [providers.fireworks.models."kimi-k2.7-code"] api_id = "accounts/fireworks/models/kimi-k2p7-code" @@ -96,7 +96,7 @@ aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] [providers.fireworks.models."deepseek-v4-flash".limits] context_window = 1048576 -max_output = 16384 +max_output = 384000 [providers.fireworks.models."deepseek-v4-flash".features] tools = true diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml index 2e438770e..daf00a77e 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml @@ -431,23 +431,25 @@ input_cost_per_mtok = 0.435 output_cost_per_mtok = 0.87 [providers.openrouter.models."deepseek-v4-flash"] -api_id = "deepseek/deepseek-v4-flash" +api_id = "deepseek/deepseek-v4-flash-0731" display_name = "DeepSeek V4 Flash" family = "deepseek-v4" aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] [providers.openrouter.models."deepseek-v4-flash".limits] context_window = 1050000 -max_output = 16384 +max_output = 384000 [providers.openrouter.models."deepseek-v4-flash".features] tools = true vision = false reasoning = false +prompt_cache = true [providers.openrouter.models."deepseek-v4-flash".costs] -input_cost_per_mtok = 0.10 -output_cost_per_mtok = 0.20 +input_cost_per_mtok = 0.14 +output_cost_per_mtok = 0.28 +cache_input_cost_per_mtok = 0.0028 [providers.openrouter.models."kimi-k2.6"] api_id = "moonshotai/kimi-k2.6" From f613821bfb3808b2765642f5dbd441878802a237 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 13:10:08 -0400 Subject: [PATCH 17/42] feat(llm): add direct DeepSeek provider --- .env.example | 1 + .../public/images/providers/deepseek.svg | 3 + .../administration/server-configuration.mdx | 1 + docs/public/core-concepts/models.mdx | 4 +- docs/public/docs.json | 1 + docs/public/integrations/deepseek.mdx | 109 ++++++++++++++++++ docs/public/reference/sdk.mdx | 1 + lib/apps/fabro-cli/src/commands/install.rs | 1 + .../fabro-llm/src/adapter_registry.rs | 2 + .../src/codec/openai_compatible/wire.rs | 22 ++++ lib/components/fabro-llm/tests/integration.rs | 22 ++++ lib/foundation/fabro-model/src/catalog.rs | 99 +++++++++++++++- .../src/catalog/providers/deepseek.toml | 61 ++++++++++ lib/foundation/fabro-static/src/env_vars.rs | 2 + .../fabro-static/src/secret_registry.rs | 2 + 15 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 apps/fabro-web/public/images/providers/deepseek.svg create mode 100644 docs/public/integrations/deepseek.mdx create mode 100644 lib/foundation/fabro-model/src/catalog/providers/deepseek.toml diff --git a/.env.example b/.env.example index ea69ab76b..c9f059ea1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ ANTHROPIC_API_KEY= BRAVE_SEARCH_API_KEY= DAYTONA_API_KEY= +DEEPSEEK_API_KEY= FIREWORKS_API_KEY= GEMINI_API_KEY= INCEPTION_API_KEY= diff --git a/apps/fabro-web/public/images/providers/deepseek.svg b/apps/fabro-web/public/images/providers/deepseek.svg new file mode 100644 index 000000000..5d6efa991 --- /dev/null +++ b/apps/fabro-web/public/images/providers/deepseek.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index c806406f7..9f107d046 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -388,6 +388,7 @@ fabro secret set GEMINI_API_KEY AI... | `MINIMAX_API_KEY` | Minimax | | `INCEPTION_API_KEY` | Inception (Mercury) | | `POOLSIDE_API_KEY` | Poolside (Laguna) | +| `DEEPSEEK_API_KEY` | DeepSeek | | `OPENROUTER_API_KEY` | OpenRouter (when enabled) | | `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | Modal (when enabled) | | `FIREWORKS_API_KEY` | Fireworks AI (when enabled) | diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index a4befa37f..84f00bb1e 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -59,13 +59,15 @@ Fabro performs this selection once when creating a run and persists the chosen p | `gemini-3.1-flash-lite` | gemini | `gemini-flash-lite`, `gemini-3.1-flash-lite-preview` | 1M | $0.25 / $1.50 | 200 tok/s | | `kimi-k2.5` | kimi | | 262K | $0.60 / $3.00 | 50 tok/s | | `kimi-k3` | kimi | `kimi` | 1M | $3.00 / $15.00 | n/a | +| `deepseek-v4-flash` | deepseek | `deepseek`, `deepseek-v4`, `deepseek-flash` | 1,048,576 | $0.14 / $0.28 | n/a | +| `deepseek-v4-pro` | deepseek | | 1,048,576 | $0.435 / $0.87 | n/a | | `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a | | `laguna-xs-2.1` | poolside | `laguna-xs` | 262K | $0.10 / $0.20 | n/a | | `glm-5.2` | zai | `glm`, `glm5`, `glm52`, `glm5.2` | 1M | $1.40 / $4.40 | n/a | | `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s | | `mercury-2` | inception | `mercury` | 131K | $0.25 / $0.75 | 1000 tok/s | -Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup. +Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup. Claude Fable 5 is available as an explicit model but is not the default Anthropic model. If Fable refuses a request, Fabro reports the refusal as a content-filter LLM error and applies the configured `run.model.fallbacks` chain when one is present. diff --git a/docs/public/docs.json b/docs/public/docs.json index e00f0d711..faf0d0f84 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -96,6 +96,7 @@ "integrations/daytona", "integrations/litellm", "integrations/bedrock", + "integrations/deepseek", "integrations/poolside", "integrations/openrouter", "integrations/modal", diff --git a/docs/public/integrations/deepseek.mdx b/docs/public/integrations/deepseek.mdx new file mode 100644 index 000000000..9180a0461 --- /dev/null +++ b/docs/public/integrations/deepseek.mdx @@ -0,0 +1,109 @@ +--- +title: "DeepSeek" +description: "Run DeepSeek V4 Flash and Pro through DeepSeek's direct API or supported gateways" +--- + +[DeepSeek](https://www.deepseek.com/) provides an OpenAI-compatible API for DeepSeek V4. Fabro includes direct access to V4 Flash and V4 Pro. The same Fabro model IDs also work through the optional Fireworks AI and OpenRouter providers. + +## Prerequisites + +- A [DeepSeek Platform](https://platform.deepseek.com/) account +- A DeepSeek API key from [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys) +- A running Fabro server + +## Configure direct access + +The direct `deepseek` provider is enabled in the built-in catalog. Store its key in the target Fabro server vault: + +```bash +fabro provider login --provider deepseek + +# For a non-default remote server: +fabro provider login --server https://your-fabro.example --provider deepseek + +# Or set the vault token directly: +fabro secret set DEEPSEEK_API_KEY +fabro secret --server https://your-fabro.example set DEEPSEEK_API_KEY +``` + +Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly: + +```bash +export DEEPSEEK_API_KEY= +``` + +Fabro sends bearer-authenticated Chat Completions requests to `https://api.deepseek.com`. + +## Included models + +| Fabro model ID | DeepSeek API model ID | Context | Max output | Role | +|---|---|---:|---:|---| +| `deepseek-v4-flash` | `deepseek-v4-flash` | 1,048,576 | 384,000 | Provider default, small default, and connectivity probe; aliases `deepseek`, `deepseek-v4`, `deepseek-flash` | +| `deepseek-v4-pro` | `deepseek-v4-pro` | 1,048,576 | 384,000 | Higher-capability V4 model | + +Both models support text input, tool calling, native reasoning, streaming, JSON output, and automatic prompt caching. They do not support image input. Thinking mode is enabled by default. + +## Use DeepSeek models + +```bash +fabro model list --provider deepseek +fabro model test --provider deepseek --model deepseek-v4-flash --deep +fabro run workflow.fabro --provider deepseek --model deepseek +``` + +In workflow stylesheets: + +```dot title="workflow.fabro" +digraph Example { + graph [ + model_stylesheet=" + * { model: deepseek-v4-flash; } + .difficult { model: deepseek-v4-pro; } + " + ] + + start [shape=Mdiamond, label="Start"] + work [label="Implement", prompt="Implement and verify the requested change."] + exit [shape=Msquare, label="Exit"] + + start -> work -> exit +} +``` + +## Prompt caching and pricing + +DeepSeek applies prefix caching automatically. Fabro reads DeepSeek's `prompt_cache_hit_tokens` usage field and reports cache-read tokens separately from uncached input tokens. + +The built-in catalog uses DeepSeek's published prices per million tokens: + +| Model | Uncached input | Cache hit | Output | +|---|---:|---:|---:| +| `deepseek-v4-flash` | $0.14 | $0.0028 | $0.28 | +| `deepseek-v4-pro` | $0.435 | $0.003625 | $0.87 | + +DeepSeek does not return an in-band dollar cost. Fabro calculates an estimated cost from these catalog rates and the reported token buckets. + +## Use a gateway + +The `deepseek-v4-flash`, `deepseek-v4-pro`, `deepseek`, `deepseek-v4`, and `deepseek-flash` selectors are portable across direct DeepSeek, Fireworks AI, and OpenRouter routes. Use `--provider` or a provider-qualified model selector when you need a specific route. + +See the [Fireworks AI integration](/integrations/fireworks) and [OpenRouter integration](/integrations/openrouter) for gateway setup and provider-specific pricing. + +## Troubleshooting + +**"No API key configured"** — Store `DEEPSEEK_API_KEY` on the target server with `fabro provider login --provider deepseek`. The server runtime resolves the key from its vault, not from process env. + +**Unknown model** — Use `deepseek-v4-flash` or `deepseek-v4-pro`. The retired `deepseek-chat` and `deepseek-reasoner` API IDs are not in the Fabro catalog. + +**A tool continuation returns HTTP 400** — Keep the assistant thinking content in conversation history. Fabro does this automatically when it replays tool-call turns. + +## Further reading + + + + Official authentication, endpoints, and API reference. + + + Official limits, features, and token prices. + + diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index feeea0c0a..ad4eedb0b 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -371,6 +371,7 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia | `MINIMAX_API_KEY` | Minimax | | `INCEPTION_API_KEY` | Inception | | `POOLSIDE_API_KEY` | Poolside | +| `DEEPSEEK_API_KEY` | DeepSeek | | `OPENROUTER_API_KEY` | OpenRouter, when enabled in settings | The first provider registered becomes the default. Provider base URLs come from the model catalog. For vault-backed usage inside Fabro, use `fabro_auth::VaultCredentialSource` instead. diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index 3486b35f5..adfb2d97c 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -3521,6 +3521,7 @@ root = "{}" assert!(ids.contains(&ProviderId::new("inception"))); assert!(ids.contains(&ProviderId::new("venice"))); assert!(ids.contains(&ProviderId::new("poolside"))); + assert!(ids.contains(&ProviderId::new("deepseek"))); assert!(!ids.contains(&ProviderId::new("fireworks"))); assert!(!ids.contains(&ProviderId::new("ollama"))); assert!(!ids.contains(&ProviderId::new("litellm"))); diff --git a/lib/components/fabro-llm/src/adapter_registry.rs b/lib/components/fabro-llm/src/adapter_registry.rs index 26ff6d70b..bb1b893ce 100644 --- a/lib/components/fabro-llm/src/adapter_registry.rs +++ b/lib/components/fabro-llm/src/adapter_registry.rs @@ -303,6 +303,8 @@ mod tests { ("claude-sonnet-4-5", "claude-sonnet-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), ("claude-sonnet-4-6", "claude-sonnet-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic), ("claude-sonnet-5", "claude-sonnet-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5), + ("deepseek-v4-flash", "deepseek-v4-flash", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), + ("deepseek-v4-pro", "deepseek-v4-pro", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi), ("gemini-3-flash-preview", "gemini-3-flash-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini), ("gemini-3.1-flash-lite", "gemini-3.1-flash-lite", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini), ("gemini-3.1-pro-preview", "gemini-3.1-pro-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini), diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs index 3e1de949d..bc3883aa6 100644 --- a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs @@ -300,6 +300,10 @@ pub(super) struct ApiUsage { pub cost: Option, #[serde(default)] pub prompt_tokens_details: Option, + /// DeepSeek-specific top-level count of prompt tokens served from its + /// automatic context cache. + #[serde(default)] + pub prompt_cache_hit_tokens: Option, #[serde(default)] pub completion_tokens_details: Option, } @@ -329,6 +333,7 @@ impl ApiUsage { .prompt_tokens_details .as_ref() .and_then(|d| d.cached_tokens) + .or(self.prompt_cache_hit_tokens) .unwrap_or(0); let cache_write_detail = self .prompt_tokens_details @@ -538,6 +543,23 @@ mod tests { }); } + #[test] + fn token_counts_accept_deepseek_cache_hit_field() { + let usage: ApiUsage = serde_json::from_value(serde_json::json!({ + "prompt_tokens": 53, + "completion_tokens": 11, + "prompt_cache_hit_tokens": 41 + })) + .unwrap(); + + assert_eq!(usage.token_counts(), TokenCounts { + input_tokens: 12, + output_tokens: 11, + cache_read_tokens: 41, + ..TokenCounts::default() + }); + } + #[test] fn reasoning_accepts_provider_and_openrouter_spellings() { let provider_response: ApiResponse = serde_json::from_value(serde_json::json!({ diff --git a/lib/components/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs index d543374eb..96b6adad8 100644 --- a/lib/components/fabro-llm/tests/integration.rs +++ b/lib/components/fabro-llm/tests/integration.rs @@ -599,6 +599,28 @@ async fn fireworks_complete() { assert_eq!(response.provider, "fireworks"); } +#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))] +async fn deepseek_complete() { + let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set"); + let adapter = + OpenAiCompatibleAdapter::new(api_key, "https://api.deepseek.com").with_name("deepseek"); + let request = Request { + // Thinking mode is enabled by default and shares this budget with the + // visible answer. + max_tokens: Some(1024), + ..make_request("deepseek-v4-flash") + }; + let response = adapter.complete(&request).await.unwrap(); + + assert!( + !response.text().is_empty(), + "response text should not be empty" + ); + assert!(response.usage.input_tokens > 0); + assert!(response.usage.output_tokens > 0 || response.usage.reasoning_tokens > 0); + assert_eq!(response.provider, "deepseek"); +} + #[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))] async fn fireworks_kimi_k2_7_code_deep_tool_round_trip() { let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set"); diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 83d1a04bd..3eb647f0c 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3028,6 +3028,77 @@ enabled = true ); } + #[test] + fn builtin_deepseek_provider_routes_v4_models() { + let deepseek = ProviderId::new("deepseek"); + let catalog = Catalog::builtin(); + let provider = catalog + .provider(&deepseek) + .expect("DeepSeek provider should be active"); + + assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); + assert_eq!(provider.codec, CodecKind::OpenAiCompatible); + assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); + assert_eq!( + provider.base_url.as_deref(), + Some("https://api.deepseek.com") + ); + assert_eq!(provider.priority, 75); + assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ + CredentialRef::Env("DEEPSEEK_API_KEY".to_string()), + CredentialRef::Vault("DEEPSEEK_API_KEY".to_string()), + ]); + assert_eq!( + catalog + .default_for_provider(&deepseek) + .map(|model| model.id.as_str()), + Some("deepseek-v4-flash") + ); + assert_eq!( + catalog + .small_default_for_provider(&deepseek) + .map(|model| model.id.as_str()), + Some("deepseek-v4-flash") + ); + assert_eq!( + catalog + .probe_for_provider(&deepseek) + .map(|model| model.id.as_str()), + Some("deepseek-v4-flash") + ); + + let expected = [ + ("deepseek-v4-flash", 0.14, 0.28, 0.0028), + ("deepseek-v4-pro", 0.435, 0.87, 0.003625), + ]; + for (id, input, output, cache_read) in expected { + let model = catalog + .get_on_provider(&deepseek, id) + .unwrap_or_else(|| panic!("DeepSeek model '{id}' should be present")); + assert_eq!(model.family, "deepseek-v4", "{id}"); + assert_eq!(model.limits.context_window, 1_048_576, "{id}"); + assert_eq!(model.limits.max_output, Some(384_000), "{id}"); + assert!(model.features.tools, "{id}"); + assert!(!model.features.vision, "{id}"); + assert!(model.features.reasoning, "{id}"); + assert!(model.features.prompt_cache, "{id}"); + assert!(!model.features.sampling_params, "{id}"); + assert_eq!(model.costs.input_cost_per_mtok, Some(input), "{id}"); + assert_eq!(model.costs.output_cost_per_mtok, Some(output), "{id}"); + assert_eq!( + model.costs.cache_input_cost_per_mtok, + Some(cache_read), + "{id}" + ); + + let settings = catalog + .model_settings_on_provider(&deepseek, id) + .unwrap_or_else(|| panic!("DeepSeek settings for '{id}' should be present")); + assert_eq!(settings.api_id, id, "{id}"); + assert!(settings.reasoning_by_default, "{id}"); + } + } + #[test] fn builtin_openrouter_provider_is_opt_in() { let openrouter = ProviderId::new("openrouter"); @@ -4011,7 +4082,7 @@ enabled = true } #[test] - fn builtin_fireworks_shared_slugs_are_portable_with_openrouter() { + fn builtin_deepseek_shared_slugs_are_portable_across_providers() { let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( r" [providers.fireworks] @@ -4037,7 +4108,20 @@ enabled = true assert_eq!(model.id, id, "{provider}/{id}"); assert_eq!(model.provider, provider, "{provider}/{id}"); } + } + for provider in [ + ProviderId::new("deepseek"), + ProviderId::new("fireworks"), + ProviderId::new("openrouter"), + ] { + for id in ["deepseek-v4-pro", "deepseek-v4-flash"] { + let model = catalog + .get_on_provider(&provider, id) + .unwrap_or_else(|| panic!("'{id}' should resolve on provider '{provider}'")); + assert_eq!(model.id, id, "{provider}/{id}"); + assert_eq!(model.provider, provider, "{provider}/{id}"); + } for alias in ["deepseek", "deepseek-v4", "deepseek-flash"] { let model = catalog .resolve_on_provider(&provider, alias) @@ -4048,6 +4132,19 @@ enabled = true assert_eq!(model.provider, provider, "{provider}/{alias}"); } } + + let selected = catalog + .select( + "deepseek", + None, + &HashSet::from([ + ProviderId::new("deepseek"), + ProviderId::new("fireworks"), + ProviderId::new("openrouter"), + ]), + ) + .expect("direct DeepSeek should win portable DeepSeek selection"); + assert_eq!(selected.provider, ProviderId::new("deepseek")); } #[test] diff --git a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml new file mode 100644 index 000000000..f7b073d72 --- /dev/null +++ b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml @@ -0,0 +1,61 @@ +[providers.deepseek] +display_name = "DeepSeek" +adapter = "openai_compatible" +api_key_url = "https://platform.deepseek.com/api_keys" +base_url = "https://api.deepseek.com" +priority = 75 + +[providers.deepseek.auth] +credentials = ["env:DEEPSEEK_API_KEY", "vault:DEEPSEEK_API_KEY"] + +# DeepSeek V4 uses thinking mode by default. The API accepts sampling +# parameters in that mode but ignores them, so Fabro omits those parameters. +# Prompt caching is automatic and usage reports prompt_cache_hit_tokens. +# Prices are from api-docs.deepseek.com/quick_start/pricing, verified +# 2026-07-31. + +[providers.deepseek.models."deepseek-v4-flash"] +display_name = "DeepSeek V4 Flash" +family = "deepseek-v4" +aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] +default = true +small_default = true +probe = true + +[providers.deepseek.models."deepseek-v4-flash".limits] +context_window = 1048576 +max_output = 384000 + +[providers.deepseek.models."deepseek-v4-flash".features] +tools = true +vision = false +reasoning = true +reasoning_by_default = true +prompt_cache = true +sampling_params = false + +[providers.deepseek.models."deepseek-v4-flash".costs] +input_cost_per_mtok = 0.14 +output_cost_per_mtok = 0.28 +cache_input_cost_per_mtok = 0.0028 + +[providers.deepseek.models."deepseek-v4-pro"] +display_name = "DeepSeek V4 Pro" +family = "deepseek-v4" + +[providers.deepseek.models."deepseek-v4-pro".limits] +context_window = 1048576 +max_output = 384000 + +[providers.deepseek.models."deepseek-v4-pro".features] +tools = true +vision = false +reasoning = true +reasoning_by_default = true +prompt_cache = true +sampling_params = false + +[providers.deepseek.models."deepseek-v4-pro".costs] +input_cost_per_mtok = 0.435 +output_cost_per_mtok = 0.87 +cache_input_cost_per_mtok = 0.003625 diff --git a/lib/foundation/fabro-static/src/env_vars.rs b/lib/foundation/fabro-static/src/env_vars.rs index 849ec6842..fc5d2312a 100644 --- a/lib/foundation/fabro-static/src/env_vars.rs +++ b/lib/foundation/fabro-static/src/env_vars.rs @@ -48,6 +48,7 @@ impl EnvVars { pub const BEDROCK_API_KEY: &'static str = "BEDROCK_API_KEY"; pub const BRAVE_SEARCH_API_KEY: &'static str = "BRAVE_SEARCH_API_KEY"; pub const CHATGPT_ACCOUNT_ID: &'static str = "CHATGPT_ACCOUNT_ID"; + pub const DEEPSEEK_API_KEY: &'static str = "DEEPSEEK_API_KEY"; pub const FIREWORKS_API_KEY: &'static str = "FIREWORKS_API_KEY"; pub const GEMINI_API_KEY: &'static str = "GEMINI_API_KEY"; pub const GEMINI_BASE_URL: &'static str = "GEMINI_BASE_URL"; @@ -198,6 +199,7 @@ mod tests { EnvVars::BEDROCK_API_KEY, EnvVars::BRAVE_SEARCH_API_KEY, EnvVars::CHATGPT_ACCOUNT_ID, + EnvVars::DEEPSEEK_API_KEY, EnvVars::FIREWORKS_API_KEY, EnvVars::GEMINI_API_KEY, EnvVars::GEMINI_BASE_URL, diff --git a/lib/foundation/fabro-static/src/secret_registry.rs b/lib/foundation/fabro-static/src/secret_registry.rs index 03dec8e12..3bfbf92be 100644 --- a/lib/foundation/fabro-static/src/secret_registry.rs +++ b/lib/foundation/fabro-static/src/secret_registry.rs @@ -19,6 +19,7 @@ const OPTIONAL_VAULT_SECRETS: &[&str] = &[ EnvVars::AWS_BEARER_TOKEN_BEDROCK, EnvVars::BEDROCK_API_KEY, EnvVars::BRAVE_SEARCH_API_KEY, + EnvVars::DEEPSEEK_API_KEY, EnvVars::FABRO_SLACK_APP_TOKEN, EnvVars::FABRO_SLACK_BOT_TOKEN, EnvVars::FIREWORKS_API_KEY, @@ -92,6 +93,7 @@ mod tests { EnvVars::ANTHROPIC_API_KEY, EnvVars::AWS_BEARER_TOKEN_BEDROCK, EnvVars::BEDROCK_API_KEY, + EnvVars::DEEPSEEK_API_KEY, EnvVars::FIREWORKS_API_KEY, EnvVars::GEMINI_API_KEY, EnvVars::INCEPTION_API_KEY, From 76dae568f5e29eeafc3edfe8c6a293b2555cbe53 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 13:14:11 -0400 Subject: [PATCH 18/42] feat(reasoning): expose DeepSeek effort controls --- docs/public/integrations/deepseek.mdx | 33 ++++++++- .../src/codec/openai_compatible/translate.rs | 35 ++++++++- .../src/codec/openai_compatible/wire.rs | 2 +- lib/components/fabro-llm/tests/integration.rs | 11 +++ .../tests/it/wire/openai_compatible.rs | 4 +- lib/foundation/fabro-model/src/catalog.rs | 74 ++++++++++++++++++- .../src/catalog/providers/deepseek.toml | 10 +++ .../src/catalog/providers/fireworks.toml | 15 +++- .../src/catalog/providers/openrouter.toml | 19 ++++- 9 files changed, 194 insertions(+), 9 deletions(-) diff --git a/docs/public/integrations/deepseek.mdx b/docs/public/integrations/deepseek.mdx index 9180a0461..69d0474cc 100644 --- a/docs/public/integrations/deepseek.mdx +++ b/docs/public/integrations/deepseek.mdx @@ -70,6 +70,34 @@ digraph Example { } ``` +## Thinking and reasoning effort + +DeepSeek enables thinking by default at `high` effort. Fabro advertises only effort values that produce a distinct model behavior on each route: + +| Route | V4 Flash | V4 Pro | +|---|---|---| +| Direct DeepSeek | `low`, `high`, `max` | `high`, `max` | +| Fireworks AI | `high`, `max` | `high`, `max` | +| OpenRouter | `low`, `high`, `max` | `high`, `xhigh` | + +DeepSeek currently maps a V4 Pro request for `low` to `high`; its documentation says this mapping will change in early August 2026. Fireworks maps `low` and `medium` to `high`, and maps `xhigh` to `max`. OpenRouter names the Pro maximum tier `xhigh`. + +Fabro omits `temperature` and `top_p` for these models because DeepSeek ignores sampling parameters while thinking is enabled. + +To disable thinking on the direct provider, omit typed `reasoning_effort` and pass DeepSeek's native toggle through provider options: + +```json +{ + "provider_options": { + "deepseek": { + "thinking": { "type": "disabled" } + } + } +} +``` + +DeepSeek requires `reasoning_content` from an assistant tool call to appear in every later request in that tool-use turn. Fabro captures this content and replays it with the assistant message. This keeps multi-step tool calls valid and prevents DeepSeek's HTTP 400 response for missing reasoning history. + ## Prompt caching and pricing DeepSeek applies prefix caching automatically. Fabro reads DeepSeek's `prompt_cache_hit_tokens` usage field and reports cache-read tokens separately from uncached input tokens. @@ -99,11 +127,14 @@ See the [Fireworks AI integration](/integrations/fireworks) and [OpenRouter inte ## Further reading - + Official authentication, endpoints, and API reference. Official limits, features, and token prices. + + Official thinking toggles, effort mappings, and tool-call replay rules. + diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs b/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs index dbef4831f..b32fac567 100644 --- a/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs @@ -237,7 +237,9 @@ pub(super) fn translate_response_format(format: &ResponseFormat) -> serde_json:: #[cfg(test)] mod tests { use super::*; - use crate::types::{AudioData, ContentPart, DocumentData, Message, Role, ToolCall}; + use crate::types::{ + AudioData, ContentPart, DocumentData, Message, Role, ThinkingData, ToolCall, + }; #[test] fn translate_assistant_message_with_tool_calls_only() { @@ -291,6 +293,37 @@ mod tests { assert_eq!(tool_calls[0].function.name, "get_weather"); } + #[test] + fn translate_assistant_tool_call_replays_reasoning_content() { + let msg = Message { + role: Role::Assistant, + content: vec![ + ContentPart::Thinking(ThinkingData { + text: "I need the weather tool.".to_string(), + signature: None, + redacted: false, + }), + ContentPart::ToolCall(ToolCall::new( + "call_2", + "get_weather", + serde_json::json!({"city": "NYC"}), + )), + ], + name: None, + tool_call_id: None, + }; + + let translated = translate_messages(&[msg]); + + assert_eq!( + translated[0].reasoning_content.as_deref(), + Some("I need the weather tool.") + ); + assert_eq!(translated[0].tool_calls.as_ref().unwrap().len(), 1); + let json = serde_json::to_value(&translated[0]).unwrap(); + assert_eq!(json["reasoning_content"], "I need the weather tool."); + } + #[test] fn translate_assistant_message_with_raw_arguments() { let mut tc = ToolCall::new("call_3", "search", serde_json::json!({"q": "rust"})); diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs index bc3883aa6..1b7ff3b33 100644 --- a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs @@ -44,7 +44,7 @@ pub(super) struct ChatMessage { #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, /// Reasoning/thinking content echoed back for providers that require it - /// (Kimi). + /// during tool-call continuations (including Kimi and DeepSeek). #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_content: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/lib/components/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs index 96b6adad8..b9790b5ad 100644 --- a/lib/components/fabro-llm/tests/integration.rs +++ b/lib/components/fabro-llm/tests/integration.rs @@ -621,6 +621,17 @@ async fn deepseek_complete() { assert_eq!(response.provider, "deepseek"); } +#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))] +async fn deepseek_v4_flash_deep_tool_round_trip() { + let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set"); + let provider = ProviderId::new("deepseek"); + let catalog = enabled_provider_catalog(&provider, None); + let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) + .expect("DeepSeek credential should resolve from the catalog"); + + assert_deep_tool_round_trip(&catalog, &provider, "deepseek-v4-flash", credential).await; +} + #[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))] async fn fireworks_kimi_k2_7_code_deep_tool_round_trip() { let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set"); diff --git a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs index 1294e2e8a..1074cc222 100644 --- a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs +++ b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs @@ -185,8 +185,8 @@ async fn encode_tool_round_trip() { fabro_test::fabro_json_snapshot!(capture.body); } -/// Assistant thinking parts echo back as `reasoning_content` (Kimi-motivated, -/// applies to every compat assistant message). +/// Assistant thinking parts echo back as `reasoning_content` (required by +/// Kimi and DeepSeek during tool-call continuations). #[tokio::test] async fn encode_thinking_round_trip_as_reasoning_content() { let capture = encode_capture(&corpus_thinking_round_trip(MODEL)).await; diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 3eb647f0c..3484b130b 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3099,6 +3099,72 @@ enabled = true } } + #[test] + fn builtin_deepseek_reasoning_controls_match_provider_dialects() { + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r" +[providers.fireworks] +enabled = true + +[providers.openrouter] +enabled = true +", + )) + .expect("DeepSeek gateway providers should build when enabled"); + + let expected = [ + (ProviderId::new("deepseek"), "deepseek-v4-flash", vec![ + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + (ProviderId::new("deepseek"), "deepseek-v4-pro", vec![ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + (ProviderId::new("fireworks"), "deepseek-v4-flash", vec![ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + (ProviderId::new("fireworks"), "deepseek-v4-pro", vec![ + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + (ProviderId::new("openrouter"), "deepseek-v4-flash", vec![ + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + (ProviderId::new("openrouter"), "deepseek-v4-pro", vec![ + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + ]; + + for (provider, id, efforts) in expected { + let model = catalog + .get_on_provider(&provider, id) + .unwrap_or_else(|| panic!("{provider}/{id} should be present")); + assert!(model.features.reasoning, "{provider}/{id}"); + assert_eq!( + model.features.reasoning_effort, + ReasoningEffortFeature::Levels, + "{provider}/{id}" + ); + assert_eq!(model.controls.reasoning_effort, efforts, "{provider}/{id}"); + assert!(!model.features.sampling_params, "{provider}/{id}"); + + let settings = catalog + .model_settings_on_provider(&provider, id) + .unwrap_or_else(|| panic!("{provider}/{id} settings should be present")); + assert!(settings.reasoning_by_default, "{provider}/{id}"); + assert_eq!( + settings.controls.reasoning_effort, efforts, + "{provider}/{id}" + ); + } + } + #[test] fn builtin_openrouter_provider_is_opt_in() { let openrouter = ProviderId::new("openrouter"); @@ -3154,6 +3220,12 @@ enabled = true catalog.settings_for(deepseek).unwrap().api_id, "deepseek/deepseek-v4-flash-0731" ); + let deepseek_pro = catalog + .get_on_provider(&openrouter, "deepseek-v4-pro") + .expect("DeepSeek V4 Pro should be present on OpenRouter"); + assert_eq!(deepseek_pro.limits.max_output, Some(384_000)); + assert!(deepseek_pro.features.prompt_cache); + assert_eq!(deepseek_pro.costs.cache_input_cost_per_mtok, Some(0.003625)); assert_eq!( catalog .default_for_provider(&openrouter) @@ -3962,7 +4034,7 @@ enabled = true 1_048_576, 384_000, false, - false, + true, 0.14, 0.28, 0.028, diff --git a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml index f7b073d72..17d06efa8 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml @@ -30,10 +30,14 @@ max_output = 384000 tools = true vision = false reasoning = true +reasoning_effort = "levels" reasoning_by_default = true prompt_cache = true sampling_params = false +[providers.deepseek.models."deepseek-v4-flash".controls] +reasoning_effort = ["low", "high", "max"] + [providers.deepseek.models."deepseek-v4-flash".costs] input_cost_per_mtok = 0.14 output_cost_per_mtok = 0.28 @@ -51,10 +55,16 @@ max_output = 384000 tools = true vision = false reasoning = true +reasoning_effort = "levels" reasoning_by_default = true prompt_cache = true sampling_params = false +[providers.deepseek.models."deepseek-v4-pro".controls] +# V4 Pro currently maps low to high. Keep only its distinct effort levels; +# DeepSeek says it plans to change Pro's mapping in early August 2026. +reasoning_effort = ["high", "max"] + [providers.deepseek.models."deepseek-v4-pro".costs] input_cost_per_mtok = 0.435 output_cost_per_mtok = 0.87 diff --git a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml index 6701af919..d6f1ae412 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml @@ -81,7 +81,14 @@ max_output = 16384 tools = true vision = false reasoning = true +reasoning_effort = "levels" +reasoning_by_default = true prompt_cache = true +sampling_params = false + +[providers.fireworks.models."deepseek-v4-pro".controls] +# Fireworks promotes low/medium to high and xhigh to max for DeepSeek V4. +reasoning_effort = ["high", "max"] [providers.fireworks.models."deepseek-v4-pro".costs] input_cost_per_mtok = 1.74 @@ -101,8 +108,14 @@ max_output = 384000 [providers.fireworks.models."deepseek-v4-flash".features] tools = true vision = false -reasoning = false +reasoning = true +reasoning_effort = "levels" +reasoning_by_default = true prompt_cache = true +sampling_params = false + +[providers.fireworks.models."deepseek-v4-flash".controls] +reasoning_effort = ["high", "max"] [providers.fireworks.models."deepseek-v4-flash".costs] input_cost_per_mtok = 0.14 diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml index daf00a77e..9d8fa87dc 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml @@ -419,16 +419,25 @@ family = "deepseek-v4" [providers.openrouter.models."deepseek-v4-pro".limits] context_window = 1050000 -max_output = 16384 +max_output = 384000 [providers.openrouter.models."deepseek-v4-pro".features] tools = true vision = false reasoning = true +reasoning_effort = "levels" +reasoning_by_default = true +prompt_cache = true +sampling_params = false + +[providers.openrouter.models."deepseek-v4-pro".controls] +# OpenRouter names DeepSeek's max tier xhigh on this route. +reasoning_effort = ["high", "xhigh"] [providers.openrouter.models."deepseek-v4-pro".costs] input_cost_per_mtok = 0.435 output_cost_per_mtok = 0.87 +cache_input_cost_per_mtok = 0.003625 [providers.openrouter.models."deepseek-v4-flash"] api_id = "deepseek/deepseek-v4-flash-0731" @@ -443,8 +452,14 @@ max_output = 384000 [providers.openrouter.models."deepseek-v4-flash".features] tools = true vision = false -reasoning = false +reasoning = true +reasoning_effort = "levels" +reasoning_by_default = true prompt_cache = true +sampling_params = false + +[providers.openrouter.models."deepseek-v4-flash".controls] +reasoning_effort = ["low", "high", "max"] [providers.openrouter.models."deepseek-v4-flash".costs] input_cost_per_mtok = 0.14 From 1016f995c4538f2320779409215dab4373ebc00d Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 13:16:37 -0400 Subject: [PATCH 19/42] chore(models): pin DeepSeek agent profile --- docs/public/integrations/deepseek.mdx | 6 ++++++ lib/foundation/fabro-model/src/catalog.rs | 5 +++++ .../fabro-model/src/catalog/providers/deepseek.toml | 3 +++ .../fabro-model/src/catalog/providers/fireworks.toml | 2 ++ .../fabro-model/src/catalog/providers/openrouter.toml | 2 ++ 5 files changed, 18 insertions(+) diff --git a/docs/public/integrations/deepseek.mdx b/docs/public/integrations/deepseek.mdx index 69d0474cc..23fcdb361 100644 --- a/docs/public/integrations/deepseek.mdx +++ b/docs/public/integrations/deepseek.mdx @@ -43,6 +43,12 @@ Fabro sends bearer-authenticated Chat Completions requests to `https://api.deeps Both models support text input, tool calling, native reasoning, streaming, JSON output, and automatic prompt caching. They do not support image input. Thinking mode is enabled by default. +## Agent profile + +DeepSeek V4 uses Fabro's `openai` agent profile on every route. This profile is the closest match for DeepSeek's general coding behavior: it supplies project `AGENTS.md` instructions, standard JSON function tools, and a JSON-compatible file editor on Chat Completions routes. The setting is model-specific, so it remains the same through direct DeepSeek, Fireworks AI, and OpenRouter. + +Fabro does not use the `gpt56` profile for DeepSeek. That profile has a smaller Codex-specific tool set for GPT-5.6 models. + ## Use DeepSeek models ```bash diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 3484b130b..81d25d29e 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3157,6 +3157,11 @@ enabled = true let settings = catalog .model_settings_on_provider(&provider, id) .unwrap_or_else(|| panic!("{provider}/{id} settings should be present")); + assert_eq!( + settings.agent_profile, + AgentProfileKind::OpenAi, + "{provider}/{id}" + ); assert!(settings.reasoning_by_default, "{provider}/{id}"); assert_eq!( settings.controls.reasoning_effort, efforts, diff --git a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml index 17d06efa8..e97981178 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/deepseek.toml @@ -18,6 +18,8 @@ credentials = ["env:DEEPSEEK_API_KEY", "vault:DEEPSEEK_API_KEY"] display_name = "DeepSeek V4 Flash" family = "deepseek-v4" aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] +# DeepSeek uses a general coding prompt and standard JSON function tools. +agent_profile = "openai" default = true small_default = true probe = true @@ -46,6 +48,7 @@ cache_input_cost_per_mtok = 0.0028 [providers.deepseek.models."deepseek-v4-pro"] display_name = "DeepSeek V4 Pro" family = "deepseek-v4" +agent_profile = "openai" [providers.deepseek.models."deepseek-v4-pro".limits] context_window = 1048576 diff --git a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml index d6f1ae412..553fd58ed 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/fireworks.toml @@ -72,6 +72,7 @@ cache_input_cost_per_mtok = 0.16 api_id = "accounts/fireworks/models/deepseek-v4-pro" display_name = "DeepSeek V4 Pro" family = "deepseek-v4" +agent_profile = "openai" [providers.fireworks.models."deepseek-v4-pro".limits] context_window = 1048576 @@ -100,6 +101,7 @@ api_id = "accounts/fireworks/models/deepseek-v4-flash" display_name = "DeepSeek V4 Flash" family = "deepseek-v4" aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] +agent_profile = "openai" [providers.fireworks.models."deepseek-v4-flash".limits] context_window = 1048576 diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml index 9d8fa87dc..d2576a830 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml @@ -416,6 +416,7 @@ output_cost_per_mtok = 1.20 api_id = "deepseek/deepseek-v4-pro" display_name = "DeepSeek V4 Pro" family = "deepseek-v4" +agent_profile = "openai" [providers.openrouter.models."deepseek-v4-pro".limits] context_window = 1050000 @@ -444,6 +445,7 @@ api_id = "deepseek/deepseek-v4-flash-0731" display_name = "DeepSeek V4 Flash" family = "deepseek-v4" aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] +agent_profile = "openai" [providers.openrouter.models."deepseek-v4-flash".limits] context_window = 1050000 From 40391ac3ac1ff673b8301f29f11c35d7bcdd97cd Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 13:17:08 -0400 Subject: [PATCH 20/42] fix(models): use exact DeepSeek context window --- lib/foundation/fabro-model/src/catalog.rs | 1 + .../fabro-model/src/catalog/providers/openrouter.toml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 81d25d29e..f2332249a 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -4198,6 +4198,7 @@ enabled = true .unwrap_or_else(|| panic!("'{id}' should resolve on provider '{provider}'")); assert_eq!(model.id, id, "{provider}/{id}"); assert_eq!(model.provider, provider, "{provider}/{id}"); + assert_eq!(model.limits.context_window, 1_048_576, "{provider}/{id}"); } for alias in ["deepseek", "deepseek-v4", "deepseek-flash"] { let model = catalog diff --git a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml index d2576a830..d4ea987bb 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/openrouter.toml @@ -419,7 +419,7 @@ family = "deepseek-v4" agent_profile = "openai" [providers.openrouter.models."deepseek-v4-pro".limits] -context_window = 1050000 +context_window = 1048576 max_output = 384000 [providers.openrouter.models."deepseek-v4-pro".features] @@ -448,7 +448,7 @@ aliases = ["deepseek-v4", "deepseek", "deepseek-flash"] agent_profile = "openai" [providers.openrouter.models."deepseek-v4-flash".limits] -context_window = 1050000 +context_window = 1048576 max_output = 384000 [providers.openrouter.models."deepseek-v4-flash".features] From 799fac1d3b94138957e902e771856525084b4aa1 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 13:17:44 -0400 Subject: [PATCH 21/42] style(models): group DeepSeek price digits --- lib/foundation/fabro-model/src/catalog.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index f2332249a..82878ab1b 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3069,7 +3069,7 @@ enabled = true let expected = [ ("deepseek-v4-flash", 0.14, 0.28, 0.0028), - ("deepseek-v4-pro", 0.435, 0.87, 0.003625), + ("deepseek-v4-pro", 0.435, 0.87, 0.003_625), ]; for (id, input, output, cache_read) in expected { let model = catalog @@ -3230,7 +3230,10 @@ enabled = true .expect("DeepSeek V4 Pro should be present on OpenRouter"); assert_eq!(deepseek_pro.limits.max_output, Some(384_000)); assert!(deepseek_pro.features.prompt_cache); - assert_eq!(deepseek_pro.costs.cache_input_cost_per_mtok, Some(0.003625)); + assert_eq!( + deepseek_pro.costs.cache_input_cost_per_mtok, + Some(0.003_625) + ); assert_eq!( catalog .default_for_provider(&openrouter) From f868734f2780998da9d1ec4e7f36fff989001027 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 13:18:12 -0400 Subject: [PATCH 22/42] Fix stale MCP servers after upgrades --- Cargo.lock | 1 + lib/apps/fabro-cli/src/commands/mcp/mod.rs | 12 +- lib/apps/fabro-cli/tests/it/cmd/mcp.rs | 180 ++++++++++++++++- lib/apps/fabro-mcp-server/Cargo.toml | 3 +- .../src/executable_monitor.rs | 190 ++++++++++++++++++ lib/apps/fabro-mcp-server/src/lib.rs | 3 +- lib/apps/fabro-mcp-server/src/server.rs | 54 ++++- 7 files changed, 425 insertions(+), 18 deletions(-) create mode 100644 lib/apps/fabro-mcp-server/src/executable_monitor.rs diff --git a/Cargo.lock b/Cargo.lock index a485c5cc4..5ae6ec28d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2838,6 +2838,7 @@ dependencies = [ "fabro-manifest", "fabro-model", "fabro-server", + "fabro-static", "fabro-tool", "fabro-types", "fabro-util", diff --git a/lib/apps/fabro-cli/src/commands/mcp/mod.rs b/lib/apps/fabro-cli/src/commands/mcp/mod.rs index f6a64a0ca..ef7233a66 100644 --- a/lib/apps/fabro-cli/src/commands/mcp/mod.rs +++ b/lib/apps/fabro-cli/src/commands/mcp/mod.rs @@ -1,6 +1,8 @@ use std::fmt::Write as _; +use std::process; use anyhow::{Context as _, Result}; +use fabro_mcp_server::McpServerExit; use crate::args::{McpAgent, McpCommand, McpNamespace, ServerConnectionArgs}; use crate::command_context::CommandContext; @@ -9,7 +11,15 @@ use crate::server_client; pub(crate) async fn dispatch(ns: McpNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { McpCommand::Start(args) => { - fabro_mcp_server::start(server_settings(base_ctx, &args.connection)?).await + let exit = + fabro_mcp_server::start(server_settings(base_ctx, &args.connection)?).await?; + if exit == McpServerExit::ExecutableReplaced { + // Tokio's stdin worker can remain blocked after the MCP service + // closes. Exit at the CLI boundary so the host can reconnect to + // the replacement executable. + process::exit(0); + } + Ok(()) } McpCommand::Config(args) => { let json = fabro_mcp_server::config_json(&config_settings(&args.connection))?; diff --git a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs index dc1052ac0..1d2ed68d2 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs @@ -8,16 +8,20 @@ )] use std::collections::HashMap; +#[cfg(unix)] +use std::fs; use std::io::{BufRead as _, Write as _}; use std::path::{Path, PathBuf}; -use std::process::Stdio; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject}; use fabro_mcp::client::McpClient; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context}; -use fabro_types::RunId; +use fabro_types::{Graph, RunId, WorkflowSettings, test_support}; use httpmock::Method::{GET, POST}; use httpmock::MockServer; @@ -515,7 +519,7 @@ async fn stdio_server_initializes_and_lists_run_tools() { fn stdio_start_writes_only_json_rpc_to_stdout() { let context = test_context!(); let fixture = mcp_stdio_fixture(&context, &[]); - let mut cmd = std::process::Command::new(&fixture.command[0]); + let mut cmd = Command::new(&fixture.command[0]); cmd.args(&fixture.command[1..]) .env_clear() .envs(&fixture.env) @@ -534,31 +538,96 @@ fn stdio_start_writes_only_json_rpc_to_stdout() { let stdout = child.stdout.take().unwrap(); let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { + thread::spawn(move || { let mut line = String::new(); let result = std::io::BufReader::new(stdout).read_line(&mut line); let _ = tx.send(result.map(|_| line)); }); let line = rx - .recv_timeout(std::time::Duration::from_secs(5)) + .recv_timeout(Duration::from_secs(5)) .expect("initialize response should arrive") .expect("stdout should be readable"); let value: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); assert_eq!(value["jsonrpc"], "2.0"); + assert_eq!(value["result"]["serverInfo"]["name"], "fabro"); + assert_eq!( + value["result"]["serverInfo"]["version"], + env!("CARGO_PKG_VERSION") + ); let _ = child.kill(); let _ = child.wait(); } +#[cfg(unix)] +#[test] +fn stdio_server_exits_when_executable_is_replaced() { + let context = test_context!(); + let fixture = mcp_stdio_fixture(&context, &[]); + let directory = tempfile::tempdir().expect("replacement directory should exist"); + let executable = directory.path().join("fabro"); + fs::copy(&fixture.command[0], &executable).expect("Fabro executable should be copied"); + let mut cmd = Command::new(&executable); + cmd.args(&fixture.command[1..]) + .env_clear() + .envs(&fixture.env) + .current_dir(&fixture.current_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().expect("MCP server should start"); + let mut stdin = child.stdin.take().expect("MCP stdin should be available"); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18","capabilities":{{}},"clientInfo":{{"name":"fabro-test","version":"0.0.0"}}}}}}"# + ) + .expect("initialize request should be written"); + + let stdout = child.stdout.take().expect("MCP stdout should be available"); + let (tx, rx) = std::sync::mpsc::channel(); + thread::spawn(move || { + let mut line = String::new(); + let result = std::io::BufReader::new(stdout).read_line(&mut line); + let _ = tx.send(result.map(|_| line)); + }); + let response = rx + .recv_timeout(Duration::from_secs(5)) + .expect("initialize response should arrive") + .expect("MCP stdout should be readable"); + let response: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); + assert_eq!(response["result"]["serverInfo"]["name"], "fabro"); + + // Keep stdin open so replacement, rather than EOF, stops the server. + let replacement = directory.path().join("fabro-replacement"); + fs::write(&replacement, b"replacement").expect("replacement file should be written"); + fs::rename(replacement, &executable).expect("Fabro executable should be replaced"); + + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = child.try_wait().expect("MCP server should be polled") { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("MCP server did not exit after its executable was replaced"); + } + thread::sleep(Duration::from_millis(50)); + }; + + assert!(status.success(), "MCP server should exit successfully"); +} + #[tokio::test(flavor = "multi_thread")] async fn stdio_startup_and_list_tools_is_fast() { let context = test_context!(); - let start = std::time::Instant::now(); + let start = Instant::now(); let client = spawn_mcp_client(&context, &[]).await; let tools = client.list_tools().await.unwrap(); assert_eq!(tools.len(), MCP_RUN_TOOL_NAMES.len()); - assert!(start.elapsed() < std::time::Duration::from_secs(2)); + assert!(start.elapsed() < Duration::from_secs(2)); client .shutdown() .await @@ -1602,12 +1671,21 @@ async fn mcp_get_resolves_selector_and_returns_summary_projection_and_questions( let projection = server.mock(|when, then| { when.method(GET) .path(format!("/api/v1/runs/{run_id}/state")); + let mut body = run_projection_json(&run_id, &serde_json::json!({ "kind": "running" })); + body["spec"]["settings"]["run"]["model"] = serde_json::json!({ + "provider": "openai", + "name": "gpt-5.6-sol", + "fallbacks": { + "gpt-5.6-sol": ["gpt-5.6-terra"] + }, + "controls": { + "reasoning_effort": null, + "speed": null + } + }); then.status(200) .header("Content-Type", "application/json") - .json_body(run_projection_json( - &run_id, - &serde_json::json!({ "kind": "running" }), - )); + .json_body(body); }); let questions = server.mock(|when, then| { when.method(GET) @@ -1644,6 +1722,10 @@ async fn mcp_get_resolves_selector_and_returns_summary_projection_and_questions( assert_eq!(get["summary"]["workflow_name"], "Simple"); assert_eq!(get["summary"]["workflow_slug"], "simple"); assert_eq!(get["projection"]["status"]["kind"], "running"); + assert_eq!( + get["projection"]["spec"]["settings"]["run"]["model"]["fallbacks"]["gpt-5.6-sol"][0], + "gpt-5.6-terra" + ); assert_eq!(get["questions"][0]["id"], "q-1"); resolve.assert(); retrieve.assert(); @@ -2001,6 +2083,82 @@ async fn mcp_events_filters_find_matches_beyond_first_page() { .expect("MCP client should shut down"); } +#[tokio::test(flavor = "multi_thread")] +async fn mcp_events_decodes_run_created_with_model_keyed_fallbacks() { + let context = test_context!(); + let server = MockServer::start(); + let target_url = format!("{}/api/v1", server.base_url()); + let target: fabro_client::ServerTarget = target_url.parse().unwrap(); + seed_dev_token_auth(&context.home_dir, &target, TEST_DEV_TOKEN); + let run_id = unique_run_id(); + let resolve = mock_resolved_run(&server, "nightly", &run_id); + let mut settings = serde_json::to_value(WorkflowSettings::default()) + .expect("workflow settings should serialize"); + settings["run"]["model"] = serde_json::json!({ + "provider": "openai", + "name": "gpt-5.6-sol", + "fallbacks": { + "gpt-5.6-sol": ["gpt-5.6-terra"] + }, + "controls": { + "reasoning_effort": null, + "speed": null + } + }); + let event = serde_json::json!({ + "seq": 1, + "id": "evt-created", + "ts": "2026-04-05T12:00:00Z", + "run_id": run_id, + "event": "run.created", + "properties": { + "settings": settings, + "graph": Graph::new("Remote Workflow"), + "labels": {}, + "run_dir": "/tmp/run", + "source_directory": "/srv/repo", + "provenance": test_support::test_run_provenance() + }, + "actor": null + }); + let events = server.mock(|when, then| { + when.method(GET) + .path(format!("/api/v1/runs/{run_id}/events")) + .query_param_missing("limit"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "data": [event], + "meta": { "has_more": false } + })); + }); + let client = spawn_mcp_client(&context, &["--server", &target_url]).await; + + let result = call_tool_json( + &client, + "fabro_run_events", + serde_json::json!({ + "run_id": "nightly", + "action": "search", + "query": "gpt-5.6-terra", + "first": 1 + }), + ) + .await; + + assert_eq!( + result["events"][0]["event"]["properties"]["settings"]["run"]["model"]["fallbacks"]["gpt-5.6-sol"] + [0], + "gpt-5.6-terra" + ); + resolve.assert(); + events.assert(); + client + .shutdown() + .await + .expect("MCP client should shut down"); +} + #[tokio::test(flavor = "multi_thread")] async fn mcp_events_requires_action_specific_inputs_before_auth() { let context = test_context!(); diff --git a/lib/apps/fabro-mcp-server/Cargo.toml b/lib/apps/fabro-mcp-server/Cargo.toml index cdbf6b65f..da0b9978b 100644 --- a/lib/apps/fabro-mcp-server/Cargo.toml +++ b/lib/apps/fabro-mcp-server/Cargo.toml @@ -21,6 +21,7 @@ fabro-manifest = { path = "../../components/fabro-manifest" } fabro-config = { path = "../../foundation/fabro-config" } fabro-model = { path = "../../foundation/fabro-model" } fabro-server = { path = "../fabro-server" } +fabro-static = { path = "../../foundation/fabro-static" } fabro-tool = { path = "../../components/fabro-tool" } fabro-types = { path = "../../foundation/fabro-types" } fabro-util = { path = "../../foundation/fabro-util" } @@ -35,4 +36,4 @@ toml.workspace = true [dev-dependencies] httpmock = "0.8" -tempfile = "3" \ No newline at end of file +tempfile = "3" diff --git a/lib/apps/fabro-mcp-server/src/executable_monitor.rs b/lib/apps/fabro-mcp-server/src/executable_monitor.rs new file mode 100644 index 000000000..9ba92149b --- /dev/null +++ b/lib/apps/fabro-mcp-server/src/executable_monitor.rs @@ -0,0 +1,190 @@ +//! Detects when an upgrade replaces the executable that launched this MCP +//! server. +//! +//! MCP hosts can keep stdio servers alive for days. Without this check, an old +//! process keeps its old API response decoder after the `fabro` file on disk is +//! upgraded. + +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; +use std::{env, io}; + +use fabro_static::EnvVars; +use tokio::time::Instant; +use tokio::{fs, time}; + +const CHECK_INTERVAL: Duration = Duration::from_secs(1); + +pub(crate) struct ExecutableMonitor { + path: PathBuf, + identity: ExecutableIdentity, +} + +impl ExecutableMonitor { + pub(crate) async fn current() -> io::Result { + let path = invoked_executable_path().await?; + Self::new(path).await + } + + async fn new(path: PathBuf) -> io::Result { + let identity = ExecutableIdentity::from_metadata(&fs::metadata(&path).await?); + Ok(Self { path, identity }) + } + + pub(crate) async fn wait_until_replaced(self) { + let mut interval = time::interval_at(Instant::now() + CHECK_INTERVAL, CHECK_INTERVAL); + loop { + interval.tick().await; + if self.was_replaced().await { + return; + } + } + } + + async fn was_replaced(&self) -> bool { + fs::metadata(&self.path).await.map_or(true, |metadata| { + ExecutableIdentity::from_metadata(&metadata) != self.identity + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExecutableIdentity { + len: u64, + modified: Option, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, +} + +impl ExecutableIdentity { + fn from_metadata(metadata: &std::fs::Metadata) -> Self { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt as _; + + Self { + len: metadata.len(), + modified: metadata.modified().ok(), + #[cfg(unix)] + device: metadata.dev(), + #[cfg(unix)] + inode: metadata.ino(), + } + } +} + +#[expect( + clippy::disallowed_methods, + reason = "MCP startup resolves its invoked executable through the process PATH so it can detect Homebrew symlink updates" +)] +async fn invoked_executable_path() -> io::Result { + let invoked = env::args_os() + .next() + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "process argv[0] is unavailable"))?; + + if invoked.components().count() > 1 { + return absolute_path(invoked); + } + + if let Some(path) = env::var_os(EnvVars::PATH) { + for directory in env::split_paths(&path) { + let candidate = absolute_path(directory.join(&invoked))?; + if fs::metadata(&candidate) + .await + .is_ok_and(|metadata| is_executable_file(&metadata)) + { + return Ok(candidate); + } + } + } + + env::current_exe() +} + +fn absolute_path(path: PathBuf) -> io::Result { + if path.is_absolute() { + Ok(path) + } else { + env::current_dir().map(|cwd| cwd.join(path)) + } +} + +fn is_executable_file(metadata: &std::fs::Metadata) -> bool { + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unchanged_executable_is_current() { + let directory = tempfile::tempdir().expect("temp directory should exist"); + let executable = directory.path().join("fabro"); + fs::write(&executable, b"current") + .await + .expect("fixture executable should be written"); + let monitor = ExecutableMonitor::new(executable).await.unwrap(); + + assert!(!monitor.was_replaced().await); + } + + #[tokio::test] + async fn atomic_executable_replacement_is_detected() { + let directory = tempfile::tempdir().expect("temp directory should exist"); + let executable = directory.path().join("fabro"); + let replacement = directory.path().join("fabro-new"); + fs::write(&executable, b"old") + .await + .expect("old fixture executable should be written"); + fs::write(&replacement, b"new executable") + .await + .expect("new fixture executable should be written"); + let monitor = ExecutableMonitor::new(executable.clone()).await.unwrap(); + + fs::rename(&replacement, &executable) + .await + .expect("fixture executable should be replaced"); + + assert!(monitor.was_replaced().await); + } + + #[tokio::test] + async fn removed_executable_is_detected() { + let directory = tempfile::tempdir().expect("temp directory should exist"); + let executable = directory.path().join("fabro"); + fs::write(&executable, b"current") + .await + .expect("fixture executable should be written"); + let monitor = ExecutableMonitor::new(executable.clone()).await.unwrap(); + + fs::remove_file(executable) + .await + .expect("fixture executable should be removed"); + + assert!(monitor.was_replaced().await); + } + + #[test] + fn executable_check_rejects_directories() { + let directory = tempfile::tempdir().expect("temp directory should exist"); + let metadata = std::fs::metadata(directory.path()).unwrap(); + + assert!(!is_executable_file(&metadata)); + } +} diff --git a/lib/apps/fabro-mcp-server/src/lib.rs b/lib/apps/fabro-mcp-server/src/lib.rs index ad6264dcb..7aa598e80 100644 --- a/lib/apps/fabro-mcp-server/src/lib.rs +++ b/lib/apps/fabro-mcp-server/src/lib.rs @@ -1,4 +1,5 @@ mod config; +mod executable_monitor; mod manifest_builder; mod server; @@ -10,7 +11,7 @@ use std::sync::Arc; use anyhow::Result; pub use config::{config_json, init_agent}; use fabro_client::Client; -pub use server::start; +pub use server::{McpServerExit, start}; pub type FabroClientFuture = Pin> + Send>>; diff --git a/lib/apps/fabro-mcp-server/src/server.rs b/lib/apps/fabro-mcp-server/src/server.rs index c1b932308..7fdc64d2e 100644 --- a/lib/apps/fabro-mcp-server/src/server.rs +++ b/lib/apps/fabro-mcp-server/src/server.rs @@ -4,15 +4,17 @@ use std::sync::Arc; use anyhow::Result; use fabro_tool::fabro_client::ClientBackend; use fabro_tool::{self as run_tools, FabroToolBackend}; +use fabro_util::version::FABRO_VERSION; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; +use rmcp::model::{CallToolResult, Content, Implementation, 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; +use crate::executable_monitor::ExecutableMonitor; use crate::manifest_builder::McpRunManifestBuilder; #[derive(Clone)] @@ -23,17 +25,45 @@ pub(crate) struct FabroMcpServer { tool_router: ToolRouter, } -pub async fn start(settings: FabroMcpServerSettings) -> Result<()> { +/// The reason a running MCP stdio server returned to its caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpServerExit { + /// The MCP service stopped without an executable replacement. + ServiceStopped, + /// The executable on disk changed while the MCP service was running. + ExecutableReplaced, +} + +pub async fn start(settings: FabroMcpServerSettings) -> Result { + let executable_monitor = ExecutableMonitor::current().await.ok(); let server = FabroMcpServer::new(Arc::new(settings)); let service = serve_server(server, stdio()).await?; - service.waiting().await?; - Ok(()) + let exit = if let Some(executable_monitor) = executable_monitor { + let cancellation = service.cancellation_token(); + let mut service_wait = Box::pin(service.waiting()); + tokio::select! { + result = &mut service_wait => { + result?; + McpServerExit::ServiceStopped + } + () = executable_monitor.wait_until_replaced() => { + cancellation.cancel(); + service_wait.await?; + McpServerExit::ExecutableReplaced + } + } + } else { + service.waiting().await?; + McpServerExit::ServiceStopped + }; + Ok(exit) } #[tool_handler(router = self.tool_router)] impl ServerHandler for FabroMcpServer { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("fabro", FABRO_VERSION).with_title("Fabro")) .with_instructions("Use these tools to create, inspect, control, wait for, and read events from Fabro workflow runs.") } } @@ -251,6 +281,22 @@ mod tests { use super::*; use crate::FabroMcpServerSettings; + #[test] + fn server_info_reports_fabro_version() { + let settings = FabroMcpServerSettings { + cwd: PathBuf::from("."), + config_path: PathBuf::from("fabro.toml"), + client_factory: Arc::new(|| { + Box::pin(async { panic!("client should not be constructed while reading info") }) + }), + }; + let info = FabroMcpServer::new(Arc::new(settings)).get_info(); + + assert_eq!(info.server_info.name, "fabro"); + assert_eq!(info.server_info.title.as_deref(), Some("Fabro")); + assert_eq!(info.server_info.version, FABRO_VERSION); + } + #[test] fn fabro_run_pair_tool_is_registered_with_stage_based_schema() { let settings = FabroMcpServerSettings { From bf347b3e0dbb08569f234235d467192a1e00f107 Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 19:22:19 +0000 Subject: [PATCH 23/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): toolchain (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 2 Fabro-Checkpoint: f5ac83dc54e01652c9db95b17f75afc449002e0c ⚒️ Generated with [Fabro](https://fabro.sh) From ffc26328ee7f169d5fc036b6acaa1f1d967d62df Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 19:24:14 +0000 Subject: [PATCH 24/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): preflight_compile (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 3 Fabro-Checkpoint: 9b9950dd2ddafca90f11fbe6c3df1acb1ef3fa17 ⚒️ Generated with [Fabro](https://fabro.sh) From 0815f9fa8f32083b44e65de03b7138ce555fb913 Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 19:26:21 +0000 Subject: [PATCH 25/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): preflight_lint (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 4 Fabro-Checkpoint: 31498930566e09d9f56902b5137491a33c6c711f ⚒️ Generated with [Fabro](https://fabro.sh) From ac6e3ced6adf252f7aea435d5286c9853d8a4ea8 Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 22:27:05 +0000 Subject: [PATCH 26/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): implement (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 5 Fabro-Checkpoint: 879008d9d587ff62ea2633980529d51587e11dbc ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/apps/fabro-server/src/lib.rs | 1 + lib/apps/fabro-server/src/run_compiler.rs | 1105 +++++++++++++++++ lib/apps/fabro-server/src/run_manifest.rs | 56 +- .../fabro-server/src/server/handler/runs.rs | 255 +++- lib/apps/fabro-server/src/server/tests.rs | 243 ++++ .../fabro-workflow/src/operations/create.rs | 835 ++++++++++--- .../fabro-workflow/src/operations/mod.rs | 7 +- 7 files changed, 2257 insertions(+), 245 deletions(-) create mode 100644 lib/apps/fabro-server/src/run_compiler.rs diff --git a/lib/apps/fabro-server/src/lib.rs b/lib/apps/fabro-server/src/lib.rs index 5fc735ba0..9a7e12978 100644 --- a/lib/apps/fabro-server/src/lib.rs +++ b/lib/apps/fabro-server/src/lib.rs @@ -34,6 +34,7 @@ pub mod manifest_validation; mod migrations; mod principal_middleware; mod request_id; +mod run_compiler; mod run_files; mod run_files_security; mod run_manifest; diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs new file mode 100644 index 000000000..341c4565c --- /dev/null +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -0,0 +1,1105 @@ +use std::collections::HashMap; +use std::error::Error as StdError; +use std::path::PathBuf; +use std::sync::Arc; + +use fabro_config::parse::{self, SettingsSource}; +use fabro_config::{ + CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, MergeMap, + RunLayer, SettingsLayer, WorkflowSettingsBuilder, +}; +use fabro_model::{Catalog, ModelSelectionError, ProviderId}; +use fabro_types::settings::AmbiguousModelRef; +use fabro_types::settings::interp::{InterpString, ResolveError}; +use fabro_types::settings::run::{McpServerSettings, RunGoal}; +use fabro_types::{ + AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings, +}; +use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError}; +use fabro_workflow::Error as WorkflowError; +use fabro_workflow::operations::{ + self, CompiledRun, CreateRunCompileInput, CreateRunPersistenceInput, + CreateRunPersistenceMetadata, MaterializedRun, WorkflowInput, +}; +use fabro_workflow::workflow_bundle::{BundledWorkflow, WorkflowBundle}; +use tokio::task; + +/// One project settings source already normalized into the manifest path +/// namespace used by the workflow bundle. +#[derive(Clone, Debug)] +pub(crate) struct ProjectSettingsSource { + pub(crate) path: ManifestPath, + pub(crate) toml: String, +} + +/// Transport-neutral inputs for compiling one submitted run. +/// +/// IDs, title, git metadata, and provenance are resolved by the caller. This +/// boundary owns only source normalization, settings resolution, workflow +/// compilation, materialization, and persistence-input assembly. +#[derive(Clone, Debug)] +pub(crate) struct RawRunCompilerInput { + pub(crate) workflow_bundle: WorkflowBundle, + pub(crate) entrypoint: ManifestPath, + pub(crate) cwd: PathBuf, + pub(crate) server_run_defaults: RunLayer, + pub(crate) server_environment_defaults: MergeMap, + pub(crate) server_mcp_catalog: HashMap, + pub(crate) project_settings: Vec, + pub(crate) user_toml: Vec, + pub(crate) run_overrides: Option, + pub(crate) cli_overrides: Option, + pub(crate) input_overrides: HashMap, + pub(crate) inline_goal_override: Option, + pub(crate) vars: HashMap, + pub(crate) run_id: Option, + pub(crate) title: Option, + pub(crate) parent_id: Option, + pub(crate) git: Option, + pub(crate) storage_root: PathBuf, + pub(crate) configured_providers: Vec, + pub(crate) workflow_slug: Option, + pub(crate) provenance: RunProvenance, + pub(crate) web_url: Option, + pub(crate) submitted_manifest_bytes: Option>, + pub(crate) automation: Option, +} + +#[derive(Clone, Debug)] +struct RunMetadata { + run_id: Option, + title: Option, + parent_id: Option, + git: Option, + storage_root: PathBuf, + workflow_slug: Option, + provenance: RunProvenance, + web_url: Option, + submitted_manifest_bytes: Option>, + automation: Option, +} + +/// Stage-one output: the selected bundled workflow and all client settings +/// sources have been parsed and normalized, but no settings have been layered. +pub(crate) struct NormalizedRun { + workflow_bundle: WorkflowBundle, + entrypoint: ManifestPath, + workflow: BundledWorkflow, + workflow_layer: Option, + project_layers: Vec, + user_toml: Vec, + cwd: PathBuf, + server_run_defaults: RunLayer, + server_environment_defaults: MergeMap, + server_mcp_catalog: HashMap, + run_overrides: Option, + cli_overrides: Option, + input_overrides: HashMap, + inline_goal_override: Option, + vars: HashMap, + configured_providers: Vec, + metadata: RunMetadata, +} + +/// Settings-layered output. Variable substitution is intentionally separate +/// so callers can snapshot variables after source/settings preparation, as the +/// create handler historically does. +pub(crate) struct LayeredRun { + workflow_bundle: WorkflowBundle, + entrypoint: ManifestPath, + workflow: BundledWorkflow, + settings: WorkflowSettings, + cwd: PathBuf, + vars: HashMap, + configured_providers: Vec, + metadata: RunMetadata, +} + +impl LayeredRun { + pub(crate) fn with_vars(mut self, vars: HashMap) -> Self { + self.vars = vars; + self + } +} + +/// Settings-resolved stage output. Callers may inspect this before policy +/// checks, then move it into [`compile_graph`] after those checks pass. +pub(crate) struct PreparedRun { + workflow_bundle: WorkflowBundle, + entrypoint: ManifestPath, + workflow: BundledWorkflow, + settings: WorkflowSettings, + cwd: PathBuf, + vars: HashMap, + configured_providers: Vec, + metadata: RunMetadata, +} + +impl PreparedRun { + pub(crate) fn resolve_run_id(mut self) -> (Self, RunId) { + let run_id = self.metadata.run_id.unwrap_or_default(); + self.metadata.run_id = Some(run_id); + (self, run_id) + } + + pub(crate) fn with_web_url(mut self, web_url: Option) -> Self { + self.metadata.web_url = web_url; + self + } + + pub(crate) fn with_configured_providers( + mut self, + configured_providers: Vec, + ) -> Self { + self.configured_providers = configured_providers; + self + } + + pub(crate) fn settings(&self) -> &WorkflowSettings { + &self.settings + } + + pub(crate) fn parent_id(&self) -> Option { + self.metadata.parent_id + } +} + +/// Graph-compiled stage output, retaining the metadata needed by later pure +/// assembly. +pub(crate) struct GraphCompiledRun { + compiled: CompiledRun, + entrypoint: ManifestPath, + metadata: RunMetadata, +} + +impl GraphCompiledRun { + #[cfg(test)] + pub(crate) fn compiled(&self) -> &CompiledRun { + &self.compiled + } +} + +/// Materialized stage output ready for pure persistence-input assembly. +pub(crate) struct PersistenceReadyRun { + materialized: MaterializedRun, + entrypoint: ManifestPath, + metadata: RunMetadata, +} + +/// Complete output of this boundary. +pub(crate) struct RunCompilerOutput { + persistence_input: CreateRunPersistenceInput, + entrypoint: ManifestPath, +} + +impl RunCompilerOutput { + #[cfg(test)] + pub(crate) fn persistence_input(&self) -> &CreateRunPersistenceInput { + &self.persistence_input + } + + #[cfg(test)] + pub(crate) fn entrypoint(&self) -> &ManifestPath { + &self.entrypoint + } + + pub(crate) fn into_parts(self) -> (CreateRunPersistenceInput, ManifestPath) { + (self.persistence_input, self.entrypoint) + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum RunCompilerError { + #[error("invalid run source: {source}")] + InvalidSource { + #[source] + source: InvalidSourceError, + }, + + #[error("invalid run settings: {source}")] + InvalidSettings { + #[source] + source: Box, + }, + + #[error("run config variable interpolation failed: {source}")] + VariableInterpolation { + #[source] + source: VariableInterpolationError, + }, + + #[error("workflow validation or parse failed: {source}")] + ValidationOrParse { + #[source] + source: WorkflowError, + }, + + #[error("model selection failed: {source}")] + ModelSelection { + #[source] + source: ModelSelectionError, + }, + + #[error("model reference failed: {source}")] + ModelReference { + #[source] + source: AmbiguousModelRef, + }, + + #[error("{context}")] + Internal { + context: &'static str, + #[source] + source: Box, + }, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum InvalidSourceError { + #[error("bundle entrypoint {entrypoint} is missing from the workflow bundle")] + MissingEntrypoint { entrypoint: ManifestPath }, + + #[error("unsupported dockerfile reference {reference:?} in {config_path}")] + UnsupportedDockerfileReference { + config_path: ManifestPath, + reference: String, + }, + + #[error("bundled dockerfile {dockerfile_path} referenced by {config_path} is missing")] + MissingDockerfile { + config_path: ManifestPath, + dockerfile_path: ManifestPath, + }, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum InvalidSettingsError { + #[error("failed to parse {kind} settings at {path}")] + Parse { + kind: &'static str, + path: ManifestPath, + #[source] + source: Box, + }, + + #[error("failed to parse user settings: {source}")] + User { + #[source] + source: fabro_config::Error, + }, + + #[error("failed to resolve layered workflow settings: {source}")] + Resolve { + #[source] + source: fabro_config::ResolveErrors, + }, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum VariableInterpolationError { + #[error(transparent)] + Interpolation(#[from] ResolveError), + + #[error("run.artifacts.include[{index}]: {source}")] + ArtifactGlob { + index: usize, + #[source] + source: WorkspaceGlobError, + }, +} + +pub(crate) type Result = std::result::Result; + +/// Normalize the bundle entrypoint and parse workflow/project settings while +/// resolving Dockerfile references against the selected workflow's files. +pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result { + let RawRunCompilerInput { + workflow_bundle, + entrypoint, + cwd, + server_run_defaults, + server_environment_defaults, + server_mcp_catalog, + project_settings, + user_toml, + run_overrides, + cli_overrides, + input_overrides, + inline_goal_override, + vars, + run_id, + title, + parent_id, + git, + storage_root, + configured_providers, + workflow_slug, + provenance, + web_url, + submitted_manifest_bytes, + automation, + } = input; + let mut workflow = workflow_bundle + .workflow(&entrypoint) + .cloned() + .ok_or_else(|| RunCompilerError::InvalidSource { + source: InvalidSourceError::MissingEntrypoint { + entrypoint: entrypoint.clone(), + }, + })?; + workflow.path = entrypoint.clone(); + + let workflow_layer = workflow + .config + .as_ref() + .map(|config| { + parse_settings_layer( + &config.source, + &config.path, + &workflow.files, + SettingsSource::Workflow, + "workflow", + ) + }) + .transpose()?; + let project_layers = project_settings + .into_iter() + .map(|project| { + parse_settings_layer( + &project.toml, + &project.path, + &workflow.files, + SettingsSource::Project, + "project", + ) + }) + .collect::>>()?; + + Ok(NormalizedRun { + workflow_bundle, + entrypoint, + workflow, + workflow_layer, + project_layers, + user_toml, + cwd, + server_run_defaults, + server_environment_defaults, + server_mcp_catalog, + run_overrides, + cli_overrides, + input_overrides, + inline_goal_override, + vars, + configured_providers, + metadata: RunMetadata { + run_id, + title, + parent_id, + git, + storage_root, + workflow_slug, + provenance, + web_url, + submitted_manifest_bytes, + automation, + }, + }) +} + +/// Layer settings and apply the submitted input and goal overrides. +pub(crate) fn layer_settings(normalized: NormalizedRun) -> Result { + let NormalizedRun { + workflow_bundle, + entrypoint, + workflow, + workflow_layer, + project_layers, + user_toml, + cwd, + server_run_defaults, + server_environment_defaults, + server_mcp_catalog, + run_overrides, + cli_overrides, + input_overrides, + inline_goal_override, + vars, + configured_providers, + metadata, + } = normalized; + let mut builder = WorkflowSettingsBuilder::new() + .server_manifest_defaults(server_run_defaults, server_environment_defaults) + .server_mcp_catalog(server_mcp_catalog); + if let Some(run) = run_overrides { + builder = builder.run_overrides(run); + } + if let Some(cli) = cli_overrides { + builder = builder.cli_overrides(cli); + } + if let Some(layer) = workflow_layer { + builder = builder.workflow_layer(layer); + } + for layer in project_layers { + builder = builder.project_layer(layer); + } + for source in user_toml { + builder = + builder + .user_toml(&source) + .map_err(|source| RunCompilerError::InvalidSettings { + source: Box::new(InvalidSettingsError::User { source }), + })?; + } + let mut settings = builder + .build() + .map_err(|source| RunCompilerError::InvalidSettings { + source: Box::new(InvalidSettingsError::Resolve { source }), + })?; + settings.run.inputs.extend(input_overrides); + if let Some(goal) = inline_goal_override { + settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal))); + } + + Ok(LayeredRun { + workflow_bundle, + entrypoint, + workflow, + settings, + cwd, + vars, + configured_providers, + metadata, + }) +} + +/// Apply a run-variable snapshot and validate the resulting artifact globs. +pub(crate) fn apply_run_variables(mut layered: LayeredRun) -> Result { + substitute_variables(&layered.vars, &mut layered.settings)?; + Ok(PreparedRun { + workflow_bundle: layered.workflow_bundle, + entrypoint: layered.entrypoint, + workflow: layered.workflow, + settings: layered.settings, + cwd: layered.cwd, + vars: layered.vars, + configured_providers: layered.configured_providers, + metadata: layered.metadata, + }) +} + +/// Run stage one and the settings/variables portion of stage two. This is the +/// convenient boundary for callers that already own a variable snapshot. +#[cfg(test)] +pub(crate) fn prepare_run(input: RawRunCompilerInput) -> Result { + apply_run_variables(layer_settings(normalize_source(input)?)?) +} + +/// Compile and validate the graph on Tokio's blocking pool. +pub(crate) async fn compile_graph( + prepared: PreparedRun, + catalog: Arc, +) -> Result { + let PreparedRun { + workflow_bundle, + entrypoint, + workflow, + settings, + cwd, + vars, + configured_providers, + metadata, + } = prepared; + let compile_input = CreateRunCompileInput { + workflow: WorkflowInput::Bundled(workflow), + settings, + vars, + cwd, + workflow_path: Some(entrypoint.clone()), + workflow_bundle: Some(workflow_bundle), + configured_providers, + }; + let compiled = + task::spawn_blocking(move || operations::compile_create_run(compile_input, catalog)) + .await + .map_err(|source| RunCompilerError::Internal { + context: "workflow compilation failed", + source: Box::new(WorkflowError::engine_with_source( + "workflow create task failed", + source, + )), + })? + .map_err(classify_workflow_error)?; + + Ok(GraphCompiledRun { + compiled, + entrypoint, + metadata, + }) +} + +/// Materialize run-level model settings on Tokio's blocking pool. +pub(crate) async fn materialize_run( + compiled: GraphCompiledRun, + catalog: Arc, +) -> Result { + let GraphCompiledRun { + compiled, + entrypoint, + metadata, + } = compiled; + let materialized = task::spawn_blocking(move || { + operations::materialize_create_run(compiled, catalog.as_ref()) + }) + .await + .map_err(|source| RunCompilerError::Internal { + context: "workflow compilation failed", + source: Box::new(WorkflowError::engine_with_source( + "workflow create task failed", + source, + )), + })? + .map_err(classify_workflow_error)?; + + Ok(PersistenceReadyRun { + materialized, + entrypoint, + metadata, + }) +} + +/// Purely assemble the complete workflow persistence input. +pub(crate) fn assemble_run(ready: PersistenceReadyRun) -> RunCompilerOutput { + let PersistenceReadyRun { + materialized, + entrypoint, + metadata, + } = ready; + let RunMetadata { + run_id, + title, + parent_id, + git, + storage_root, + workflow_slug, + provenance, + web_url, + submitted_manifest_bytes, + automation, + } = metadata; + let persistence_input = operations::assemble_create_run_persistence_input( + materialized, + CreateRunPersistenceMetadata { + run_id: run_id.unwrap_or_default(), + storage_root, + workflow_slug, + submitted_manifest_bytes, + title, + automation, + git, + fork_source_ref: None, + parent_id, + provenance, + web_url, + }, + ); + + RunCompilerOutput { + persistence_input, + entrypoint, + } +} + +/// Compile a raw run all the way to a complete persistence input. +#[cfg(test)] +pub(crate) async fn compile_run( + input: RawRunCompilerInput, + catalog: Arc, +) -> Result { + let prepared = prepare_run(input)?; + let compiled = compile_graph(prepared, Arc::clone(&catalog)).await?; + let materialized = materialize_run(compiled, catalog).await?; + Ok(assemble_run(materialized)) +} + +fn parse_settings_layer( + source: &str, + config_path: &ManifestPath, + files: &HashMap, + settings_source: SettingsSource, + kind: &'static str, +) -> Result { + let mut layer = + source + .parse::() + .map_err(|source| RunCompilerError::InvalidSettings { + source: Box::new(InvalidSettingsError::Parse { + kind, + path: config_path.clone(), + source: Box::new(source), + }), + })?; + parse::validate_settings_source(&layer, settings_source).map_err(|source| { + RunCompilerError::InvalidSettings { + source: Box::new(InvalidSettingsError::Parse { + kind, + path: config_path.clone(), + source: Box::new(source), + }), + } + })?; + resolve_dockerfiles(&mut layer, config_path, files)?; + Ok(layer) +} + +fn resolve_dockerfiles( + layer: &mut SettingsLayer, + config_path: &ManifestPath, + files: &HashMap, +) -> Result<()> { + for environment in layer.environments.values_mut() { + if let Some(image) = environment.image.as_mut() { + resolve_dockerfile(image, config_path, files)?; + } + } + if let Some(image) = layer + .run + .as_mut() + .and_then(|run| run.environment.as_mut()) + .and_then(|environment| environment.image.as_mut()) + { + resolve_dockerfile(image, config_path, files)?; + } + Ok(()) +} + +fn resolve_dockerfile( + image: &mut EnvironmentImageLayer, + config_path: &ManifestPath, + files: &HashMap, +) -> Result<()> { + let Some(EnvironmentDockerfileLayer::Path { path }) = image.dockerfile.as_ref() else { + return Ok(()); + }; + let reference = path.clone(); + let dockerfile_path = ManifestPath::from_reference(config_path.parent_or_dot(), &reference) + .ok_or_else(|| RunCompilerError::InvalidSource { + source: InvalidSourceError::UnsupportedDockerfileReference { + config_path: config_path.clone(), + reference: reference.clone(), + }, + })?; + let content = + files + .get(&dockerfile_path) + .cloned() + .ok_or_else(|| RunCompilerError::InvalidSource { + source: InvalidSourceError::MissingDockerfile { + config_path: config_path.clone(), + dockerfile_path: dockerfile_path.clone(), + }, + })?; + image.dockerfile = Some(EnvironmentDockerfileLayer::Inline(content)); + Ok(()) +} + +fn substitute_variables( + variables: &HashMap, + settings: &mut WorkflowSettings, +) -> Result<()> { + settings + .run + .substitute_variables(|name| variables.get(name).cloned()) + .map_err(|source| RunCompilerError::VariableInterpolation { + source: VariableInterpolationError::Interpolation(source), + })?; + for (index, pattern) in settings.run.artifacts.include.iter().enumerate() { + WorkspaceGlob::try_new(pattern).map_err(|source| { + RunCompilerError::VariableInterpolation { + source: VariableInterpolationError::ArtifactGlob { index, source }, + } + })?; + } + Ok(()) +} + +fn classify_workflow_error(error: WorkflowError) -> RunCompilerError { + match error { + WorkflowError::ModelSelection(source) => RunCompilerError::ModelSelection { source }, + WorkflowError::ModelReference(source) => RunCompilerError::ModelReference { source }, + source @ (WorkflowError::Parse(_) | WorkflowError::ValidationFailed { .. }) => { + RunCompilerError::ValidationOrParse { source } + } + source => RunCompilerError::Internal { + context: "workflow compilation failed", + source: Box::new(source), + }, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::error::Error as _; + use std::sync::Arc; + + use fabro_config::EnvironmentDockerfileLayer; + use fabro_graphviz::graph::AttrValue; + use fabro_model::Catalog; + use fabro_types::settings::interp::ResolveCtx; + use fabro_types::settings::run::RunGoal; + use fabro_types::{AutomationRef, Principal, RunProvenance, SystemActorKind}; + use fabro_workflow::workflow_bundle::ParsedWorkflowConfig; + + use super::*; + + const DOT: &str = r#"digraph Test { + graph [goal="Graph goal"] + start [shape=Mdiamond] + work [prompt="Ship {{ inputs.target }} for {{ vars.owner }}", model="gpt-5.4"] + exit [shape=Msquare] + start -> work -> exit + }"#; + + fn manifest_path(value: &str) -> ManifestPath { + ManifestPath::from_wire(value).expect("fixture manifest path should be valid") + } + + fn provenance() -> RunProvenance { + RunProvenance { + server: None, + client: None, + subject: Principal::System { + system_kind: SystemActorKind::Engine, + }, + } + } + + fn workflow( + entrypoint: &ManifestPath, + workflow_toml: Option<&str>, + files: HashMap, + ) -> BundledWorkflow { + BundledWorkflow { + path: entrypoint.clone(), + source: DOT.to_string(), + config: workflow_toml.map(|source| ParsedWorkflowConfig { + path: manifest_path("flows/workflow.toml"), + source: source.to_string(), + }), + files, + } + } + + fn raw_input( + workflow_toml: Option<&str>, + files: HashMap, + ) -> RawRunCompilerInput { + let entrypoint = manifest_path("flows/workflow.fabro"); + let workflow = workflow(&entrypoint, workflow_toml, files); + RawRunCompilerInput { + workflow_bundle: WorkflowBundle::new(HashMap::from([(entrypoint.clone(), workflow)])), + entrypoint, + cwd: PathBuf::from("/workspace"), + server_run_defaults: RunLayer::default(), + server_environment_defaults: fabro_environment::seeded_catalog_layer(), + server_mcp_catalog: HashMap::new(), + project_settings: Vec::new(), + user_toml: Vec::new(), + run_overrides: None, + cli_overrides: None, + input_overrides: HashMap::new(), + inline_goal_override: None, + vars: HashMap::new(), + run_id: Some(RunId::new()), + title: None, + parent_id: None, + git: None, + storage_root: PathBuf::from("/tmp/fabro-storage"), + configured_providers: Catalog::builtin().all_provider_ids().into_iter().collect(), + workflow_slug: None, + provenance: provenance(), + web_url: None, + submitted_manifest_bytes: None, + automation: None, + } + } + + #[test] + fn stage_one_rejects_missing_entrypoint() { + let mut input = raw_input(None, HashMap::new()); + input.entrypoint = manifest_path("flows/missing.fabro"); + + let Err(error) = normalize_source(input) else { + panic!("missing entrypoint should fail"); + }; + + assert!(matches!(error, RunCompilerError::InvalidSource { + source: InvalidSourceError::MissingEntrypoint { .. }, + })); + } + + #[test] + fn unresolved_run_id_is_allocated_only_after_variables_are_applied() { + let mut input = raw_input(None, HashMap::new()); + input.run_id = None; + let normalized = normalize_source(input).expect("source should normalize"); + let layered = layer_settings(normalized).expect("settings should layer"); + let prepared = apply_run_variables(layered).expect("variables should apply"); + + let (prepared, run_id) = prepared.resolve_run_id(); + + assert_eq!(prepared.metadata.run_id, Some(run_id)); + } + + #[test] + fn stage_one_rejects_missing_dockerfile_and_preserves_source_chain() { + let workflow_toml = r#" +_version = 1 + +[run.environment.image] +dockerfile = { path = "Dockerfile" } +"#; + + let Err(error) = normalize_source(raw_input(Some(workflow_toml), HashMap::new())) else { + panic!("missing dockerfile should fail"); + }; + + assert!(matches!(error, RunCompilerError::InvalidSource { + source: InvalidSourceError::MissingDockerfile { .. }, + })); + let source = error + .source() + .expect("top-level error should retain source"); + assert!(source.to_string().contains("Dockerfile")); + } + + #[test] + fn stage_one_resolves_bundled_dockerfile() { + let workflow_toml = r#" +_version = 1 + +[run.environment.image] +dockerfile = { path = "Dockerfile" } +"#; + let normalized = normalize_source(raw_input( + Some(workflow_toml), + HashMap::from([( + manifest_path("flows/Dockerfile"), + "FROM ubuntu:24.04\n".to_string(), + )]), + )) + .expect("bundled dockerfile should resolve"); + let dockerfile = normalized + .workflow_layer + .as_ref() + .and_then(|layer| layer.run.as_ref()) + .and_then(|run| run.environment.as_ref()) + .and_then(|environment| environment.image.as_ref()) + .and_then(|image| image.dockerfile.as_ref()); + + assert_eq!( + dockerfile, + Some(&EnvironmentDockerfileLayer::Inline( + "FROM ubuntu:24.04\n".to_string() + )) + ); + } + + #[test] + fn settings_apply_precedence_vars_inputs_and_safe_artifact_globs() { + let workflow_toml = r#" +_version = 1 + +[run.metadata] +layer = "workflow" +owner = "{{ vars.owner }}" + +[run.inputs] +target = "workflow" + +[run.artifacts] +include = ["reports/{{ vars.owner }}/*.json"] +"#; + let mut input = raw_input(Some(workflow_toml), HashMap::new()); + input.project_settings.push(ProjectSettingsSource { + path: manifest_path(".fabro/project.toml"), + toml: r#" +_version = 1 + +[run.metadata] +layer = "project" +"# + .to_string(), + }); + input.user_toml = vec![ + r#" +_version = 1 + +[run.metadata] +layer = "user" +"# + .to_string(), + ]; + input.run_overrides = Some( + toml::from_str::( + r#" +_version = 1 + +[run.metadata] +layer = "args" +owner = "{{ vars.owner }}" +"#, + ) + .expect("args settings should parse") + .run + .expect("args run layer should exist"), + ); + input.input_overrides.insert( + "target".to_string(), + toml::Value::String("override".to_string()), + ); + input.inline_goal_override = Some("Ship {{ vars.owner }}".to_string()); + input + .vars + .insert("owner".to_string(), "payments".to_string()); + + let prepared = prepare_run(input).expect("settings should prepare"); + let settings = prepared.settings(); + + assert_eq!( + settings.run.metadata.get("layer").map(String::as_str), + Some("args") + ); + assert_eq!( + settings.run.metadata.get("owner").map(String::as_str), + Some("payments") + ); + assert_eq!( + settings.run.inputs.get("target"), + Some(&toml::Value::String("override".to_string())) + ); + assert_eq!(settings.run.artifacts.include, vec![ + "reports/payments/*.json" + ]); + let Some(RunGoal::Inline(goal)) = settings.run.goal.as_ref() else { + panic!("inline goal override should win"); + }; + assert_eq!( + goal.resolve_with(&mut ResolveCtx::default()).unwrap(), + "Ship payments" + ); + } + + #[test] + fn settings_reject_artifact_glob_made_unsafe_by_variable() { + let workflow_toml = r#" +_version = 1 + +[run.artifacts] +include = ["reports/{{ vars.path }}/*.json"] +"#; + let mut input = raw_input(Some(workflow_toml), HashMap::new()); + input + .vars + .insert("path".to_string(), "../secrets".to_string()); + + let Err(error) = prepare_run(input) else { + panic!("unsafe artifact glob should fail"); + }; + + assert!(matches!(error, RunCompilerError::VariableInterpolation { + source: VariableInterpolationError::ArtifactGlob { .. }, + })); + } + + #[tokio::test] + async fn graph_vars_are_hard_errors_and_successfully_render_when_present() { + let catalog = Arc::new(Catalog::from_builtin().unwrap()); + let missing = prepare_run(raw_input(None, HashMap::new())) + .expect("settings preparation should not compile graph vars"); + let Err(error) = compile_graph(missing, Arc::clone(&catalog)).await else { + panic!("missing graph variable should be a hard error"); + }; + assert!(matches!(error, RunCompilerError::ValidationOrParse { + source: WorkflowError::ValidationFailed { .. }, + })); + + let mut input = raw_input(None, HashMap::new()); + input + .vars + .insert("owner".to_string(), "payments".to_string()); + input.input_overrides.insert( + "target".to_string(), + toml::Value::String("checkout".to_string()), + ); + let compiled = compile_graph( + prepare_run(input).expect("settings should prepare"), + catalog, + ) + .await + .expect("graph variables should render"); + let work = &compiled.compiled().validated().graph().nodes["work"]; + + assert_eq!( + work.attrs.get("prompt").and_then(AttrValue::as_str), + Some("Ship checkout for payments") + ); + assert_eq!( + work.attrs.get("provider").and_then(AttrValue::as_str), + Some("openai") + ); + } + + #[tokio::test] + async fn assembly_retains_entrypoint_and_run_metadata() { + let run_id = RunId::new(); + let parent_id = RunId::new(); + let automation = AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule".to_string()), + }; + let submitted = b"submitted manifest".to_vec(); + let mut input = raw_input(None, HashMap::new()); + input.run_id = Some(run_id); + input.parent_id = Some(parent_id); + input.title = Some("Compiler boundary".to_string()); + input.workflow_slug = Some("compiler-boundary".to_string()); + input.web_url = Some(format!("https://fabro.test/runs/{run_id}")); + input.submitted_manifest_bytes = Some(submitted.clone()); + input.automation = Some(automation.clone()); + input + .vars + .insert("owner".to_string(), "payments".to_string()); + input.input_overrides.insert( + "target".to_string(), + toml::Value::String("checkout".to_string()), + ); + let expected_entrypoint = input.entrypoint.clone(); + + let output = compile_run(input, Arc::new(Catalog::from_builtin().unwrap())) + .await + .expect("run should compile"); + let persistence = output.persistence_input(); + + assert_eq!(output.entrypoint(), &expected_entrypoint); + assert_eq!(persistence.run_id(), run_id); + assert_eq!(persistence.workflow_slug(), Some("compiler-boundary")); + assert_eq!( + persistence.submitted_manifest_bytes(), + Some(submitted.as_slice()) + ); + assert_eq!(persistence.automation(), Some(&automation)); + assert_eq!( + persistence + .definition() + .map(|definition| &definition.workflow_path), + Some(&expected_entrypoint) + ); + assert_eq!( + persistence.materialized().settings().run.goal.as_ref(), + Some(&RunGoal::Inline(InterpString::parse("Graph goal"))) + ); + } +} diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index fd9a1f50c..707b63931 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -38,8 +38,7 @@ use fabro_validate::Severity; use fabro_workflow::Error as WorkflowError; use fabro_workflow::model_fallback::resolve_model_fallbacks; use fabro_workflow::operations::{ - CreateRunInput, ValidateInput, WorkflowInput, validate, validate_with_catalog, - validate_with_ready_providers, + ValidateInput, WorkflowInput, validate, validate_with_catalog, validate_with_ready_providers, }; use fabro_workflow::pipeline::Validated; use fabro_workflow::run_materialization::materialize_run_with_ready_providers; @@ -56,21 +55,34 @@ pub(crate) struct PreparedManifest { pub cwd: PathBuf, pub git: Option, pub root_source: String, + #[allow( + dead_code, + reason = "create now resolves identity in the run compiler adapter" + )] pub run_id: Option, + #[allow( + dead_code, + reason = "create now resolves lineage in the run compiler adapter" + )] pub parent_id: Option, + #[allow( + dead_code, + reason = "create now normalizes titles in the run compiler adapter" + )] pub title: Option, pub settings: WorkflowSettings, pub target_path: ManifestPath, + #[allow(dead_code, reason = "create now owns the bundle through run_compiler")] pub workflow_bundle: WorkflowBundle, pub workflow_input: BundledWorkflow, pub source_directory: PathBuf, } #[derive(Clone, Debug, Default)] -struct ManifestSettingsOverrides { - run: Option, - cli: Option, - input_overrides: HashMap, +pub(crate) struct ManifestSettingsOverrides { + pub(crate) run: Option, + pub(crate) cli: Option, + pub(crate) input_overrides: HashMap, } #[cfg(test)] @@ -235,34 +247,6 @@ fn manifest_validate_input( } } -pub(crate) fn create_run_input( - prepared: PreparedManifest, - configured_providers: Vec, - provenance: RunProvenance, - web_url: Option, - vars: HashMap, -) -> CreateRunInput { - CreateRunInput { - workflow: WorkflowInput::Bundled(prepared.workflow_input), - settings: prepared.settings, - vars, - cwd: prepared.cwd, - workflow_slug: None, - workflow_path: Some(prepared.target_path), - workflow_bundle: Some(prepared.workflow_bundle), - submitted_manifest_bytes: None, - run_id: prepared.run_id, - title: prepared.title, - automation: None, - git: prepared.git, - fork_source_ref: None, - parent_id: prepared.parent_id, - provenance, - configured_providers, - web_url, - } -} - pub(crate) async fn run_preflight( state: &AppState, prepared: &PreparedManifest, @@ -387,7 +371,7 @@ fn settings_layer_with_resolved_dockerfiles( Ok(layer) } -fn manifest_args_overrides( +pub(crate) fn manifest_args_overrides( args: Option<&types::ManifestArgs>, ) -> Result { let Some(args) = args else { @@ -468,7 +452,7 @@ fn resolve_manifest_dockerfile( Ok(()) } -fn manifest_project_config_path( +pub(crate) fn manifest_project_config_path( config: &types::ManifestConfig, cwd: &Path, ) -> Result { diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 0327d0cda..21451adf8 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::io::ErrorKind; +use std::path::PathBuf; use std::sync::Arc; use axum::extract::{Path, Query, State}; @@ -13,7 +14,8 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use bytes::Bytes; use chrono::{DateTime, Utc}; use fabro_api::types::{ - BoardColumn, RunManifest, SubmitAnswerRequest, UpdateRunParentRequest, UpdateRunRequest, + BoardColumn, ManifestGoalType, RunManifest, SubmitAnswerRequest, UpdateRunParentRequest, + UpdateRunRequest, }; use fabro_config::Storage; use fabro_interview::AnswerSubmission; @@ -23,8 +25,8 @@ use fabro_store::{ }; use fabro_types::settings::ResolveError; use fabro_types::{ - AutomationRef, Principal, Run, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, - RunStatusKind, StageContextWindow, StageContextWindowStaleness, + AutomationRef, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance, + RunServerProvenance, RunStatusKind, StageContextWindow, StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler, StageModelUsage, StageProjection, SystemActorKind, WorkflowSettings, parse_blob_ref, }; @@ -32,6 +34,7 @@ use fabro_util::version::FABRO_VERSION; use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError}; use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice}; use fabro_workflow::run_status::RunStatus; +use fabro_workflow::workflow_bundle::WorkflowBundle; use fabro_workflow::{Error as WorkflowError, operations}; use strum::VariantArray as _; use tokio::fs; @@ -48,6 +51,7 @@ use crate::principal_middleware::{ RequireCommandLog, RequireRunManagementTarget, RequireRunScoped, RequireRunStageScoped, RequiredRunManagementActor, RequiredUser, }; +use crate::run_compiler::{self, ProjectSettingsSource, RawRunCompilerInput, RunCompilerError}; use crate::run_files::{list_run_commits, list_run_files}; use crate::run_manifest; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; @@ -552,6 +556,151 @@ pub(crate) struct CreateRunFromManifestRequest { pub(crate) automation: Option, } +struct ManifestRunCompilerAdapter { + workflow_bundle: WorkflowBundle, + entrypoint: ManifestPath, + cwd: PathBuf, + project_settings: Vec, + user_toml: Vec, + run_overrides: Option, + cli_overrides: Option, + input_overrides: HashMap, + inline_goal_override: Option, + run_id: Option, + parent_id: Option, + title: Option, + git: Option, +} + +fn adapt_manifest_for_run_compiler( + manifest: &RunManifest, + explicit_run_id: Option, +) -> anyhow::Result { + use anyhow::{Context as _, anyhow, bail}; + use fabro_api::types::ManifestConfigType; + + if manifest.version != 1 { + bail!("unsupported manifest version {}", manifest.version); + } + let cwd = PathBuf::from(&manifest.cwd); + let entrypoint = ManifestPath::from_wire(&manifest.target.path) + .ok_or_else(|| anyhow!("invalid manifest target path: {}", manifest.target.path))?; + let workflow_bundle = run_manifest::workflow_bundle_from_manifest(&manifest.workflows)?; + if workflow_bundle.workflow(&entrypoint).is_none() { + return Err(anyhow!( + "manifest target path is missing from workflows map" + )); + } + let overrides = run_manifest::manifest_args_overrides(manifest.args.as_ref()) + .context("failed to parse manifest args")?; + let project_settings = manifest + .configs + .iter() + .filter(|config| config.type_ == ManifestConfigType::Project) + .filter_map(|config| config.source.as_ref().map(|source| (config, source))) + .map(|(config, source)| { + Ok(ProjectSettingsSource { + path: run_manifest::manifest_project_config_path(config, &cwd)?, + toml: source.clone(), + }) + }) + .collect::>>()?; + let user_toml = manifest + .configs + .iter() + .filter(|config| config.type_ == ManifestConfigType::User) + .filter_map(|config| config.source.clone()) + .collect(); + let inline_goal_override = manifest + .goal + .as_ref() + .filter(|goal| goal.type_ != ManifestGoalType::Graph) + .map(|goal| goal.text.clone()); + let title = manifest + .title + .as_ref() + .map(|title| fabro_types::normalize_explicit_run_title(title.as_str())) + .transpose()?; + let manifest_run_id = manifest + .run_id + .as_deref() + .map(str::parse::) + .transpose() + .context("invalid run ID")?; + let parent_id = manifest + .parent_id + .as_deref() + .map(str::parse::) + .transpose() + .context("invalid parent run ID")?; + + Ok(ManifestRunCompilerAdapter { + workflow_bundle, + entrypoint, + cwd, + project_settings, + user_toml, + run_overrides: overrides.run, + cli_overrides: overrides.cli, + input_overrides: overrides.input_overrides, + inline_goal_override, + run_id: explicit_run_id.or(manifest_run_id), + parent_id, + title, + git: manifest.git.clone(), + }) +} + +fn compiler_preparation_error_response(error: RunCompilerError) -> Response { + use run_compiler::{InvalidSettingsError, InvalidSourceError}; + + let detail = match error { + RunCompilerError::InvalidSource { source } => match source { + InvalidSourceError::MissingEntrypoint { .. } => { + "manifest target path is missing from workflows map".to_string() + } + InvalidSourceError::UnsupportedDockerfileReference { reference, .. } => { + format!("unsupported dockerfile reference: {reference}") + } + InvalidSourceError::MissingDockerfile { + dockerfile_path, .. + } => format!("missing bundled dockerfile: {dockerfile_path}"), + }, + RunCompilerError::InvalidSettings { source } => match *source { + InvalidSettingsError::Parse { .. } => "Failed to parse run config TOML".to_string(), + InvalidSettingsError::User { source } => source.to_string(), + InvalidSettingsError::Resolve { .. } => { + "failed to resolve manifest settings".to_string() + } + }, + RunCompilerError::VariableInterpolation { source } => { + format!("Run config variable interpolation failed: {source}") + } + other => return compiler_execution_error_response(other), + }; + ApiError::bad_request(detail).into_response() +} + +fn compiler_execution_error_response(error: RunCompilerError) -> Response { + match error { + RunCompilerError::ValidationOrParse { .. } => { + ApiError::bad_request("Validation failed").into_response() + } + RunCompilerError::ModelSelection { source } => { + ApiError::bad_request(format!("Model selection failed: {source}")).into_response() + } + RunCompilerError::ModelReference { source } => { + ApiError::bad_request(format!("Model reference failed: {source}")).into_response() + } + RunCompilerError::Internal { source, .. } => ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to persist run state: {source}"), + ) + .into_response(), + other => compiler_preparation_error_response(other), + } +} + pub(crate) async fn create_run_from_manifest( state: Arc, request: CreateRunFromManifestRequest, @@ -568,15 +717,45 @@ pub(crate) async fn create_run_from_manifest( let manifest_run_defaults = state.manifest_run_defaults(); let manifest_environment_defaults = state.environment_store().catalog_layer(); let manifest_mcp_server_catalog = state.mcp_server_store().catalog_settings(); - let mut prepared = match run_manifest::prepare_manifest_with_environment_defaults( - manifest_run_defaults.as_ref(), - manifest_environment_defaults.as_ref(), - &manifest_mcp_server_catalog, - &manifest, - ) { - Ok(prepared) => prepared, + let manifest_adapter = match adapt_manifest_for_run_compiler(&manifest, explicit_run_id) { + Ok(adapter) => adapter, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; + let provenance = run_provenance(&headers, &actor); + let raw_compiler_input = RawRunCompilerInput { + workflow_bundle: manifest_adapter.workflow_bundle, + entrypoint: manifest_adapter.entrypoint, + cwd: manifest_adapter.cwd, + server_run_defaults: manifest_run_defaults.as_ref().clone(), + server_environment_defaults: manifest_environment_defaults.as_ref().clone(), + server_mcp_catalog: manifest_mcp_server_catalog, + project_settings: manifest_adapter.project_settings, + user_toml: manifest_adapter.user_toml, + run_overrides: manifest_adapter.run_overrides, + cli_overrides: manifest_adapter.cli_overrides, + input_overrides: manifest_adapter.input_overrides, + inline_goal_override: manifest_adapter.inline_goal_override, + vars: HashMap::new(), + run_id: manifest_adapter.run_id, + title: manifest_adapter.title, + parent_id: manifest_adapter.parent_id, + git: manifest_adapter.git, + storage_root: state.server_storage_dir(), + configured_providers: Vec::new(), + workflow_slug: None, + provenance, + web_url: None, + submitted_manifest_bytes: Some(submitted_manifest_bytes), + automation, + }; + let normalized = match run_compiler::normalize_source(raw_compiler_input) { + Ok(normalized) => normalized, + Err(err) => return compiler_preparation_error_response(err), + }; + let layered = match run_compiler::layer_settings(normalized) { + Ok(layered) => layered, + Err(err) => return compiler_preparation_error_response(err), + }; let vars = match snapshot_run_variables(&state).await { Ok(vars) => vars, Err(err) => { @@ -584,20 +763,19 @@ pub(crate) async fn create_run_from_manifest( .into_response(); } }; - if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) { - return ApiError::bad_request(format!("Run config variable interpolation failed: {err}")) - .into_response(); - } - let run_id = explicit_run_id - .or(prepared.run_id) - .unwrap_or_else(RunId::new); - let provider = run_manifest::effective_sandbox_provider(&prepared.settings.run); + let prepared = match run_compiler::apply_run_variables(layered.with_vars(vars)) { + Ok(prepared) => prepared, + Err(err) => return compiler_preparation_error_response(err), + }; + let (prepared, run_id) = prepared.resolve_run_id(); + let prepared = prepared.with_web_url(state.run_web_url(&run_id)); + let provider = run_manifest::effective_sandbox_provider(&prepared.settings().run); if let Some(error) = run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider) { return ApiError::bad_request(error).into_response(); } - if let Some(parent_id) = prepared.parent_id { + if let Some(parent_id) = prepared.parent_id() { if parent_id == run_id { return ApiError::bad_request("A run cannot be its own parent.").into_response(); } @@ -607,7 +785,6 @@ pub(crate) async fn create_run_from_manifest( } info!(run_id = %run_id, "Run created"); - let web_url = state.run_web_url(&run_id); let catalog = state.catalog(); // Resolve once: we need both the provider IDs (for the run create input // and ask-fabro-readiness) and the LLM client itself (for the spawned @@ -628,34 +805,24 @@ pub(crate) async fn create_run_from_manifest( ready_provider_ids.clone() } }; - let provenance = run_provenance(&headers, &actor); - let mut create_input = run_manifest::create_run_input( - prepared.clone(), - run_materialization_provider_ids, - provenance, - web_url.clone(), - vars, - ); - create_input.run_id = Some(run_id); - create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes); - create_input.automation = automation; - - let storage_root = state.server_storage_dir(); - let created = match Box::pin(operations::create( + let prepared = prepared.with_configured_providers(run_materialization_provider_ids); + let compiled = match run_compiler::compile_graph(prepared, Arc::clone(&catalog)).await { + Ok(compiled) => compiled, + Err(err) => return compiler_execution_error_response(err), + }; + let materialized = match run_compiler::materialize_run(compiled, catalog).await { + Ok(materialized) => materialized, + Err(err) => return compiler_execution_error_response(err), + }; + let compiler_output = run_compiler::assemble_run(materialized); + let (persistence_input, title_generation_target) = compiler_output.into_parts(); + let created = match Box::pin(operations::persist_create_run( state.stores.runs.as_ref(), - create_input, - storage_root, - catalog, + persistence_input, )) .await { Ok(created) => created, - Err(WorkflowError::ValidationFailed { .. } | WorkflowError::Parse(_)) => { - return ApiError::bad_request("Validation failed").into_response(); - } - Err(err @ (WorkflowError::ModelSelection(_) | WorkflowError::ModelReference(_))) => { - return ApiError::bad_request(err.to_string()).into_response(); - } Err(err) => { return ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, @@ -699,7 +866,7 @@ pub(crate) async fn create_run_from_manifest( let run_spec = created.persisted.run_spec(); let workflow = run_title_generation::workflow_summary(&run_spec.graph); let run_inputs = run_spec.settings.run.inputs.clone(); - let workflow_target = prepared.target_path.to_string(); + let workflow_target = title_generation_target.to_string(); let title_catalog = state.catalog(); let title_model = title_catalog.small_default_for_configured_ids(&ready_provider_ids); let title_model_id = title_model.id.clone(); diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index b3da5be63..fd920c042 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -3660,6 +3660,249 @@ async fn create_run_from_manifest_helper_persists_automation_metadata() { assert_eq!(summary.automation, Some(automation)); } +#[tokio::test] +async fn create_run_from_manifest_pins_compiled_and_persisted_behavior() { + let state = TestAppStateBuilder::new() + .runtime_settings( + default_test_server_settings(), + manifest_run_defaults_from_toml( + r#" +[run.metadata] +server-label = "server" +layer = "server" +"#, + ), + ) + .env_lookup(|_| None) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) + .build(); + let run_id = RunId::new(); + let dot = r#"digraph CompilePin { + graph [goal="Graph goal", target="{{ inputs.target }}"] + start [shape=Mdiamond] + work [prompt="Ship {{ inputs.target }}", model="gpt-5.4"] + exit [shape=Msquare] + start -> work -> exit + }"#; + let mut manifest_json = minimal_manifest_json(dot); + manifest_json["title"] = json!(" Pinned create "); + manifest_json["goal"] = json!({ + "type": "value", + "text": "Inline release goal" + }); + manifest_json["args"] = json!({ + "model": "gpt-5.4", + "input": ["target=payments"] + }); + manifest_json["configs"] = json!([{ + "type": "project", + "path": "/tmp/project/.fabro/project.toml", + "source": r#" +_version = 1 + +[project] +name = "payments-project" + +[run.metadata] +project-label = "project" +layer = "project" +"# + }]); + manifest_json["cwd"] = json!("/tmp/project"); + manifest_json["git"] = json!({ + "origin_url": "https://github.com/acme/payments.git", + "branch": "feature/compiler", + "sha": "0123456789abcdef", + "dirty": "clean", + "push_outcome": { "type": "not_attempted" } + }); + let manifest: RunManifest = serde_json::from_value(manifest_json).unwrap(); + let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + header::USER_AGENT, + "fabro-cli/9.8.7".parse().expect("user agent should parse"), + ); + + let response = Box::pin(handler::runs::create_run_from_manifest( + Arc::clone(&state), + handler::runs::CreateRunFromManifestRequest { + manifest, + submitted_manifest_bytes: submitted_manifest_bytes.clone(), + explicit_run_id: Some(run_id), + explicit_title_supplied: true, + actor: Principal::System { + system_kind: SystemActorKind::Engine, + }, + headers, + automation: None, + }, + )) + .await; + + let body = response_json!(response, StatusCode::CREATED).await; + assert_eq!(body["id"], run_id.to_string()); + assert_eq!(body["title"], "Pinned create"); + assert_eq!(body["lifecycle"]["status"]["kind"], "submitted"); + + let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap(); + let events = run_store.list_events().await.unwrap(); + assert_eq!( + events + .iter() + .map(|envelope| envelope.event.event_name()) + .collect::>(), + vec!["run.created", "run.submitted"] + ); + let run_state = run_store.state().await.unwrap(); + let spec = &run_state.spec; + assert_eq!(spec.run_id, run_id); + assert_eq!(spec.graph.goal(), "Inline release goal"); + assert_eq!( + spec.graph.attrs.get("target").and_then(AttrValue::as_str), + Some("{{ inputs.target }}") + ); + assert_eq!( + spec.graph.nodes["work"] + .attrs + .get("prompt") + .and_then(AttrValue::as_str), + Some("Ship payments") + ); + assert_eq!( + spec.graph.nodes["work"] + .attrs + .get("model") + .and_then(AttrValue::as_str), + Some("gpt-5.4") + ); + assert_eq!( + spec.graph.nodes["work"] + .attrs + .get("provider") + .and_then(AttrValue::as_str), + Some("openai") + ); + assert_eq!(spec.settings.run.model.name.as_deref(), Some("gpt-5.4")); + assert_eq!(spec.settings.run.model.provider.as_deref(), Some("openai")); + assert_eq!( + spec.settings.run.inputs.get("target"), + Some(&toml::Value::String("payments".to_string())) + ); + assert_eq!( + spec.settings.project.name.as_deref(), + Some("payments-project") + ); + assert_eq!( + spec.labels.get("project-label").map(String::as_str), + Some("project") + ); + assert_eq!( + spec.labels.get("layer").map(String::as_str), + Some("project") + ); + assert_eq!( + spec.git.as_ref().map(|git| git.origin_url.as_str()), + Some("https://github.com/acme/payments.git") + ); + + let created = events[0].event.to_value().unwrap(); + assert_eq!(created["properties"]["title"], "Pinned create"); + assert_eq!(created["properties"]["labels"]["project-label"], "project"); + assert_eq!( + created["properties"]["provenance"]["client"]["user_agent"], + "fabro-cli/9.8.7" + ); + assert_eq!( + created["properties"]["provenance"]["subject"]["kind"], + "system" + ); + let manifest_blob = created["properties"]["manifest_blob"] + .as_str() + .expect("run.created should carry the submitted source blob") + .parse::() + .unwrap(); + let persisted_manifest = run_store + .read_blob(&manifest_blob) + .await + .unwrap() + .expect("submitted source blob should exist"); + assert_eq!(persisted_manifest.as_ref(), submitted_manifest_bytes); +} + +#[tokio::test] +async fn create_run_from_manifest_pins_compiler_http_error_mappings() { + let cases = [ + ( + { + let mut manifest = minimal_manifest_json(MINIMAL_DOT); + manifest["version"] = json!(2); + manifest + }, + "unsupported manifest version 2", + ), + ( + minimal_manifest_json( + r#"digraph Test { + graph [goal="Test"] + start [shape=Mdiamond] + work [prompt="Use {{ vars.MISSING }}"] + exit [shape=Msquare] + start -> work -> exit + }"#, + ), + "Validation failed", + ), + ( + { + let mut manifest = minimal_manifest_json( + r#"digraph Test { + graph [goal="Test"] + start [shape=Mdiamond] + work [prompt="Do work", model="gpt-5.4", provider="missing-provider"] + exit [shape=Msquare] + start -> work -> exit + }"#, + ); + manifest["args"] = json!({ + "model": "gpt-5.4", + "provider": "missing-provider" + }); + manifest + }, + "Model selection failed: unknown model provider 'missing-provider'", + ), + ]; + + for (manifest_json, expected_detail) in cases { + let state = TestAppStateBuilder::new() + .env_lookup(|_| None) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) + .build(); + let manifest: RunManifest = serde_json::from_value(manifest_json).unwrap(); + let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + + let response = Box::pin(handler::runs::create_run_from_manifest( + state, + handler::runs::CreateRunFromManifestRequest { + manifest, + submitted_manifest_bytes, + explicit_run_id: Some(RunId::new()), + explicit_title_supplied: true, + actor: Principal::System { + system_kind: SystemActorKind::Engine, + }, + headers: HeaderMap::new(), + automation: None, + }, + )) + .await; + + let body = response_json!(response, StatusCode::BAD_REQUEST).await; + assert_eq!(body["errors"][0]["detail"], expected_detail); + } +} + #[tokio::test] async fn fake_automation_materializer_injection_captures_input_and_returns_manifest() { let materialized_manifest: RunManifest = diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index 8451ef1df..f238fbc40 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -26,8 +26,7 @@ use crate::file_resolver::FileResolver; use crate::pipeline::types::PersistOptions; use crate::pipeline::{self, Persisted, TransformOptions, Validated}; use crate::records::RunSpec; -use crate::run_lookup::default_scratch_base; -use crate::run_materialization::materialize_run; +use crate::run_materialization; use crate::transforms::{ModelResolutionTransform, RenderMode}; use crate::workflow_bundle::{RunDefinition, WorkflowBundle}; @@ -58,6 +57,36 @@ pub struct CreateRunInput { pub web_url: Option, } +/// Inputs needed to resolve and compile a workflow for run creation. +#[derive(Clone, Debug)] +pub struct CreateRunCompileInput { + pub workflow: WorkflowInput, + pub settings: WorkflowSettings, + pub vars: HashMap, + pub cwd: PathBuf, + pub workflow_path: Option, + pub workflow_bundle: Option, + pub configured_providers: Vec, +} + +/// Durable metadata joined to a materialized workflow before persistence. +/// `run_id` is already resolved, and `storage_root` is used to derive the +/// run's scratch directory during pure input assembly. +#[derive(Clone, Debug)] +pub struct CreateRunPersistenceMetadata { + pub run_id: RunId, + pub storage_root: PathBuf, + pub workflow_slug: Option, + pub submitted_manifest_bytes: Option>, + pub title: Option, + pub automation: Option, + pub git: Option, + pub fork_source_ref: Option, + pub parent_id: Option, + pub provenance: RunProvenance, + pub web_url: Option, +} + #[derive(Debug)] pub struct CreatedRun { pub persisted: Persisted, @@ -66,20 +95,132 @@ pub struct CreatedRun { pub dot_path: Option, } -struct PersistCreateOptions { +/// Result of resolving, preprocessing, validating, and promoting a workflow +/// for run creation. Model selectors in the graph are resolved, while the run +/// settings still reflect the compiled source and have not been materialized. +pub struct CompiledRun { + validated: Validated, settings: WorkflowSettings, - run_id: Option, - run_dir: Option, + raw_source: String, workflow_slug: Option, - source_name: Option, + workflow_config: Option, + dot_path: Option, + current_dir: Option, + file_resolver: Option>, + definition: Option, + source_directory: String, labels: HashMap, - source_directory: Option, - automation: Option, - git: Option, - fork_source_ref: Option, - provenance: RunProvenance, configured_providers: Vec, - catalog: Arc, +} + +impl CompiledRun { + pub fn validated(&self) -> &Validated { + &self.validated + } + + pub fn settings(&self) -> &WorkflowSettings { + &self.settings + } + + pub fn resolved_source(&self) -> &str { + &self.raw_source + } + + pub fn workflow_slug(&self) -> Option<&str> { + self.workflow_slug.as_deref() + } + + pub fn workflow_config(&self) -> Option<&str> { + self.workflow_config.as_deref() + } + + pub fn dot_path(&self) -> Option<&Path> { + self.dot_path.as_deref() + } + + pub fn current_dir(&self) -> Option<&Path> { + self.current_dir.as_deref() + } + + pub fn file_resolver(&self) -> Option> { + self.file_resolver.clone() + } + + pub fn definition(&self) -> Option<&RunDefinition> { + self.definition.as_ref() + } + + pub fn source_directory(&self) -> &str { + &self.source_directory + } + + pub fn labels(&self) -> &HashMap { + &self.labels + } +} + +/// Compiled workflow with its run-level model settings materialized against +/// the same provider snapshot used during compilation. +pub struct MaterializedRun { + compiled: CompiledRun, + settings: WorkflowSettings, +} + +impl MaterializedRun { + pub fn compiled(&self) -> &CompiledRun { + &self.compiled + } + + pub fn settings(&self) -> &WorkflowSettings { + &self.settings + } +} + +/// Complete input for creating a durable run. The run ID and run directory +/// are resolved during assembly, before persistence begins. +pub struct CreateRunPersistenceInput { + materialized: MaterializedRun, + run_id: RunId, + run_dir: PathBuf, + workflow_slug: Option, + submitted_manifest_bytes: Option>, + title: Option, + automation: Option, + git: Option, + fork_source_ref: Option, + parent_id: Option, + provenance: RunProvenance, + web_url: Option, +} + +impl CreateRunPersistenceInput { + pub fn materialized(&self) -> &MaterializedRun { + &self.materialized + } + + pub fn run_id(&self) -> RunId { + self.run_id + } + + pub fn run_dir(&self) -> &Path { + &self.run_dir + } + + pub fn workflow_slug(&self) -> Option<&str> { + self.workflow_slug.as_deref() + } + + pub fn submitted_manifest_bytes(&self) -> Option<&[u8]> { + self.submitted_manifest_bytes.as_deref() + } + + pub fn automation(&self) -> Option<&AutomationRef> { + self.automation.as_ref() + } + + pub fn definition(&self) -> Option<&RunDefinition> { + self.materialized.compiled.definition.as_ref() + } } /// Resolve workflow inputs, normalize settings using the caller-provided @@ -90,96 +231,278 @@ pub async fn create( storage_root: PathBuf, catalog: Arc, ) -> Result { - let resolved = resolve_workflow(ResolveWorkflowInput { - workflow: request.workflow, - settings: request.settings, - cwd: request.cwd, + let run_id = request.run_id.unwrap_or_default(); + let persistence_input = spawn_blocking(move || { + let CreateRunInput { + workflow, + settings, + vars, + cwd, + workflow_slug, + workflow_path, + workflow_bundle, + submitted_manifest_bytes, + run_id: _, + title, + automation, + git, + fork_source_ref, + parent_id, + provenance, + configured_providers, + web_url, + } = request; + let compiled = compile_create_run( + CreateRunCompileInput { + workflow, + settings, + vars, + cwd, + workflow_path, + workflow_bundle, + configured_providers, + }, + Arc::clone(&catalog), + )?; + let materialized = materialize_create_run(compiled, catalog.as_ref())?; + Ok::<_, Error>(assemble_create_run_persistence_input( + materialized, + CreateRunPersistenceMetadata { + run_id, + storage_root, + workflow_slug, + submitted_manifest_bytes, + title, + automation, + git, + fork_source_ref, + parent_id, + provenance, + web_url, + }, + )) }) - .map_err(|err| Error::Parse(err.to_string()))?; - let labels = resolved.settings.combined_labels(); - let settings = resolved.settings.clone(); + .await + .map_err(|err| Error::engine_with_source("workflow create task failed", err))??; - let CreateRunInput { - workflow: _, - settings: _, + Box::pin(persist_create_run(store, persistence_input)).await +} + +/// Resolve, preprocess, validate, and promote a workflow for run creation. +/// +/// This stage is synchronous and may read workflow files. Async callers must +/// run it on a blocking thread. +pub fn compile_create_run( + input: CreateRunCompileInput, + catalog: Arc, +) -> Result { + let CreateRunCompileInput { + workflow, + settings, vars, - cwd: _, - workflow_slug, + cwd, workflow_path, workflow_bundle, - submitted_manifest_bytes, + configured_providers, + } = input; + let resolved = resolve_workflow(ResolveWorkflowInput { + workflow, + settings, + cwd, + }) + .map_err(|err| Error::Parse(err.to_string()))?; + let settings = resolved.settings; + let labels = settings.combined_labels(); + let workflow_config = resolved + .workflow_toml_path + .as_deref() + .and_then(|path| std::fs::read_to_string(path).ok()); + let source_name = resolved + .dot_path + .as_ref() + .map(|path| path.display().to_string()); + let definition = match (workflow_path, workflow_bundle) { + (Some(workflow_path), Some(workflow_bundle)) => { + let bundled = workflow_bundle.workflow(&workflow_path).ok_or_else(|| { + Error::Parse("workflow path is missing from workflow bundle".to_string()) + })?; + if bundled.source != resolved.raw_source { + return Err(Error::Parse( + "resolved workflow does not match workflow bundle entrypoint".to_string(), + )); + } + Some(RunDefinition::new(workflow_path, workflow_bundle)) + } + (None, None) => None, + _ => { + return Err(Error::Parse( + "workflow path and workflow bundle must be provided together".to_string(), + )); + } + }; + let mut validated = preprocess_and_validate( + &resolved.raw_source, + resolved.goal_override.as_deref(), + &TransformOptions { + current_dir: resolved.current_dir.clone(), + file_resolver: resolved.file_resolver.clone(), + template_context: template_context(Some(&settings), vars), + source_name, + render_mode: RenderMode::Structural, + custom_transforms: Vec::new(), + model_resolution: Some( + ModelResolutionTransform::for_eligible( + catalog, + configured_providers.iter().cloned().collect(), + ) + .with_default_provider(configured_default_provider(&settings)), + ), + }, + )?; + + validated.promote_template_undefined_variables_to_errors(); + if validated.has_errors() { + return Err(Error::ValidationFailed { + diagnostics: validated.diagnostics().to_vec(), + }); + } + + Ok(CompiledRun { + validated, + settings, + raw_source: resolved.raw_source, + workflow_slug: resolved.workflow_slug, + workflow_config, + dot_path: resolved.dot_path, + current_dir: resolved.current_dir, + file_resolver: resolved.file_resolver, + definition, + source_directory: resolved.working_directory.to_string_lossy().to_string(), + labels, + configured_providers, + }) +} + +/// Materialize run-level model settings from a compiled workflow. +pub fn materialize_create_run( + compiled: CompiledRun, + catalog: &Catalog, +) -> Result { + let settings = run_materialization::materialize_run( + compiled.settings.clone(), + compiled.validated.graph(), + catalog, + &compiled.configured_providers, + )?; + Ok(MaterializedRun { compiled, settings }) +} + +/// Assemble all inputs needed for persistence without I/O or recompilation. +pub fn assemble_create_run_persistence_input( + materialized: MaterializedRun, + metadata: CreateRunPersistenceMetadata, +) -> CreateRunPersistenceInput { + let CreateRunPersistenceMetadata { run_id, + storage_root, + workflow_slug, + submitted_manifest_bytes, title, automation, git, fork_source_ref, parent_id, provenance, - configured_providers, web_url, - } = request; + } = metadata; + let run_dir = Storage::new(storage_root) + .run_scratch(&run_id) + .root() + .to_path_buf(); + let workflow_slug = workflow_slug.or_else(|| materialized.compiled.workflow_slug.clone()); - let run_id = run_id.unwrap_or_else(RunId::new); - let storage = Storage::new(storage_root); - let run_dir = storage.run_scratch(&run_id).root().to_path_buf(); - let source_directory = Some(resolved.working_directory.to_string_lossy().to_string()); + CreateRunPersistenceInput { + materialized, + run_id, + run_dir, + workflow_slug, + submitted_manifest_bytes, + title, + automation, + git, + fork_source_ref, + parent_id, + provenance, + web_url, + } +} - let goal_override = resolved.goal_override.clone(); - let current_dir = resolved.current_dir.clone(); - let file_resolver = resolved.file_resolver.clone(); - let resolved_workflow_slug = resolved.workflow_slug.clone(); +/// Persist one already-compiled and materialized run without recompiling it. +pub async fn persist_create_run( + store: &Database, + input: CreateRunPersistenceInput, +) -> Result { + let CreateRunPersistenceInput { + materialized, + run_id, + run_dir, + workflow_slug, + submitted_manifest_bytes, + title, + automation, + git, + fork_source_ref, + parent_id, + provenance, + web_url, + } = input; + let MaterializedRun { compiled, settings } = materialized; + let CompiledRun { + validated, + settings: _, + raw_source, + workflow_slug: _, + workflow_config, + dot_path, + current_dir: _, + file_resolver: _, + definition, + source_directory, + labels, + configured_providers: _, + } = compiled; let persisted_run_dir = run_dir.clone(); - let accepted_definition = match (&workflow_path, &workflow_bundle) { - (Some(workflow_path), Some(workflow_bundle)) => Some(RunDefinition::new( - workflow_path.clone(), - workflow_bundle.clone(), - )), - _ => None, - }; - - let raw_source = resolved.raw_source.clone(); - let source_name = resolved - .dot_path - .as_ref() - .map(|path| path.display().to_string()); let persisted = spawn_blocking(move || { - create_from_source( - &raw_source, - vars, - PersistCreateOptions { - settings, - run_id: Some(run_id), - run_dir: Some(persisted_run_dir), - workflow_slug: workflow_slug.or(resolved_workflow_slug), - source_name, - labels, - source_directory, - automation, - git, - fork_source_ref, - provenance, - configured_providers, - catalog, - }, - current_dir, - file_resolver, - goal_override.as_deref(), - ) + let run_spec = RunSpec { + run_id, + settings, + graph: validated.graph().clone(), + graph_source: Some(validated.source().to_string()), + workflow_slug, + automation, + source_directory: Some(source_directory), + labels, + provenance, + manifest_blob: None, + definition_blob: None, + git, + fork_source_ref, + }; + pipeline::persist(validated, PersistOptions { + run_dir: persisted_run_dir, + run_spec, + }) }) .await .map_err(|err| Error::engine_with_source("workflow create task failed", err))??; - let workflow_config = resolved - .workflow_toml_path - .as_deref() - .and_then(|path| std::fs::read_to_string(path).ok()); persist_created_run( store, &persisted, - &resolved.raw_source, + &raw_source, workflow_config, submitted_manifest_bytes.as_deref(), - accepted_definition.as_ref(), + definition.as_ref(), title, parent_id, web_url, @@ -190,7 +513,7 @@ pub async fn create( persisted, run_id, run_dir, - dot_path: resolved.dot_path, + dot_path, }) } @@ -285,40 +608,6 @@ fn store_error(err: impl std::fmt::Display) -> Error { Error::engine(err.to_string()) } -fn create_from_source( - dot_source: &str, - vars: HashMap, - options: PersistCreateOptions, - current_dir: Option, - file_resolver: Option>, - goal_override: Option<&str>, -) -> Result { - let mut validated = preprocess_and_validate(dot_source, goal_override, &TransformOptions { - current_dir, - file_resolver, - template_context: template_context(Some(&options.settings), vars), - source_name: options.source_name.clone(), - render_mode: RenderMode::Structural, - custom_transforms: Vec::new(), - model_resolution: Some( - ModelResolutionTransform::for_eligible( - Arc::clone(&options.catalog), - options.configured_providers.iter().cloned().collect(), - ) - .with_default_provider(configured_default_provider(&options.settings)), - ), - })?; - - validated.promote_template_undefined_variables_to_errors(); - if validated.has_errors() { - return Err(Error::ValidationFailed { - diagnostics: validated.diagnostics().to_vec(), - }); - } - - persist_validated(validated, options) -} - /// Parse, transform, and validate `dot_source`. /// /// `options.model_resolution` drives both halves of catalog awareness: it @@ -376,59 +665,6 @@ fn apply_goal_override(graph: &mut Graph, goal_override: Option<&str>) { } } -fn persist_validated( - validated: Validated, - options: PersistCreateOptions, -) -> Result { - let PersistCreateOptions { - settings, - run_id, - run_dir, - workflow_slug, - source_name: _, - labels, - source_directory, - automation, - git, - fork_source_ref, - provenance, - configured_providers, - catalog, - } = options; - - let settings = materialize_run( - settings, - validated.graph(), - catalog.as_ref(), - &configured_providers, - )?; - - let run_id = run_id.unwrap_or_else(RunId::new); - let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id)); - - let run_spec = RunSpec { - run_id, - settings, - graph: validated.graph().clone(), - graph_source: Some(validated.source().to_string()), - workflow_slug, - automation, - source_directory, - labels, - provenance, - manifest_blob: None, - definition_blob: None, - git, - fork_source_ref, - }; - - pipeline::persist(validated, PersistOptions { run_dir, run_spec }) -} - -pub(crate) fn default_run_dir(run_id: &RunId) -> PathBuf { - make_run_dir(&default_scratch_base(), run_id) -} - pub fn make_run_dir(scratch_base: &Path, run_id: &RunId) -> PathBuf { fabro_config::RunScratch::for_run(scratch_base, run_id) .root() @@ -456,6 +692,7 @@ mod tests { use object_store::memory::InMemory; use super::*; + use crate::file_resolver::FileResolver; use crate::operations::{ValidateInput, validate, validate_with_catalog}; use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE}; use crate::transforms::Transform; @@ -547,6 +784,38 @@ reasoning = false Catalog::builtin().all_provider_ids().into_iter().collect() } + fn compile_input(request: &CreateRunInput) -> CreateRunCompileInput { + CreateRunCompileInput { + workflow: request.workflow.clone(), + settings: request.settings.clone(), + vars: request.vars.clone(), + cwd: request.cwd.clone(), + workflow_path: request.workflow_path.clone(), + workflow_bundle: request.workflow_bundle.clone(), + configured_providers: request.configured_providers.clone(), + } + } + + fn persistence_metadata( + request: &CreateRunInput, + run_id: RunId, + storage_root: &Path, + ) -> CreateRunPersistenceMetadata { + CreateRunPersistenceMetadata { + run_id, + storage_root: storage_root.to_path_buf(), + workflow_slug: request.workflow_slug.clone(), + submitted_manifest_bytes: request.submitted_manifest_bytes.clone(), + title: request.title.clone(), + automation: request.automation.clone(), + git: request.git.clone(), + fork_source_ref: request.fork_source_ref.clone(), + parent_id: request.parent_id, + provenance: request.provenance.clone(), + web_url: request.web_url.clone(), + } + } + fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated { validate_with_catalog( ValidateInput { @@ -1337,6 +1606,244 @@ reasoning = false ); } + #[test] + fn assemble_create_run_persistence_input_resolves_complete_durable_identity() { + let dir = tempfile::tempdir().unwrap(); + let storage_root = dir.path().join("storage"); + let automation = AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule_1".to_string()), + }; + let request = CreateRunInput { + workflow: WorkflowInput::DotSource { + source: MINIMAL_DOT.to_string(), + base_dir: None, + }, + settings: test_default_settings(), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("request-slug".to_string()), + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: Some(b"submitted manifest".to_vec()), + run_id: Some(fixtures::RUN_1), + title: Some("Assembled run".to_string()), + automation: Some(automation.clone()), + git: None, + fork_source_ref: None, + parent_id: Some(fixtures::RUN_2), + provenance: test_support::test_run_provenance(), + configured_providers: test_provider_ids(), + web_url: Some("https://fabro.test/runs/1".to_string()), + }; + let catalog = test_catalog(); + let resolved_run_id = fixtures::RUN_64; + + let compiled = compile_create_run(compile_input(&request), Arc::clone(&catalog)).unwrap(); + let materialized = materialize_create_run(compiled, catalog.as_ref()).unwrap(); + let metadata = persistence_metadata(&request, resolved_run_id, &storage_root); + let input = assemble_create_run_persistence_input(materialized, metadata); + + assert_eq!(input.run_id(), resolved_run_id); + assert_eq!( + input.run_dir(), + Storage::new(&storage_root) + .run_scratch(&resolved_run_id) + .root() + ); + assert_eq!(input.workflow_slug(), Some("request-slug")); + assert_eq!( + input.submitted_manifest_bytes(), + Some(b"submitted manifest".as_slice()) + ); + assert_eq!(input.automation(), Some(&automation)); + assert_eq!( + input.materialized().settings().run.model.name.as_deref(), + Some("claude-sonnet-5") + ); + } + + #[test] + fn compile_create_run_rejects_mismatched_bundle_definition() { + let workflow_path = ManifestPath::from_wire("workflows/main.fabro").unwrap(); + let compiled_workflow = BundledWorkflow { + path: workflow_path.clone(), + source: MINIMAL_DOT.to_string(), + config: None, + files: HashMap::new(), + }; + let mismatched_bundle = + WorkflowBundle::new(HashMap::from([(workflow_path.clone(), BundledWorkflow { + source: MINIMAL_DOT.replace("Build feature", "Different goal"), + ..compiled_workflow.clone() + })])); + + let Err(error) = compile_create_run( + CreateRunCompileInput { + workflow: WorkflowInput::Bundled(compiled_workflow), + settings: test_default_settings(), + vars: HashMap::new(), + cwd: PathBuf::from("/tmp/project"), + workflow_path: Some(workflow_path), + workflow_bundle: Some(mismatched_bundle), + configured_providers: test_provider_ids(), + }, + test_catalog(), + ) else { + panic!("mismatched accepted definition should fail"); + }; + + assert!(matches!(error, Error::Parse(message) if message == + "resolved workflow does not match workflow bundle entrypoint")); + } + + #[test] + fn compile_create_run_exposes_resolved_metadata_and_definition() { + let workflow_path = ManifestPath::from_wire("workflows/main.fabro").unwrap(); + let bundled = BundledWorkflow { + path: workflow_path.clone(), + source: MINIMAL_DOT.to_string(), + config: None, + files: HashMap::new(), + }; + let bundle = WorkflowBundle::new(HashMap::from([(workflow_path.clone(), bundled.clone())])); + let compiled = compile_create_run( + CreateRunCompileInput { + workflow: WorkflowInput::Bundled(bundled), + settings: test_default_settings(), + vars: HashMap::new(), + cwd: PathBuf::from("/tmp/project"), + workflow_path: Some(workflow_path.clone()), + workflow_bundle: Some(bundle), + configured_providers: test_provider_ids(), + }, + test_catalog(), + ) + .unwrap(); + + assert_eq!(compiled.resolved_source(), MINIMAL_DOT); + assert_eq!(compiled.current_dir(), Some(Path::new("workflows"))); + assert_eq!(compiled.dot_path(), Some(workflow_path.as_path())); + assert!(compiled.file_resolver().is_some()); + assert_eq!(compiled.labels(), &compiled.settings().combined_labels()); + let materialized = materialize_create_run(compiled, test_catalog().as_ref()).unwrap(); + let input = + assemble_create_run_persistence_input(materialized, CreateRunPersistenceMetadata { + run_id: fixtures::RUN_1, + storage_root: PathBuf::from("/tmp/storage"), + workflow_slug: None, + submitted_manifest_bytes: None, + title: None, + automation: None, + git: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + web_url: None, + }); + let definition = input + .definition() + .expect("bundled create input should retain a run definition"); + assert_eq!(definition.workflow_path, workflow_path); + } + + #[tokio::test] + async fn persist_create_run_uses_compiled_graph_without_recompiling_source() { + let dir = tempfile::tempdir().unwrap(); + let storage_root = dir.path().join("storage"); + let dot_path = dir.path().join("workflow.fabro"); + let compiled_source = MINIMAL_DOT.replace("Build feature", "Compiled goal"); + std::fs::write(&dot_path, &compiled_source).unwrap(); + let automation = AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule_1".to_string()), + }; + let request = CreateRunInput { + workflow: WorkflowInput::Path(dot_path.clone()), + settings: test_default_settings(), + vars: HashMap::new(), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("compiled-slug".to_string()), + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: Some(b"submitted manifest".to_vec()), + run_id: Some(fixtures::RUN_2), + title: Some("Compiled run".to_string()), + automation: Some(automation.clone()), + git: None, + fork_source_ref: None, + parent_id: None, + provenance: test_support::test_run_provenance(), + configured_providers: test_provider_ids(), + web_url: None, + }; + let catalog = test_catalog(); + let workflow_config_path = dir.path().join("workflow.toml"); + std::fs::write( + &workflow_config_path, + "_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + let compiled = compile_create_run(compile_input(&request), Arc::clone(&catalog)).unwrap(); + assert_eq!( + compiled.workflow_config(), + Some("_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n") + ); + + std::fs::write(&dot_path, "this is no longer a graph").unwrap(); + std::fs::write(&workflow_config_path, "changed after compilation").unwrap(); + + let materialized = materialize_create_run(compiled, catalog.as_ref()).unwrap(); + let metadata = persistence_metadata(&request, fixtures::RUN_2, &storage_root); + let input = assemble_create_run_persistence_input(materialized, metadata); + let store = memory_store(); + let created = persist_create_run(store.as_ref(), input).await.unwrap(); + + assert_eq!(created.run_id, fixtures::RUN_2); + assert_eq!(created.dot_path.as_deref(), Some(dot_path.as_path())); + assert_eq!(created.persisted.graph().goal(), "Compiled goal"); + assert_eq!(created.persisted.source(), compiled_source); + + let run_store = store.open_run_reader(&fixtures::RUN_2).await.unwrap(); + let state = run_store.state().await.unwrap(); + assert_eq!(state.spec.graph.goal(), "Compiled goal"); + assert_eq!(state.spec.automation, Some(automation)); + let events = run_store.list_events().await.unwrap(); + assert_eq!( + events + .iter() + .map(|event| event.event.event_name()) + .collect::>(), + vec!["run.created", "run.submitted"] + ); + let EventBody::RunCreated(created) = &events[0].event.body else { + panic!("first durable event should be run.created"); + }; + assert_eq!( + created.workflow_source.as_deref(), + Some(compiled_source.as_str()) + ); + assert_eq!( + created.workflow_config.as_deref(), + Some("_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n") + ); + let manifest_blob = created + .manifest_blob + .as_ref() + .expect("submitted manifest should be persisted"); + assert_eq!( + run_store + .read_blob(manifest_blob) + .await + .unwrap() + .expect("submitted manifest blob should exist") + .as_ref(), + b"submitted manifest" + ); + } + #[tokio::test] async fn create_returns_validation_failed_with_diagnostics() { let dot = r#"digraph Test { diff --git a/lib/components/fabro-workflow/src/operations/mod.rs b/lib/components/fabro-workflow/src/operations/mod.rs index 9523c5ff5..57ed472f4 100644 --- a/lib/components/fabro-workflow/src/operations/mod.rs +++ b/lib/components/fabro-workflow/src/operations/mod.rs @@ -14,7 +14,12 @@ pub use archive::{ ArchiveOutcome, UnarchiveOutcome, archive, archived_rejection_message, ensure_not_archived, unarchive, }; -pub use create::{CreateRunInput, CreatedRun, create, make_run_dir}; +pub use create::{ + CompiledRun, CreateRunCompileInput, CreateRunInput, CreateRunPersistenceInput, + CreateRunPersistenceMetadata, CreatedRun, MaterializedRun, + assemble_create_run_persistence_input, compile_create_run, create, make_run_dir, + materialize_create_run, persist_create_run, +}; pub use fork::{ForkOutcome, ForkRunInput, ResolvedForkTarget, fork_run}; pub use resume::resume; pub use retry::{RetryOutcome, RetryRunInput, retry_run}; From 76f61f163b511faacde75886ad69124639fb5296 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 01:41:12 +0000 Subject: [PATCH 27/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): simplify_fable (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 6 Fabro-Checkpoint: 1254f4ca92f7b1f822cdfa7ca549ef8595a50fed ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/apps/fabro-server/src/run_compiler.rs | 770 +++++++----------- lib/apps/fabro-server/src/run_manifest.rs | 124 +-- .../fabro-server/src/server/handler/runs.rs | 190 ++--- .../fabro-workflow/src/operations/create.rs | 245 +++--- 4 files changed, 504 insertions(+), 825 deletions(-) diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs index 341c4565c..a49655ce5 100644 --- a/lib/apps/fabro-server/src/run_compiler.rs +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -1,3 +1,29 @@ +//! The create-time run compiler: the single pipeline that turns an acquired +//! workflow bundle into a complete, persistable run. +//! +//! The pipeline has four stages, each its own function with typed input and +//! output: +//! +//! 1. [`normalize_source`] — resolve the bundle entrypoint and parse the +//! bundle-relative settings sources (workflow and project layers, with +//! dockerfile references inlined from bundled files). +//! 2. [`layer_settings`] + [`apply_run_variables`] + graph compilation — layer +//! settings from every configured source, substitute the run-scoped variable +//! snapshot, then parse/transform/validate the graph through the +//! fabro-workflow pipeline. +//! 3. Model pinning — materialize run-level model settings against the catalog +//! and the configured provider set. Stages 2's graph compilation and stage 3 +//! share one blocking dispatch via [`compile_and_pin`]. +//! 4. [`assemble_run`] — purely assemble the complete persistence input; no +//! field is mutated after assembly. +//! +//! The input is deliberately source-neutral: it speaks in terms of an +//! acquired [`WorkflowBundle`], not any wire request type, so non-HTTP +//! callers and alternative workflow sources can drive the same pipeline. +//! Callers own source acquisition, run-id resolution, variable snapshotting, +//! and (for HTTP callers) all wire mapping — including turning +//! [`RunCompilerError`] into HTTP responses. + use std::collections::HashMap; use std::error::Error as StdError; use std::path::PathBuf; @@ -8,8 +34,7 @@ use fabro_config::{ CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder, }; -use fabro_model::{Catalog, ModelSelectionError, ProviderId}; -use fabro_types::settings::AmbiguousModelRef; +use fabro_model::{Catalog, ProviderId}; use fabro_types::settings::interp::{InterpString, ResolveError}; use fabro_types::settings::run::{McpServerSettings, RunGoal}; use fabro_types::{ @@ -26,7 +51,7 @@ use tokio::task; /// One project settings source already normalized into the manifest path /// namespace used by the workflow bundle. -#[derive(Clone, Debug)] +#[derive(Debug)] pub(crate) struct ProjectSettingsSource { pub(crate) path: ManifestPath, pub(crate) toml: String, @@ -34,10 +59,11 @@ pub(crate) struct ProjectSettingsSource { /// Transport-neutral inputs for compiling one submitted run. /// -/// IDs, title, git metadata, and provenance are resolved by the caller. This -/// boundary owns only source normalization, settings resolution, workflow -/// compilation, materialization, and persistence-input assembly. -#[derive(Clone, Debug)] +/// Identity (`run_id`), lineage, title, git metadata, and provenance are +/// resolved by the caller. This boundary owns only source normalization, +/// settings resolution, workflow compilation, model pinning, and +/// persistence-input assembly. +#[derive(Debug)] pub(crate) struct RawRunCompilerInput { pub(crate) workflow_bundle: WorkflowBundle, pub(crate) entrypoint: ManifestPath, @@ -51,13 +77,11 @@ pub(crate) struct RawRunCompilerInput { pub(crate) cli_overrides: Option, pub(crate) input_overrides: HashMap, pub(crate) inline_goal_override: Option, - pub(crate) vars: HashMap, - pub(crate) run_id: Option, + pub(crate) run_id: RunId, pub(crate) title: Option, pub(crate) parent_id: Option, pub(crate) git: Option, pub(crate) storage_root: PathBuf, - pub(crate) configured_providers: Vec, pub(crate) workflow_slug: Option, pub(crate) provenance: RunProvenance, pub(crate) web_url: Option, @@ -65,20 +89,6 @@ pub(crate) struct RawRunCompilerInput { pub(crate) automation: Option, } -#[derive(Clone, Debug)] -struct RunMetadata { - run_id: Option, - title: Option, - parent_id: Option, - git: Option, - storage_root: PathBuf, - workflow_slug: Option, - provenance: RunProvenance, - web_url: Option, - submitted_manifest_bytes: Option>, - automation: Option, -} - /// Stage-one output: the selected bundled workflow and all client settings /// sources have been parsed and normalized, but no settings have been layered. pub(crate) struct NormalizedRun { @@ -96,176 +106,90 @@ pub(crate) struct NormalizedRun { cli_overrides: Option, input_overrides: HashMap, inline_goal_override: Option, - vars: HashMap, - configured_providers: Vec, - metadata: RunMetadata, + metadata: CreateRunPersistenceMetadata, } -/// Settings-layered output. Variable substitution is intentionally separate -/// so callers can snapshot variables after source/settings preparation, as the -/// create handler historically does. +/// Settings-layered output. Variable substitution is a separate stage so +/// callers can snapshot run variables after settings resolution and apply +/// the snapshot through [`apply_run_variables`]. pub(crate) struct LayeredRun { - workflow_bundle: WorkflowBundle, - entrypoint: ManifestPath, - workflow: BundledWorkflow, - settings: WorkflowSettings, - cwd: PathBuf, - vars: HashMap, - configured_providers: Vec, - metadata: RunMetadata, + workflow_bundle: WorkflowBundle, + entrypoint: ManifestPath, + workflow: BundledWorkflow, + settings: WorkflowSettings, + cwd: PathBuf, + metadata: CreateRunPersistenceMetadata, } -impl LayeredRun { - pub(crate) fn with_vars(mut self, vars: HashMap) -> Self { - self.vars = vars; - self - } -} - -/// Settings-resolved stage output. Callers may inspect this before policy -/// checks, then move it into [`compile_graph`] after those checks pass. +/// Variable-substituted stage output. Callers may inspect the resolved +/// settings before policy checks, then move it into [`compile_and_pin`]. pub(crate) struct PreparedRun { - workflow_bundle: WorkflowBundle, - entrypoint: ManifestPath, - workflow: BundledWorkflow, - settings: WorkflowSettings, - cwd: PathBuf, - vars: HashMap, - configured_providers: Vec, - metadata: RunMetadata, + layered: LayeredRun, + vars: HashMap, } impl PreparedRun { - pub(crate) fn resolve_run_id(mut self) -> (Self, RunId) { - let run_id = self.metadata.run_id.unwrap_or_default(); - self.metadata.run_id = Some(run_id); - (self, run_id) - } - - pub(crate) fn with_web_url(mut self, web_url: Option) -> Self { - self.metadata.web_url = web_url; - self - } - - pub(crate) fn with_configured_providers( - mut self, - configured_providers: Vec, - ) -> Self { - self.configured_providers = configured_providers; - self - } - pub(crate) fn settings(&self) -> &WorkflowSettings { - &self.settings + &self.layered.settings } pub(crate) fn parent_id(&self) -> Option { - self.metadata.parent_id + self.layered.metadata.parent_id } } /// Graph-compiled stage output, retaining the metadata needed by later pure /// assembly. -pub(crate) struct GraphCompiledRun { - compiled: CompiledRun, - entrypoint: ManifestPath, - metadata: RunMetadata, +struct GraphCompiledRun { + compiled: CompiledRun, + metadata: CreateRunPersistenceMetadata, } -impl GraphCompiledRun { - #[cfg(test)] - pub(crate) fn compiled(&self) -> &CompiledRun { - &self.compiled - } -} - -/// Materialized stage output ready for pure persistence-input assembly. -pub(crate) struct PersistenceReadyRun { +/// Model-pinned stage output ready for pure persistence-input assembly. +pub(crate) struct PinnedRun { materialized: MaterializedRun, - entrypoint: ManifestPath, - metadata: RunMetadata, -} - -/// Complete output of this boundary. -pub(crate) struct RunCompilerOutput { - persistence_input: CreateRunPersistenceInput, - entrypoint: ManifestPath, -} - -impl RunCompilerOutput { - #[cfg(test)] - pub(crate) fn persistence_input(&self) -> &CreateRunPersistenceInput { - &self.persistence_input - } - - #[cfg(test)] - pub(crate) fn entrypoint(&self) -> &ManifestPath { - &self.entrypoint - } - - pub(crate) fn into_parts(self) -> (CreateRunPersistenceInput, ManifestPath) { - (self.persistence_input, self.entrypoint) - } + metadata: CreateRunPersistenceMetadata, } #[derive(Debug, thiserror::Error)] pub(crate) enum RunCompilerError { - #[error("invalid run source: {source}")] - InvalidSource { - #[source] - source: InvalidSourceError, - }, + /// The acquired source bundle is invalid: missing entrypoint or broken + /// bundled-file references. + #[error(transparent)] + InvalidSource(#[from] InvalidSourceError), - #[error("invalid run settings: {source}")] - InvalidSettings { - #[source] - source: Box, - }, + /// A settings source failed to parse, or the layered settings failed to + /// resolve. + #[error(transparent)] + InvalidSettings(Box), - #[error("run config variable interpolation failed: {source}")] - VariableInterpolation { - #[source] - source: VariableInterpolationError, - }, + /// The run-variable snapshot could not be substituted into the resolved + /// run settings. + #[error("Run config variable interpolation failed: {0}")] + VariableInterpolation(#[from] VariableInterpolationError), - #[error("workflow validation or parse failed: {source}")] - ValidationOrParse { - #[source] - source: WorkflowError, - }, - - #[error("model selection failed: {source}")] - ModelSelection { - #[source] - source: ModelSelectionError, - }, - - #[error("model reference failed: {source}")] - ModelReference { - #[source] - source: AmbiguousModelRef, - }, - - #[error("{context}")] - Internal { - context: &'static str, - #[source] - source: Box, - }, + /// Graph compilation or model pinning failed in the workflow engine. The + /// full [`WorkflowError`] is preserved so callers can distinguish + /// validation, parse, and model-selection failures. + #[error(transparent)] + Workflow(#[from] WorkflowError), } +// The `Display` strings below are pinned to the pre-extraction wire +// contract: both the create handler and the manifest preparation path render +// them directly into HTTP 400 details. #[derive(Debug, thiserror::Error)] pub(crate) enum InvalidSourceError { - #[error("bundle entrypoint {entrypoint} is missing from the workflow bundle")] + #[error("manifest target path is missing from workflows map")] MissingEntrypoint { entrypoint: ManifestPath }, - #[error("unsupported dockerfile reference {reference:?} in {config_path}")] + #[error("unsupported dockerfile reference: {reference}")] UnsupportedDockerfileReference { config_path: ManifestPath, reference: String, }, - #[error("bundled dockerfile {dockerfile_path} referenced by {config_path} is missing")] + #[error("missing bundled dockerfile: {dockerfile_path}")] MissingDockerfile { config_path: ManifestPath, dockerfile_path: ManifestPath, @@ -274,21 +198,17 @@ pub(crate) enum InvalidSourceError { #[derive(Debug, thiserror::Error)] pub(crate) enum InvalidSettingsError { - #[error("failed to parse {kind} settings at {path}")] + #[error("Failed to parse run config TOML")] Parse { - kind: &'static str, path: ManifestPath, #[source] source: Box, }, - #[error("failed to parse user settings: {source}")] - User { - #[source] - source: fabro_config::Error, - }, + #[error(transparent)] + User(fabro_config::Error), - #[error("failed to resolve layered workflow settings: {source}")] + #[error("failed to resolve manifest settings")] Resolve { #[source] source: fabro_config::ResolveErrors, @@ -310,8 +230,12 @@ pub(crate) enum VariableInterpolationError { pub(crate) type Result = std::result::Result; +fn invalid_settings(source: InvalidSettingsError) -> RunCompilerError { + RunCompilerError::InvalidSettings(Box::new(source)) +} + /// Normalize the bundle entrypoint and parse workflow/project settings while -/// resolving Dockerfile references against the selected workflow's files. +/// resolving dockerfile references against the selected workflow's files. pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result { let RawRunCompilerInput { workflow_bundle, @@ -326,13 +250,11 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result Result Result>>()?; @@ -390,24 +308,24 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result Result { let NormalizedRun { workflow_bundle, @@ -424,8 +342,6 @@ pub(crate) fn layer_settings(normalized: NormalizedRun) -> Result { cli_overrides, input_overrides, inline_goal_override, - vars, - configured_providers, metadata, } = normalized; let mut builder = WorkflowSettingsBuilder::new() @@ -444,18 +360,13 @@ pub(crate) fn layer_settings(normalized: NormalizedRun) -> Result { builder = builder.project_layer(layer); } for source in user_toml { - builder = - builder - .user_toml(&source) - .map_err(|source| RunCompilerError::InvalidSettings { - source: Box::new(InvalidSettingsError::User { source }), - })?; + builder = builder + .user_toml(&source) + .map_err(|source| invalid_settings(InvalidSettingsError::User(source)))?; } let mut settings = builder .build() - .map_err(|source| RunCompilerError::InvalidSettings { - source: Box::new(InvalidSettingsError::Resolve { source }), - })?; + .map_err(|source| invalid_settings(InvalidSettingsError::Resolve { source }))?; settings.run.inputs.extend(input_overrides); if let Some(goal) = inline_goal_override { settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal))); @@ -467,187 +378,124 @@ pub(crate) fn layer_settings(normalized: NormalizedRun) -> Result { workflow, settings, cwd, - vars, - configured_providers, metadata, }) } -/// Apply a run-variable snapshot and validate the resulting artifact globs. -pub(crate) fn apply_run_variables(mut layered: LayeredRun) -> Result { - substitute_variables(&layered.vars, &mut layered.settings)?; - Ok(PreparedRun { - workflow_bundle: layered.workflow_bundle, - entrypoint: layered.entrypoint, - workflow: layered.workflow, - settings: layered.settings, - cwd: layered.cwd, - vars: layered.vars, - configured_providers: layered.configured_providers, - metadata: layered.metadata, - }) +/// Apply a run-variable snapshot to the layered settings and validate the +/// resulting artifact globs. The snapshot is also retained for graph template +/// rendering during compilation. +pub(crate) fn apply_run_variables( + mut layered: LayeredRun, + vars: HashMap, +) -> Result { + substitute_run_variables(&vars, &mut layered.settings)?; + Ok(PreparedRun { layered, vars }) } -/// Run stage one and the settings/variables portion of stage two. This is the -/// convenient boundary for callers that already own a variable snapshot. -#[cfg(test)] -pub(crate) fn prepare_run(input: RawRunCompilerInput) -> Result { - apply_run_variables(layer_settings(normalize_source(input)?)?) -} - -/// Compile and validate the graph on Tokio's blocking pool. -pub(crate) async fn compile_graph( +/// Compile and validate the graph, then pin run-level model settings, in one +/// dispatch on Tokio's blocking pool: graph compilation is CPU-heavy and may +/// read a goal file, and pinning is pure CPU that belongs alongside it. +pub(crate) async fn compile_and_pin( prepared: PreparedRun, + configured_providers: Vec, + catalog: Arc, +) -> Result { + task::spawn_blocking(move || { + let compiled = compile_graph(prepared, configured_providers, Arc::clone(&catalog))?; + pin_models(compiled, &catalog) + }) + .await + .map_err(|source| { + RunCompilerError::Workflow(WorkflowError::engine_with_source( + "workflow create task failed", + source, + )) + })? +} + +/// Stage two's graph compilation: parse, transform, and validate through the +/// fabro-workflow pipeline, with undefined template variables promoted to +/// hard errors. +fn compile_graph( + prepared: PreparedRun, + configured_providers: Vec, catalog: Arc, ) -> Result { let PreparedRun { - workflow_bundle, - entrypoint, - workflow, - settings, - cwd, + layered: + LayeredRun { + workflow_bundle, + entrypoint, + workflow, + settings, + cwd, + metadata, + }, vars, - configured_providers, - metadata, } = prepared; - let compile_input = CreateRunCompileInput { - workflow: WorkflowInput::Bundled(workflow), - settings, - vars, - cwd, - workflow_path: Some(entrypoint.clone()), - workflow_bundle: Some(workflow_bundle), - configured_providers, - }; - let compiled = - task::spawn_blocking(move || operations::compile_create_run(compile_input, catalog)) - .await - .map_err(|source| RunCompilerError::Internal { - context: "workflow compilation failed", - source: Box::new(WorkflowError::engine_with_source( - "workflow create task failed", - source, - )), - })? - .map_err(classify_workflow_error)?; - - Ok(GraphCompiledRun { - compiled, - entrypoint, - metadata, - }) -} - -/// Materialize run-level model settings on Tokio's blocking pool. -pub(crate) async fn materialize_run( - compiled: GraphCompiledRun, - catalog: Arc, -) -> Result { - let GraphCompiledRun { - compiled, - entrypoint, - metadata, - } = compiled; - let materialized = task::spawn_blocking(move || { - operations::materialize_create_run(compiled, catalog.as_ref()) - }) - .await - .map_err(|source| RunCompilerError::Internal { - context: "workflow compilation failed", - source: Box::new(WorkflowError::engine_with_source( - "workflow create task failed", - source, - )), - })? - .map_err(classify_workflow_error)?; - - Ok(PersistenceReadyRun { - materialized, - entrypoint, - metadata, - }) -} - -/// Purely assemble the complete workflow persistence input. -pub(crate) fn assemble_run(ready: PersistenceReadyRun) -> RunCompilerOutput { - let PersistenceReadyRun { - materialized, - entrypoint, - metadata, - } = ready; - let RunMetadata { - run_id, - title, - parent_id, - git, - storage_root, - workflow_slug, - provenance, - web_url, - submitted_manifest_bytes, - automation, - } = metadata; - let persistence_input = operations::assemble_create_run_persistence_input( - materialized, - CreateRunPersistenceMetadata { - run_id: run_id.unwrap_or_default(), - storage_root, - workflow_slug, - submitted_manifest_bytes, - title, - automation, - git, - fork_source_ref: None, - parent_id, - provenance, - web_url, + let compiled = operations::compile_create_run( + CreateRunCompileInput { + workflow: WorkflowInput::Bundled(workflow), + settings, + vars, + cwd, + workflow_path: Some(entrypoint), + workflow_bundle: Some(workflow_bundle), + configured_providers, }, - ); + catalog, + )?; - RunCompilerOutput { - persistence_input, - entrypoint, - } + Ok(GraphCompiledRun { compiled, metadata }) } -/// Compile a raw run all the way to a complete persistence input. -#[cfg(test)] -pub(crate) async fn compile_run( - input: RawRunCompilerInput, - catalog: Arc, -) -> Result { - let prepared = prepare_run(input)?; - let compiled = compile_graph(prepared, Arc::clone(&catalog)).await?; - let materialized = materialize_run(compiled, catalog).await?; - Ok(assemble_run(materialized)) +/// Stage three: pin concrete model and provider selections against the +/// catalog and the configured provider set. +fn pin_models(compiled: GraphCompiledRun, catalog: &Catalog) -> Result { + let GraphCompiledRun { compiled, metadata } = compiled; + let materialized = operations::materialize_create_run(compiled, catalog)?; + Ok(PinnedRun { + materialized, + metadata, + }) } -fn parse_settings_layer( +/// Stage four: purely assemble the complete persistence input. Every durable +/// field — run id, submitted source bytes, automation reference — is set here +/// once; nothing mutates the result afterwards. +pub(crate) fn assemble_run(pinned: PinnedRun) -> CreateRunPersistenceInput { + let PinnedRun { + materialized, + metadata, + } = pinned; + operations::assemble_create_run_persistence_input(materialized, metadata) +} + +/// Parse one bundle-relative settings source, rejecting keys that are not +/// allowed for `settings_source` and inlining dockerfile references from the +/// bundled files. +/// +/// Parses via [`SettingsLayer`] so unknown nested keys (like a stale +/// `[server.integrations.github.permissions]` after the move to +/// `[run.integrations.github.permissions]`) trip `deny_unknown_fields`. +pub(crate) fn settings_layer_with_resolved_dockerfiles( source: &str, config_path: &ManifestPath, files: &HashMap, settings_source: SettingsSource, - kind: &'static str, ) -> Result { - let mut layer = - source - .parse::() - .map_err(|source| RunCompilerError::InvalidSettings { - source: Box::new(InvalidSettingsError::Parse { - kind, - path: config_path.clone(), - source: Box::new(source), - }), - })?; - parse::validate_settings_source(&layer, settings_source).map_err(|source| { - RunCompilerError::InvalidSettings { - source: Box::new(InvalidSettingsError::Parse { - kind, - path: config_path.clone(), - source: Box::new(source), - }), - } - })?; + let parse_error = |source: Box| { + invalid_settings(InvalidSettingsError::Parse { + path: config_path.clone(), + source, + }) + }; + let mut layer = source + .parse::() + .map_err(|source| parse_error(Box::new(source)))?; + parse::validate_settings_source(&layer, settings_source) + .map_err(|source| parse_error(Box::new(source)))?; resolve_dockerfiles(&mut layer, config_path, files)?; Ok(layer) } @@ -683,65 +531,41 @@ fn resolve_dockerfile( }; let reference = path.clone(); let dockerfile_path = ManifestPath::from_reference(config_path.parent_or_dot(), &reference) - .ok_or_else(|| RunCompilerError::InvalidSource { - source: InvalidSourceError::UnsupportedDockerfileReference { - config_path: config_path.clone(), - reference: reference.clone(), - }, + .ok_or_else(|| InvalidSourceError::UnsupportedDockerfileReference { + config_path: config_path.clone(), + reference: reference.clone(), })?; - let content = - files - .get(&dockerfile_path) - .cloned() - .ok_or_else(|| RunCompilerError::InvalidSource { - source: InvalidSourceError::MissingDockerfile { - config_path: config_path.clone(), - dockerfile_path: dockerfile_path.clone(), - }, - })?; + let content = files.get(&dockerfile_path).cloned().ok_or_else(|| { + InvalidSourceError::MissingDockerfile { + config_path: config_path.clone(), + dockerfile_path: dockerfile_path.clone(), + } + })?; image.dockerfile = Some(EnvironmentDockerfileLayer::Inline(content)); Ok(()) } -fn substitute_variables( +/// Substitute run-scoped variables into the resolved run settings, then +/// re-validate the artifact-include globs: a substituted variable can make a +/// previously-safe glob unsafe. +pub(crate) fn substitute_run_variables( variables: &HashMap, settings: &mut WorkflowSettings, -) -> Result<()> { +) -> std::result::Result<(), VariableInterpolationError> { settings .run - .substitute_variables(|name| variables.get(name).cloned()) - .map_err(|source| RunCompilerError::VariableInterpolation { - source: VariableInterpolationError::Interpolation(source), - })?; + .substitute_variables(|name| variables.get(name).cloned())?; for (index, pattern) in settings.run.artifacts.include.iter().enumerate() { - WorkspaceGlob::try_new(pattern).map_err(|source| { - RunCompilerError::VariableInterpolation { - source: VariableInterpolationError::ArtifactGlob { index, source }, - } - })?; + WorkspaceGlob::try_new(pattern) + .map_err(|source| VariableInterpolationError::ArtifactGlob { index, source })?; } Ok(()) } -fn classify_workflow_error(error: WorkflowError) -> RunCompilerError { - match error { - WorkflowError::ModelSelection(source) => RunCompilerError::ModelSelection { source }, - WorkflowError::ModelReference(source) => RunCompilerError::ModelReference { source }, - source @ (WorkflowError::Parse(_) | WorkflowError::ValidationFailed { .. }) => { - RunCompilerError::ValidationOrParse { source } - } - source => RunCompilerError::Internal { - context: "workflow compilation failed", - source: Box::new(source), - }, - } -} - #[cfg(test)] mod tests { use std::collections::HashMap; use std::error::Error as _; - use std::sync::Arc; use fabro_config::EnvironmentDockerfileLayer; use fabro_graphviz::graph::AttrValue; @@ -810,13 +634,11 @@ mod tests { cli_overrides: None, input_overrides: HashMap::new(), inline_goal_override: None, - vars: HashMap::new(), - run_id: Some(RunId::new()), + run_id: RunId::new(), title: None, parent_id: None, git: None, storage_root: PathBuf::from("/tmp/fabro-storage"), - configured_providers: Catalog::builtin().all_provider_ids().into_iter().collect(), workflow_slug: None, provenance: provenance(), web_url: None, @@ -825,8 +647,19 @@ mod tests { } } + fn test_provider_ids() -> Vec { + Catalog::builtin().all_provider_ids().into_iter().collect() + } + + fn prepare_run( + input: RawRunCompilerInput, + vars: HashMap, + ) -> Result { + apply_run_variables(layer_settings(normalize_source(input)?)?, vars) + } + #[test] - fn stage_one_rejects_missing_entrypoint() { + fn normalize_source_rejects_missing_entrypoint() { let mut input = raw_input(None, HashMap::new()); input.entrypoint = manifest_path("flows/missing.fabro"); @@ -834,26 +667,14 @@ mod tests { panic!("missing entrypoint should fail"); }; - assert!(matches!(error, RunCompilerError::InvalidSource { - source: InvalidSourceError::MissingEntrypoint { .. }, - })); + assert!(matches!( + error, + RunCompilerError::InvalidSource(InvalidSourceError::MissingEntrypoint { .. }) + )); } #[test] - fn unresolved_run_id_is_allocated_only_after_variables_are_applied() { - let mut input = raw_input(None, HashMap::new()); - input.run_id = None; - let normalized = normalize_source(input).expect("source should normalize"); - let layered = layer_settings(normalized).expect("settings should layer"); - let prepared = apply_run_variables(layered).expect("variables should apply"); - - let (prepared, run_id) = prepared.resolve_run_id(); - - assert_eq!(prepared.metadata.run_id, Some(run_id)); - } - - #[test] - fn stage_one_rejects_missing_dockerfile_and_preserves_source_chain() { + fn normalize_source_rejects_missing_dockerfile_with_pinned_message() { let workflow_toml = r#" _version = 1 @@ -865,17 +686,38 @@ dockerfile = { path = "Dockerfile" } panic!("missing dockerfile should fail"); }; - assert!(matches!(error, RunCompilerError::InvalidSource { - source: InvalidSourceError::MissingDockerfile { .. }, - })); - let source = error - .source() - .expect("top-level error should retain source"); - assert!(source.to_string().contains("Dockerfile")); + assert!(matches!( + error, + RunCompilerError::InvalidSource(InvalidSourceError::MissingDockerfile { .. }) + )); + assert_eq!( + error.to_string(), + "missing bundled dockerfile: flows/Dockerfile" + ); } #[test] - fn stage_one_resolves_bundled_dockerfile() { + fn normalize_source_preserves_settings_parse_source_chain() { + let workflow_toml = r#" +_version = 1 + +[run.unknown-table] +key = "value" +"#; + + let Err(error) = normalize_source(raw_input(Some(workflow_toml), HashMap::new())) else { + panic!("unknown settings key should fail"); + }; + + assert_eq!(error.to_string(), "Failed to parse run config TOML"); + let source = error + .source() + .expect("parse error should retain the TOML source"); + assert!(source.to_string().contains("unknown")); + } + + #[test] + fn normalize_source_resolves_bundled_dockerfile() { let workflow_toml = r#" _version = 1 @@ -960,11 +802,12 @@ owner = "{{ vars.owner }}" toml::Value::String("override".to_string()), ); input.inline_goal_override = Some("Ship {{ vars.owner }}".to_string()); - input - .vars - .insert("owner".to_string(), "payments".to_string()); - let prepared = prepare_run(input).expect("settings should prepare"); + let prepared = prepare_run( + input, + HashMap::from([("owner".to_string(), "payments".to_string())]), + ) + .expect("settings should prepare"); let settings = prepared.settings(); assert_eq!( @@ -999,47 +842,50 @@ _version = 1 [run.artifacts] include = ["reports/{{ vars.path }}/*.json"] "#; - let mut input = raw_input(Some(workflow_toml), HashMap::new()); - input - .vars - .insert("path".to_string(), "../secrets".to_string()); + let input = raw_input(Some(workflow_toml), HashMap::new()); - let Err(error) = prepare_run(input) else { + let Err(error) = prepare_run( + input, + HashMap::from([("path".to_string(), "../secrets".to_string())]), + ) else { panic!("unsafe artifact glob should fail"); }; - assert!(matches!(error, RunCompilerError::VariableInterpolation { - source: VariableInterpolationError::ArtifactGlob { .. }, - })); + assert!(matches!( + error, + RunCompilerError::VariableInterpolation(VariableInterpolationError::ArtifactGlob { + index: 0, + source: WorkspaceGlobError::ParentTraversal { .. }, + }) + )); } - #[tokio::test] - async fn graph_vars_are_hard_errors_and_successfully_render_when_present() { + #[test] + fn graph_vars_are_hard_errors_and_successfully_render_when_present() { let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let missing = prepare_run(raw_input(None, HashMap::new())) + let missing = prepare_run(raw_input(None, HashMap::new()), HashMap::new()) .expect("settings preparation should not compile graph vars"); - let Err(error) = compile_graph(missing, Arc::clone(&catalog)).await else { + let Err(error) = compile_graph(missing, test_provider_ids(), Arc::clone(&catalog)) else { panic!("missing graph variable should be a hard error"); }; - assert!(matches!(error, RunCompilerError::ValidationOrParse { - source: WorkflowError::ValidationFailed { .. }, - })); + assert!(matches!( + error, + RunCompilerError::Workflow(WorkflowError::ValidationFailed { .. }) + )); let mut input = raw_input(None, HashMap::new()); - input - .vars - .insert("owner".to_string(), "payments".to_string()); input.input_overrides.insert( "target".to_string(), toml::Value::String("checkout".to_string()), ); - let compiled = compile_graph( - prepare_run(input).expect("settings should prepare"), - catalog, + let prepared = prepare_run( + input, + HashMap::from([("owner".to_string(), "payments".to_string())]), ) - .await - .expect("graph variables should render"); - let work = &compiled.compiled().validated().graph().nodes["work"]; + .expect("settings should prepare"); + let compiled = compile_graph(prepared, test_provider_ids(), catalog) + .expect("graph variables should render"); + let work = &compiled.compiled.validated().graph().nodes["work"]; assert_eq!( work.attrs.get("prompt").and_then(AttrValue::as_str), @@ -1051,8 +897,8 @@ include = ["reports/{{ vars.path }}/*.json"] ); } - #[tokio::test] - async fn assembly_retains_entrypoint_and_run_metadata() { + #[test] + fn assembly_retains_entrypoint_and_run_metadata() { let run_id = RunId::new(); let parent_id = RunId::new(); let automation = AutomationRef { @@ -1062,28 +908,30 @@ include = ["reports/{{ vars.path }}/*.json"] }; let submitted = b"submitted manifest".to_vec(); let mut input = raw_input(None, HashMap::new()); - input.run_id = Some(run_id); + input.run_id = run_id; input.parent_id = Some(parent_id); input.title = Some("Compiler boundary".to_string()); input.workflow_slug = Some("compiler-boundary".to_string()); input.web_url = Some(format!("https://fabro.test/runs/{run_id}")); input.submitted_manifest_bytes = Some(submitted.clone()); input.automation = Some(automation.clone()); - input - .vars - .insert("owner".to_string(), "payments".to_string()); input.input_overrides.insert( "target".to_string(), toml::Value::String("checkout".to_string()), ); let expected_entrypoint = input.entrypoint.clone(); + let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let output = compile_run(input, Arc::new(Catalog::from_builtin().unwrap())) - .await - .expect("run should compile"); - let persistence = output.persistence_input(); + let prepared = prepare_run( + input, + HashMap::from([("owner".to_string(), "payments".to_string())]), + ) + .expect("settings should prepare"); + let compiled = compile_graph(prepared, test_provider_ids(), Arc::clone(&catalog)) + .expect("graph should compile"); + let pinned = pin_models(compiled, &catalog).expect("models should pin"); + let persistence = assemble_run(pinned); - assert_eq!(output.entrypoint(), &expected_entrypoint); assert_eq!(persistence.run_id(), run_id); assert_eq!(persistence.workflow_slug(), Some("compiler-boundary")); assert_eq!( diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 707b63931..86485cedf 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -7,11 +7,10 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use fabro_api::types; use fabro_auth::auth_issue_message; -use fabro_config::parse::{self, SettingsSource}; +use fabro_config::parse::SettingsSource; use fabro_config::{ - CliLayer, CliOutputLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, - MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder, parse_input_overrides, - parse_labels, project, + CliLayer, CliOutputLayer, EnvironmentLayer, MergeMap, RunLayer, SettingsLayer, + WorkflowSettingsBuilder, parse_input_overrides, parse_labels, project, }; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; @@ -47,6 +46,7 @@ use futures_util::stream::{self, StreamExt}; use tokio::process::Command; use tokio::time; +use crate::run_compiler; use crate::server::AppState; use crate::server_secrets::LlmClientResult; @@ -55,25 +55,8 @@ pub(crate) struct PreparedManifest { pub cwd: PathBuf, pub git: Option, pub root_source: String, - #[allow( - dead_code, - reason = "create now resolves identity in the run compiler adapter" - )] - pub run_id: Option, - #[allow( - dead_code, - reason = "create now resolves lineage in the run compiler adapter" - )] - pub parent_id: Option, - #[allow( - dead_code, - reason = "create now normalizes titles in the run compiler adapter" - )] - pub title: Option, pub settings: WorkflowSettings, pub target_path: ManifestPath, - #[allow(dead_code, reason = "create now owns the bundle through run_compiler")] - pub workflow_bundle: WorkflowBundle, pub workflow_input: BundledWorkflow, pub source_directory: PathBuf, } @@ -169,34 +152,36 @@ pub(crate) fn prepare_manifest_with_environment_defaults( { settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal.text))); } - let title = manifest + // Validation-only parses: the create path resolves title and identity in + // its manifest adapter, but preflight/validate keep rejecting invalid + // values with the same messages. + manifest .title .as_ref() .map(|title| fabro_types::normalize_explicit_run_title(title.as_str())) .transpose()?; + manifest + .run_id + .as_deref() + .map(str::parse::) + .transpose() + .context("invalid run ID")?; + manifest + .parent_id + .as_deref() + .map(str::parse::) + .transpose() + .context("invalid parent run ID")?; + let source_directory = project::resolve_working_directory_from_run(&settings.run, &cwd); Ok(PreparedManifest { - cwd: cwd.clone(), + cwd, git: manifest.git.clone(), root_source, - run_id: manifest - .run_id - .as_deref() - .map(str::parse::) - .transpose() - .context("invalid run ID")?, - parent_id: manifest - .parent_id - .as_deref() - .map(str::parse::) - .transpose() - .context("invalid parent run ID")?, - title, - settings: settings.clone(), + settings, target_path, - workflow_bundle, workflow_input, - source_directory: project::resolve_working_directory_from_run(&settings.run, &cwd), + source_directory, }) } @@ -359,16 +344,13 @@ fn settings_layer_with_resolved_dockerfiles( files: &HashMap, settings_source: SettingsSource, ) -> Result { - // Parse via `SettingsLayer` so unknown nested keys (like a stale - // `[server.integrations.github.permissions]` after the move to - // `[run.integrations.github.permissions]`) trip `deny_unknown_fields`. - let mut layer = source - .parse::() - .context("Failed to parse run config TOML")?; - parse::validate_settings_source(&layer, settings_source) - .context("Failed to parse run config TOML")?; - resolve_manifest_dockerfiles(&mut layer, config_path, files)?; - Ok(layer) + run_compiler::settings_layer_with_resolved_dockerfiles( + source, + config_path, + files, + settings_source, + ) + .map_err(anyhow::Error::new) } pub(crate) fn manifest_args_overrides( @@ -408,50 +390,6 @@ pub(crate) fn manifest_args_overrides( }) } -fn resolve_manifest_dockerfiles( - layer: &mut SettingsLayer, - config_path: &ManifestPath, - files: &HashMap, -) -> Result<()> { - for environment in layer.environments.values_mut() { - if let Some(image) = environment.image.as_mut() { - resolve_manifest_dockerfile(image, config_path, files)?; - } - } - if let Some(image) = layer - .run - .as_mut() - .and_then(|run| run.environment.as_mut()) - .and_then(|environment| environment.image.as_mut()) - { - resolve_manifest_dockerfile(image, config_path, files)?; - } - Ok(()) -} - -fn resolve_manifest_dockerfile( - image: &mut EnvironmentImageLayer, - config_path: &ManifestPath, - files: &HashMap, -) -> Result<()> { - let source = image.dockerfile.as_mut(); - let Some(source) = source else { - return Ok(()); - }; - let EnvironmentDockerfileLayer::Path { path } = &*source else { - return Ok(()); - }; - let path_owned = path.clone(); - let manifest_path = ManifestPath::from_reference(config_path.parent_or_dot(), &path_owned) - .ok_or_else(|| anyhow!("unsupported dockerfile reference: {path_owned}"))?; - let content = files - .get(&manifest_path) - .cloned() - .ok_or_else(|| anyhow!("missing bundled dockerfile: {manifest_path}"))?; - *source = EnvironmentDockerfileLayer::Inline(content); - Ok(()) -} - pub(crate) fn manifest_project_config_path( config: &types::ManifestConfig, cwd: &Path, diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 21451adf8..977cda2e3 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -3,6 +3,7 @@ use std::io::ErrorKind; use std::path::PathBuf; use std::sync::Arc; +use anyhow::Context as _; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; @@ -14,24 +15,22 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use bytes::Bytes; use chrono::{DateTime, Utc}; use fabro_api::types::{ - BoardColumn, ManifestGoalType, RunManifest, SubmitAnswerRequest, UpdateRunParentRequest, - UpdateRunRequest, + BoardColumn, ManifestConfigType, ManifestGoalType, RunManifest, SubmitAnswerRequest, + UpdateRunParentRequest, UpdateRunRequest, }; -use fabro_config::Storage; +use fabro_config::{CliLayer, RunLayer, Storage}; use fabro_interview::AnswerSubmission; use fabro_llm::client::Client as LlmClient; use fabro_store::{ RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryVisibility, }; -use fabro_types::settings::ResolveError; use fabro_types::{ - AutomationRef, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance, - RunServerProvenance, RunStatusKind, StageContextWindow, StageContextWindowStaleness, - StageContextWindowUnavailableReason, StageHandler, StageModelUsage, StageProjection, - SystemActorKind, WorkflowSettings, parse_blob_ref, + AutomationRef, GitContext, ManifestPath, Principal, Run, RunClientProvenance, RunId, + RunProvenance, RunServerProvenance, RunStatusKind, StageContextWindow, + StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler, + StageModelUsage, StageProjection, SystemActorKind, parse_blob_ref, }; use fabro_util::version::FABRO_VERSION; -use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError}; use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice}; use fabro_workflow::run_status::RunStatus; use fabro_workflow::workflow_bundle::WorkflowBundle; @@ -562,34 +561,32 @@ struct ManifestRunCompilerAdapter { cwd: PathBuf, project_settings: Vec, user_toml: Vec, - run_overrides: Option, - cli_overrides: Option, + run_overrides: Option, + cli_overrides: Option, input_overrides: HashMap, inline_goal_override: Option, run_id: Option, parent_id: Option, title: Option, - git: Option, + git: Option, } fn adapt_manifest_for_run_compiler( manifest: &RunManifest, explicit_run_id: Option, ) -> anyhow::Result { - use anyhow::{Context as _, anyhow, bail}; - use fabro_api::types::ManifestConfigType; - if manifest.version != 1 { - bail!("unsupported manifest version {}", manifest.version); + anyhow::bail!("unsupported manifest version {}", manifest.version); } let cwd = PathBuf::from(&manifest.cwd); let entrypoint = ManifestPath::from_wire(&manifest.target.path) - .ok_or_else(|| anyhow!("invalid manifest target path: {}", manifest.target.path))?; + .ok_or_else(|| anyhow::anyhow!("invalid manifest target path: {}", manifest.target.path))?; let workflow_bundle = run_manifest::workflow_bundle_from_manifest(&manifest.workflows)?; + // The compiler rejects a missing entrypoint with this same message, but + // checking here keeps the legacy error precedence: a missing entrypoint + // wins over invalid args, title, or run IDs. if workflow_bundle.workflow(&entrypoint).is_none() { - return Err(anyhow!( - "manifest target path is missing from workflows map" - )); + anyhow::bail!("manifest target path is missing from workflows map"); } let overrides = run_manifest::manifest_args_overrides(manifest.args.as_ref()) .context("failed to parse manifest args")?; @@ -651,53 +648,28 @@ fn adapt_manifest_for_run_compiler( }) } -fn compiler_preparation_error_response(error: RunCompilerError) -> Response { - use run_compiler::{InvalidSettingsError, InvalidSourceError}; - - let detail = match error { - RunCompilerError::InvalidSource { source } => match source { - InvalidSourceError::MissingEntrypoint { .. } => { - "manifest target path is missing from workflows map".to_string() - } - InvalidSourceError::UnsupportedDockerfileReference { reference, .. } => { - format!("unsupported dockerfile reference: {reference}") - } - InvalidSourceError::MissingDockerfile { - dockerfile_path, .. - } => format!("missing bundled dockerfile: {dockerfile_path}"), - }, - RunCompilerError::InvalidSettings { source } => match *source { - InvalidSettingsError::Parse { .. } => "Failed to parse run config TOML".to_string(), - InvalidSettingsError::User { source } => source.to_string(), - InvalidSettingsError::Resolve { .. } => { - "failed to resolve manifest settings".to_string() - } - }, - RunCompilerError::VariableInterpolation { source } => { - format!("Run config variable interpolation failed: {source}") - } - other => return compiler_execution_error_response(other), - }; - ApiError::bad_request(detail).into_response() -} - -fn compiler_execution_error_response(error: RunCompilerError) -> Response { +/// Map a [`RunCompilerError`] onto the create endpoint's pre-extraction wire +/// contract. The 400 details for source, settings, and interpolation errors +/// are the error types' own `Display` strings, which are pinned to the +/// legacy messages. +fn run_compiler_error_response(error: RunCompilerError) -> Response { match error { - RunCompilerError::ValidationOrParse { .. } => { - ApiError::bad_request("Validation failed").into_response() + RunCompilerError::InvalidSource(_) + | RunCompilerError::InvalidSettings(_) + | RunCompilerError::VariableInterpolation(_) => { + ApiError::bad_request(error.to_string()).into_response() } - RunCompilerError::ModelSelection { source } => { - ApiError::bad_request(format!("Model selection failed: {source}")).into_response() - } - RunCompilerError::ModelReference { source } => { - ApiError::bad_request(format!("Model reference failed: {source}")).into_response() - } - RunCompilerError::Internal { source, .. } => ApiError::new( + RunCompilerError::Workflow( + WorkflowError::ValidationFailed { .. } | WorkflowError::Parse(_), + ) => ApiError::bad_request("Validation failed").into_response(), + RunCompilerError::Workflow( + err @ (WorkflowError::ModelSelection(_) | WorkflowError::ModelReference(_)), + ) => ApiError::bad_request(err.to_string()).into_response(), + RunCompilerError::Workflow(err) => ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to persist run state: {source}"), + format!("Failed to persist run state: {err}"), ) .into_response(), - other => compiler_preparation_error_response(other), } } @@ -721,7 +693,8 @@ pub(crate) async fn create_run_from_manifest( Ok(adapter) => adapter, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; - let provenance = run_provenance(&headers, &actor); + let run_id = manifest_adapter.run_id.unwrap_or_default(); + let title_generation_target = manifest_adapter.entrypoint.clone(); let raw_compiler_input = RawRunCompilerInput { workflow_bundle: manifest_adapter.workflow_bundle, entrypoint: manifest_adapter.entrypoint, @@ -735,26 +708,24 @@ pub(crate) async fn create_run_from_manifest( cli_overrides: manifest_adapter.cli_overrides, input_overrides: manifest_adapter.input_overrides, inline_goal_override: manifest_adapter.inline_goal_override, - vars: HashMap::new(), - run_id: manifest_adapter.run_id, + run_id, title: manifest_adapter.title, parent_id: manifest_adapter.parent_id, git: manifest_adapter.git, storage_root: state.server_storage_dir(), - configured_providers: Vec::new(), workflow_slug: None, - provenance, - web_url: None, + provenance: run_provenance(&headers, &actor), + web_url: state.run_web_url(&run_id), submitted_manifest_bytes: Some(submitted_manifest_bytes), automation, }; let normalized = match run_compiler::normalize_source(raw_compiler_input) { Ok(normalized) => normalized, - Err(err) => return compiler_preparation_error_response(err), + Err(err) => return run_compiler_error_response(err), }; let layered = match run_compiler::layer_settings(normalized) { Ok(layered) => layered, - Err(err) => return compiler_preparation_error_response(err), + Err(err) => return run_compiler_error_response(err), }; let vars = match snapshot_run_variables(&state).await { Ok(vars) => vars, @@ -763,12 +734,10 @@ pub(crate) async fn create_run_from_manifest( .into_response(); } }; - let prepared = match run_compiler::apply_run_variables(layered.with_vars(vars)) { + let prepared = match run_compiler::apply_run_variables(layered, vars) { Ok(prepared) => prepared, - Err(err) => return compiler_preparation_error_response(err), + Err(err) => return run_compiler_error_response(err), }; - let (prepared, run_id) = prepared.resolve_run_id(); - let prepared = prepared.with_web_url(state.run_web_url(&run_id)); let provider = run_manifest::effective_sandbox_provider(&prepared.settings().run); if let Some(error) = run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider) @@ -805,17 +774,14 @@ pub(crate) async fn create_run_from_manifest( ready_provider_ids.clone() } }; - let prepared = prepared.with_configured_providers(run_materialization_provider_ids); - let compiled = match run_compiler::compile_graph(prepared, Arc::clone(&catalog)).await { - Ok(compiled) => compiled, - Err(err) => return compiler_execution_error_response(err), - }; - let materialized = match run_compiler::materialize_run(compiled, catalog).await { - Ok(materialized) => materialized, - Err(err) => return compiler_execution_error_response(err), - }; - let compiler_output = run_compiler::assemble_run(materialized); - let (persistence_input, title_generation_target) = compiler_output.into_parts(); + let pinned = + match run_compiler::compile_and_pin(prepared, run_materialization_provider_ids, catalog) + .await + { + Ok(pinned) => pinned, + Err(err) => return run_compiler_error_response(err), + }; + let persistence_input = run_compiler::assemble_run(pinned); let created = match Box::pin(operations::persist_create_run( state.stores.runs.as_ref(), persistence_input, @@ -1011,7 +977,7 @@ async fn run_preflight( .into_response(); } }; - if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) { + if let Err(err) = run_compiler::substitute_run_variables(&vars, &mut prepared.settings) { return ApiError::bad_request(format!("Run config variable interpolation failed: {err}")) .into_response(); } @@ -1064,7 +1030,7 @@ async fn validate_run_manifest( .into_response(); } }; - if let Err(err) = substitute_run_variables(&vars, &mut prepared.settings) { + if let Err(err) = run_compiler::substitute_run_variables(&vars, &mut prepared.settings) { return ApiError::bad_request(format!("Run config variable interpolation failed: {err}")) .into_response(); } @@ -1092,33 +1058,6 @@ async fn snapshot_run_variables( state.stores.variables.value_map().await } -#[derive(Debug, thiserror::Error)] -enum RunVariableSubstitutionError { - #[error(transparent)] - Interpolation(#[from] ResolveError), - - #[error("run.artifacts.include[{index}]: {source}")] - ArtifactGlob { - index: usize, - #[source] - source: WorkspaceGlobError, - }, -} - -fn substitute_run_variables( - variables: &HashMap, - settings: &mut WorkflowSettings, -) -> Result<(), RunVariableSubstitutionError> { - settings - .run - .substitute_variables(|name| variables.get(name).cloned())?; - for (index, pattern) in settings.run.artifacts.include.iter().enumerate() { - WorkspaceGlob::try_new(pattern) - .map_err(|source| RunVariableSubstitutionError::ArtifactGlob { index, source })?; - } - Ok(()) -} - async fn get_run_status( RequireRunManagementTarget(id, _actor): RequireRunManagementTarget, State(state): State>, @@ -1421,26 +1360,3 @@ fn build_command_log_response( }) .into_response() } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_artifact_glob_that_becomes_unsafe_after_interpolation() { - let variables = HashMap::from([("PATTERN".to_string(), "../outside/**".to_string())]); - let mut settings = WorkflowSettings::default(); - settings.run.artifacts.include = vec!["{{ vars.PATTERN }}".to_string()]; - - let error = substitute_run_variables(&variables, &mut settings) - .expect_err("interpolated parent traversal should be rejected"); - - assert!(matches!( - error, - RunVariableSubstitutionError::ArtifactGlob { - index: 0, - source: WorkspaceGlobError::ParentTraversal { .. }, - } - )); - } -} diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index f238fbc40..780599b02 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -22,7 +22,6 @@ use tokio::task::spawn_blocking; use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; use crate::error::Error; use crate::event::{Event, append_event, to_run_event_at}; -use crate::file_resolver::FileResolver; use crate::pipeline::types::PersistOptions; use crate::pipeline::{self, Persisted, TransformOptions, Validated}; use crate::records::RunSpec; @@ -57,8 +56,62 @@ pub struct CreateRunInput { pub web_url: Option, } +impl CreateRunInput { + /// Split into the compile-stage input and the persistence metadata for + /// `run_id`, the two halves of the create pipeline. + fn into_stages( + self, + run_id: RunId, + storage_root: PathBuf, + ) -> (CreateRunCompileInput, CreateRunPersistenceMetadata) { + let Self { + workflow, + settings, + vars, + cwd, + workflow_slug, + workflow_path, + workflow_bundle, + submitted_manifest_bytes, + run_id: _, + title, + automation, + git, + fork_source_ref, + parent_id, + provenance, + configured_providers, + web_url, + } = self; + ( + CreateRunCompileInput { + workflow, + settings, + vars, + cwd, + workflow_path, + workflow_bundle, + configured_providers, + }, + CreateRunPersistenceMetadata { + run_id, + storage_root, + workflow_slug, + submitted_manifest_bytes, + title, + automation, + git, + fork_source_ref, + parent_id, + provenance, + web_url, + }, + ) + } +} + /// Inputs needed to resolve and compile a workflow for run creation. -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct CreateRunCompileInput { pub workflow: WorkflowInput, pub settings: WorkflowSettings, @@ -72,7 +125,7 @@ pub struct CreateRunCompileInput { /// Durable metadata joined to a materialized workflow before persistence. /// `run_id` is already resolved, and `storage_root` is used to derive the /// run's scratch directory during pure input assembly. -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct CreateRunPersistenceMetadata { pub run_id: RunId, pub storage_root: PathBuf, @@ -105,8 +158,6 @@ pub struct CompiledRun { workflow_slug: Option, workflow_config: Option, dot_path: Option, - current_dir: Option, - file_resolver: Option>, definition: Option, source_directory: String, labels: HashMap, @@ -121,56 +172,23 @@ impl CompiledRun { pub fn settings(&self) -> &WorkflowSettings { &self.settings } - - pub fn resolved_source(&self) -> &str { - &self.raw_source - } - - pub fn workflow_slug(&self) -> Option<&str> { - self.workflow_slug.as_deref() - } - - pub fn workflow_config(&self) -> Option<&str> { - self.workflow_config.as_deref() - } - - pub fn dot_path(&self) -> Option<&Path> { - self.dot_path.as_deref() - } - - pub fn current_dir(&self) -> Option<&Path> { - self.current_dir.as_deref() - } - - pub fn file_resolver(&self) -> Option> { - self.file_resolver.clone() - } - - pub fn definition(&self) -> Option<&RunDefinition> { - self.definition.as_ref() - } - - pub fn source_directory(&self) -> &str { - &self.source_directory - } - - pub fn labels(&self) -> &HashMap { - &self.labels - } } /// Compiled workflow with its run-level model settings materialized against /// the same provider snapshot used during compilation. pub struct MaterializedRun { - compiled: CompiledRun, - settings: WorkflowSettings, + validated: Validated, + settings: WorkflowSettings, + raw_source: String, + workflow_slug: Option, + workflow_config: Option, + dot_path: Option, + definition: Option, + source_directory: String, + labels: HashMap, } impl MaterializedRun { - pub fn compiled(&self) -> &CompiledRun { - &self.compiled - } - pub fn settings(&self) -> &WorkflowSettings { &self.settings } @@ -219,7 +237,7 @@ impl CreateRunPersistenceInput { } pub fn definition(&self) -> Option<&RunDefinition> { - self.materialized.compiled.definition.as_ref() + self.materialized.definition.as_ref() } } @@ -233,53 +251,12 @@ pub async fn create( ) -> Result { let run_id = request.run_id.unwrap_or_default(); let persistence_input = spawn_blocking(move || { - let CreateRunInput { - workflow, - settings, - vars, - cwd, - workflow_slug, - workflow_path, - workflow_bundle, - submitted_manifest_bytes, - run_id: _, - title, - automation, - git, - fork_source_ref, - parent_id, - provenance, - configured_providers, - web_url, - } = request; - let compiled = compile_create_run( - CreateRunCompileInput { - workflow, - settings, - vars, - cwd, - workflow_path, - workflow_bundle, - configured_providers, - }, - Arc::clone(&catalog), - )?; + let (compile_input, metadata) = request.into_stages(run_id, storage_root); + let compiled = compile_create_run(compile_input, Arc::clone(&catalog))?; let materialized = materialize_create_run(compiled, catalog.as_ref())?; Ok::<_, Error>(assemble_create_run_persistence_input( materialized, - CreateRunPersistenceMetadata { - run_id, - storage_root, - workflow_slug, - submitted_manifest_bytes, - title, - automation, - git, - fork_source_ref, - parent_id, - provenance, - web_url, - }, + metadata, )) }) .await @@ -374,8 +351,6 @@ pub fn compile_create_run( workflow_slug: resolved.workflow_slug, workflow_config, dot_path: resolved.dot_path, - current_dir: resolved.current_dir, - file_resolver: resolved.file_resolver, definition, source_directory: resolved.working_directory.to_string_lossy().to_string(), labels, @@ -388,13 +363,35 @@ pub fn materialize_create_run( compiled: CompiledRun, catalog: &Catalog, ) -> Result { + let CompiledRun { + validated, + settings, + raw_source, + workflow_slug, + workflow_config, + dot_path, + definition, + source_directory, + labels, + configured_providers, + } = compiled; let settings = run_materialization::materialize_run( - compiled.settings.clone(), - compiled.validated.graph(), + settings, + validated.graph(), catalog, - &compiled.configured_providers, + &configured_providers, )?; - Ok(MaterializedRun { compiled, settings }) + Ok(MaterializedRun { + validated, + settings, + raw_source, + workflow_slug, + workflow_config, + dot_path, + definition, + source_directory, + labels, + }) } /// Assemble all inputs needed for persistence without I/O or recompilation. @@ -419,7 +416,7 @@ pub fn assemble_create_run_persistence_input( .run_scratch(&run_id) .root() .to_path_buf(); - let workflow_slug = workflow_slug.or_else(|| materialized.compiled.workflow_slug.clone()); + let workflow_slug = workflow_slug.or_else(|| materialized.workflow_slug.clone()); CreateRunPersistenceInput { materialized, @@ -456,21 +453,17 @@ pub async fn persist_create_run( provenance, web_url, } = input; - let MaterializedRun { compiled, settings } = materialized; - let CompiledRun { + let MaterializedRun { validated, - settings: _, + settings, raw_source, workflow_slug: _, workflow_config, dot_path, - current_dir: _, - file_resolver: _, definition, source_directory, labels, - configured_providers: _, - } = compiled; + } = materialized; let persisted_run_dir = run_dir.clone(); let persisted = spawn_blocking(move || { let run_spec = RunSpec { @@ -785,15 +778,10 @@ reasoning = false } fn compile_input(request: &CreateRunInput) -> CreateRunCompileInput { - CreateRunCompileInput { - workflow: request.workflow.clone(), - settings: request.settings.clone(), - vars: request.vars.clone(), - cwd: request.cwd.clone(), - workflow_path: request.workflow_path.clone(), - workflow_bundle: request.workflow_bundle.clone(), - configured_providers: request.configured_providers.clone(), - } + let (compile_input, _) = request + .clone() + .into_stages(RunId::new(), PathBuf::from("/tmp/storage")); + compile_input } fn persistence_metadata( @@ -801,19 +789,10 @@ reasoning = false run_id: RunId, storage_root: &Path, ) -> CreateRunPersistenceMetadata { - CreateRunPersistenceMetadata { - run_id, - storage_root: storage_root.to_path_buf(), - workflow_slug: request.workflow_slug.clone(), - submitted_manifest_bytes: request.submitted_manifest_bytes.clone(), - title: request.title.clone(), - automation: request.automation.clone(), - git: request.git.clone(), - fork_source_ref: request.fork_source_ref.clone(), - parent_id: request.parent_id, - provenance: request.provenance.clone(), - web_url: request.web_url.clone(), - } + let (_, metadata) = request + .clone() + .into_stages(run_id, storage_root.to_path_buf()); + metadata } fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated { @@ -1722,11 +1701,9 @@ reasoning = false ) .unwrap(); - assert_eq!(compiled.resolved_source(), MINIMAL_DOT); - assert_eq!(compiled.current_dir(), Some(Path::new("workflows"))); - assert_eq!(compiled.dot_path(), Some(workflow_path.as_path())); - assert!(compiled.file_resolver().is_some()); - assert_eq!(compiled.labels(), &compiled.settings().combined_labels()); + assert_eq!(compiled.raw_source, MINIMAL_DOT); + assert_eq!(compiled.dot_path.as_deref(), Some(workflow_path.as_path())); + assert_eq!(compiled.labels, compiled.settings().combined_labels()); let materialized = materialize_create_run(compiled, test_catalog().as_ref()).unwrap(); let input = assemble_create_run_persistence_input(materialized, CreateRunPersistenceMetadata { @@ -1788,7 +1765,7 @@ reasoning = false .unwrap(); let compiled = compile_create_run(compile_input(&request), Arc::clone(&catalog)).unwrap(); assert_eq!( - compiled.workflow_config(), + compiled.workflow_config.as_deref(), Some("_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n") ); From bca63e75c6dc12fae82ce1e29766689c36436466 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 03:31:48 +0000 Subject: [PATCH 28/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): simplify_sol (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 7 ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/apps/fabro-server/src/run_compiler.rs | 131 ++++++++++++++---- lib/apps/fabro-server/src/run_manifest.rs | 5 +- .../fabro-server/src/server/handler/runs.rs | 105 ++++++++------ lib/apps/fabro-server/src/server/tests.rs | 99 +++++++++++++ 4 files changed, 272 insertions(+), 68 deletions(-) diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs index a49655ce5..437df572b 100644 --- a/lib/apps/fabro-server/src/run_compiler.rs +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -25,11 +25,10 @@ //! [`RunCompilerError`] into HTTP responses. use std::collections::HashMap; -use std::error::Error as StdError; use std::path::PathBuf; use std::sync::Arc; -use fabro_config::parse::{self, SettingsSource}; +use fabro_config::parse::{self, ParseError, SettingsSource}; use fabro_config::{ CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder, @@ -49,14 +48,23 @@ use fabro_workflow::operations::{ use fabro_workflow::workflow_bundle::{BundledWorkflow, WorkflowBundle}; use tokio::task; -/// One project settings source already normalized into the manifest path -/// namespace used by the workflow bundle. +/// One project settings source in the acquired source's path namespace. #[derive(Debug)] pub(crate) struct ProjectSettingsSource { - pub(crate) path: ManifestPath, + pub(crate) path: std::result::Result, pub(crate) toml: String, } +/// A project settings path that the source adapter could not normalize. +#[derive(Debug, thiserror::Error)] +pub(crate) enum ProjectSettingsPathError { + #[error("project settings path is missing")] + Missing, + + #[error("invalid project settings path: {path}")] + Invalid { path: String }, +} + /// Transport-neutral inputs for compiling one submitted run. /// /// Identity (`run_id`), lineage, title, git metadata, and provenance are @@ -77,7 +85,7 @@ pub(crate) struct RawRunCompilerInput { pub(crate) cli_overrides: Option, pub(crate) input_overrides: HashMap, pub(crate) inline_goal_override: Option, - pub(crate) run_id: RunId, + pub(crate) run_id: Option, pub(crate) title: Option, pub(crate) parent_id: Option, pub(crate) git: Option, @@ -106,7 +114,20 @@ pub(crate) struct NormalizedRun { cli_overrides: Option, input_overrides: HashMap, inline_goal_override: Option, - metadata: CreateRunPersistenceMetadata, + metadata: RunMetadata, +} + +struct RunMetadata { + run_id: Option, + storage_root: PathBuf, + workflow_slug: Option, + submitted_manifest_bytes: Option>, + title: Option, + automation: Option, + git: Option, + parent_id: Option, + provenance: RunProvenance, + web_url: Option, } /// Settings-layered output. Variable substitution is a separate stage so @@ -118,7 +139,7 @@ pub(crate) struct LayeredRun { workflow: BundledWorkflow, settings: WorkflowSettings, cwd: PathBuf, - metadata: CreateRunPersistenceMetadata, + metadata: RunMetadata, } /// Variable-substituted stage output. Callers may inspect the resolved @@ -133,22 +154,45 @@ impl PreparedRun { &self.layered.settings } + pub(crate) fn with_identity( + mut self, + run_id: Option, + parent_id: Option, + title: Option, + ) -> Self { + self.layered.metadata.run_id = run_id; + self.layered.metadata.parent_id = parent_id; + self.layered.metadata.title = title; + self + } + pub(crate) fn parent_id(&self) -> Option { self.layered.metadata.parent_id } + + pub(crate) fn resolve_run_id(mut self) -> (Self, RunId) { + let run_id = self.layered.metadata.run_id.unwrap_or_default(); + self.layered.metadata.run_id = Some(run_id); + (self, run_id) + } + + pub(crate) fn with_web_url(mut self, web_url: Option) -> Self { + self.layered.metadata.web_url = web_url; + self + } } /// Graph-compiled stage output, retaining the metadata needed by later pure /// assembly. struct GraphCompiledRun { compiled: CompiledRun, - metadata: CreateRunPersistenceMetadata, + metadata: RunMetadata, } /// Model-pinned stage output ready for pure persistence-input assembly. pub(crate) struct PinnedRun { materialized: MaterializedRun, - metadata: CreateRunPersistenceMetadata, + metadata: RunMetadata, } #[derive(Debug, thiserror::Error)] @@ -158,7 +202,7 @@ pub(crate) enum RunCompilerError { #[error(transparent)] InvalidSource(#[from] InvalidSourceError), - /// A settings source failed to parse, or the layered settings failed to + /// A settings source or path is invalid, or the layered settings failed to /// resolve. #[error(transparent)] InvalidSettings(Box), @@ -202,7 +246,7 @@ pub(crate) enum InvalidSettingsError { Parse { path: ManifestPath, #[source] - source: Box, + source: ParseError, }, #[error(transparent)] @@ -213,6 +257,9 @@ pub(crate) enum InvalidSettingsError { #[source] source: fabro_config::ResolveErrors, }, + + #[error("{}", project_path_error(.source))] + ProjectPath { source: ProjectSettingsPathError }, } #[derive(Debug, thiserror::Error)] @@ -230,6 +277,17 @@ pub(crate) enum VariableInterpolationError { pub(crate) type Result = std::result::Result; +fn project_path_error(source: &ProjectSettingsPathError) -> String { + match source { + ProjectSettingsPathError::Missing => { + "invalid manifest project config path: missing path".to_string() + } + ProjectSettingsPathError::Invalid { path } => { + format!("invalid manifest project config path: {path}") + } + } +} + fn invalid_settings(source: InvalidSettingsError) -> RunCompilerError { RunCompilerError::InvalidSettings(Box::new(source)) } @@ -284,9 +342,12 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result Result Result CreateRunPersistenceInput { materialized, metadata, } = pinned; - operations::assemble_create_run_persistence_input(materialized, metadata) + let RunMetadata { + run_id, + storage_root, + workflow_slug, + submitted_manifest_bytes, + title, + automation, + git, + parent_id, + provenance, + web_url, + } = metadata; + operations::assemble_create_run_persistence_input(materialized, CreateRunPersistenceMetadata { + run_id: run_id.expect("run ID should be resolved before compilation"), + storage_root, + workflow_slug, + submitted_manifest_bytes, + title, + automation, + git, + fork_source_ref: None, + parent_id, + provenance, + web_url, + }) } /// Parse one bundle-relative settings source, rejecting keys that are not @@ -485,17 +569,14 @@ pub(crate) fn settings_layer_with_resolved_dockerfiles( files: &HashMap, settings_source: SettingsSource, ) -> Result { - let parse_error = |source: Box| { + let parse_error = |source| { invalid_settings(InvalidSettingsError::Parse { path: config_path.clone(), source, }) }; - let mut layer = source - .parse::() - .map_err(|source| parse_error(Box::new(source)))?; - parse::validate_settings_source(&layer, settings_source) - .map_err(|source| parse_error(Box::new(source)))?; + let mut layer = source.parse::().map_err(parse_error)?; + parse::validate_settings_source(&layer, settings_source).map_err(parse_error)?; resolve_dockerfiles(&mut layer, config_path, files)?; Ok(layer) } @@ -634,7 +715,7 @@ mod tests { cli_overrides: None, input_overrides: HashMap::new(), inline_goal_override: None, - run_id: RunId::new(), + run_id: Some(RunId::new()), title: None, parent_id: None, git: None, @@ -765,7 +846,7 @@ include = ["reports/{{ vars.owner }}/*.json"] "#; let mut input = raw_input(Some(workflow_toml), HashMap::new()); input.project_settings.push(ProjectSettingsSource { - path: manifest_path(".fabro/project.toml"), + path: Ok(manifest_path(".fabro/project.toml")), toml: r#" _version = 1 @@ -908,7 +989,7 @@ include = ["reports/{{ vars.path }}/*.json"] }; let submitted = b"submitted manifest".to_vec(); let mut input = raw_input(None, HashMap::new()); - input.run_id = run_id; + input.run_id = Some(run_id); input.parent_id = Some(parent_id); input.title = Some("Compiler boundary".to_string()); input.workflow_slug = Some("compiler-boundary".to_string()); diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 86485cedf..df7f20603 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -152,9 +152,6 @@ pub(crate) fn prepare_manifest_with_environment_defaults( { settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal.text))); } - // Validation-only parses: the create path resolves title and identity in - // its manifest adapter, but preflight/validate keep rejecting invalid - // values with the same messages. manifest .title .as_ref() @@ -390,7 +387,7 @@ pub(crate) fn manifest_args_overrides( }) } -pub(crate) fn manifest_project_config_path( +fn manifest_project_config_path( config: &types::ManifestConfig, cwd: &Path, ) -> Result { diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 977cda2e3..6048ed046 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -25,10 +25,10 @@ use fabro_store::{ RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryVisibility, }; use fabro_types::{ - AutomationRef, GitContext, ManifestPath, Principal, Run, RunClientProvenance, RunId, - RunProvenance, RunServerProvenance, RunStatusKind, StageContextWindow, - StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler, - StageModelUsage, StageProjection, SystemActorKind, parse_blob_ref, + AutomationRef, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance, + RunServerProvenance, RunStatusKind, StageContextWindow, StageContextWindowStaleness, + StageContextWindowUnavailableReason, StageHandler, StageModelUsage, StageProjection, + SystemActorKind, parse_blob_ref, }; use fabro_util::version::FABRO_VERSION; use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice}; @@ -50,7 +50,9 @@ use crate::principal_middleware::{ RequireCommandLog, RequireRunManagementTarget, RequireRunScoped, RequireRunStageScoped, RequiredRunManagementActor, RequiredUser, }; -use crate::run_compiler::{self, ProjectSettingsSource, RawRunCompilerInput, RunCompilerError}; +use crate::run_compiler::{ + self, ProjectSettingsPathError, ProjectSettingsSource, RawRunCompilerInput, RunCompilerError, +}; use crate::run_files::{list_run_commits, list_run_files}; use crate::run_manifest; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; @@ -565,15 +567,10 @@ struct ManifestRunCompilerAdapter { cli_overrides: Option, input_overrides: HashMap, inline_goal_override: Option, - run_id: Option, - parent_id: Option, - title: Option, - git: Option, } -fn adapt_manifest_for_run_compiler( +fn adapt_manifest_source_for_run_compiler( manifest: &RunManifest, - explicit_run_id: Option, ) -> anyhow::Result { if manifest.version != 1 { anyhow::bail!("unsupported manifest version {}", manifest.version); @@ -582,9 +579,6 @@ fn adapt_manifest_for_run_compiler( let entrypoint = ManifestPath::from_wire(&manifest.target.path) .ok_or_else(|| anyhow::anyhow!("invalid manifest target path: {}", manifest.target.path))?; let workflow_bundle = run_manifest::workflow_bundle_from_manifest(&manifest.workflows)?; - // The compiler rejects a missing entrypoint with this same message, but - // checking here keeps the legacy error precedence: a missing entrypoint - // wins over invalid args, title, or run IDs. if workflow_bundle.workflow(&entrypoint).is_none() { anyhow::bail!("manifest target path is missing from workflows map"); } @@ -595,13 +589,11 @@ fn adapt_manifest_for_run_compiler( .iter() .filter(|config| config.type_ == ManifestConfigType::Project) .filter_map(|config| config.source.as_ref().map(|source| (config, source))) - .map(|(config, source)| { - Ok(ProjectSettingsSource { - path: run_manifest::manifest_project_config_path(config, &cwd)?, - toml: source.clone(), - }) + .map(|(config, source)| ProjectSettingsSource { + path: normalize_project_settings_path(config.path.as_deref(), &cwd), + toml: source.clone(), }) - .collect::>>()?; + .collect(); let user_toml = manifest .configs .iter() @@ -613,6 +605,46 @@ fn adapt_manifest_for_run_compiler( .as_ref() .filter(|goal| goal.type_ != ManifestGoalType::Graph) .map(|goal| goal.text.clone()); + + Ok(ManifestRunCompilerAdapter { + workflow_bundle, + entrypoint, + cwd, + project_settings, + user_toml, + run_overrides: overrides.run, + cli_overrides: overrides.cli, + input_overrides: overrides.input_overrides, + inline_goal_override, + }) +} + +fn normalize_project_settings_path( + path: Option<&str>, + cwd: &std::path::Path, +) -> Result { + let path = path.ok_or(ProjectSettingsPathError::Missing)?; + let path_ref = std::path::Path::new(path); + let manifest_path = if path_ref.is_absolute() { + ManifestPath::from_absolute(path_ref, cwd) + } else { + ManifestPath::from_wire(path) + }; + manifest_path.ok_or_else(|| ProjectSettingsPathError::Invalid { + path: path.to_string(), + }) +} + +struct ManifestRunIdentity { + run_id: Option, + parent_id: Option, + title: Option, +} + +fn manifest_run_identity( + manifest: &RunManifest, + explicit_run_id: Option, +) -> anyhow::Result { let title = manifest .title .as_ref() @@ -630,21 +662,10 @@ fn adapt_manifest_for_run_compiler( .map(str::parse::) .transpose() .context("invalid parent run ID")?; - - Ok(ManifestRunCompilerAdapter { - workflow_bundle, - entrypoint, - cwd, - project_settings, - user_toml, - run_overrides: overrides.run, - cli_overrides: overrides.cli, - input_overrides: overrides.input_overrides, - inline_goal_override, + Ok(ManifestRunIdentity { run_id: explicit_run_id.or(manifest_run_id), parent_id, title, - git: manifest.git.clone(), }) } @@ -689,11 +710,10 @@ pub(crate) async fn create_run_from_manifest( let manifest_run_defaults = state.manifest_run_defaults(); let manifest_environment_defaults = state.environment_store().catalog_layer(); let manifest_mcp_server_catalog = state.mcp_server_store().catalog_settings(); - let manifest_adapter = match adapt_manifest_for_run_compiler(&manifest, explicit_run_id) { + let manifest_adapter = match adapt_manifest_source_for_run_compiler(&manifest) { Ok(adapter) => adapter, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; - let run_id = manifest_adapter.run_id.unwrap_or_default(); let title_generation_target = manifest_adapter.entrypoint.clone(); let raw_compiler_input = RawRunCompilerInput { workflow_bundle: manifest_adapter.workflow_bundle, @@ -708,14 +728,14 @@ pub(crate) async fn create_run_from_manifest( cli_overrides: manifest_adapter.cli_overrides, input_overrides: manifest_adapter.input_overrides, inline_goal_override: manifest_adapter.inline_goal_override, - run_id, - title: manifest_adapter.title, - parent_id: manifest_adapter.parent_id, - git: manifest_adapter.git, + run_id: None, + title: None, + parent_id: None, + git: manifest.git.clone(), storage_root: state.server_storage_dir(), workflow_slug: None, provenance: run_provenance(&headers, &actor), - web_url: state.run_web_url(&run_id), + web_url: None, submitted_manifest_bytes: Some(submitted_manifest_bytes), automation, }; @@ -738,6 +758,13 @@ pub(crate) async fn create_run_from_manifest( Ok(prepared) => prepared, Err(err) => return run_compiler_error_response(err), }; + let identity = match manifest_run_identity(&manifest, explicit_run_id) { + Ok(identity) => identity, + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; + let prepared = prepared.with_identity(identity.run_id, identity.parent_id, identity.title); + let (prepared, run_id) = prepared.resolve_run_id(); + let prepared = prepared.with_web_url(state.run_web_url(&run_id)); let provider = run_manifest::effective_sandbox_provider(&prepared.settings().run); if let Some(error) = run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider) diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index fd920c042..90b4e7f08 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -3903,6 +3903,105 @@ async fn create_run_from_manifest_pins_compiler_http_error_mappings() { } } +#[tokio::test] +async fn create_run_from_manifest_preserves_competing_preparation_error_precedence() { + let mut workflow_before_title = minimal_manifest_json(MINIMAL_DOT); + workflow_before_title["workflows"]["workflow.fabro"]["config"] = json!({ + "path": "workflow.toml", + "source": "_version = 1\n[run.unknown]\nvalue = true\n", + }); + workflow_before_title["title"] = json!(" "); + + let mut project_parse_before_later_path = minimal_manifest_json(MINIMAL_DOT); + project_parse_before_later_path["configs"] = json!([ + { + "type": "project", + "path": "/tmp/.fabro/project.toml", + "source": "_version = 1\n[run.unknown]\nvalue = true\n", + }, + { + "type": "project", + "source": "_version = 1\n", + }, + ]); + + for (manifest_json, expected_detail) in [ + (workflow_before_title, "Failed to parse run config TOML"), + ( + project_parse_before_later_path, + "Failed to parse run config TOML", + ), + ] { + let state = TestAppStateBuilder::new().build(); + let manifest: RunManifest = serde_json::from_value(manifest_json).unwrap(); + let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + + let response = Box::pin(handler::runs::create_run_from_manifest( + state, + handler::runs::CreateRunFromManifestRequest { + manifest, + submitted_manifest_bytes, + explicit_run_id: None, + explicit_title_supplied: true, + actor: Principal::System { + system_kind: SystemActorKind::Engine, + }, + headers: HeaderMap::new(), + automation: None, + }, + )) + .await; + + let body = response_json!(response, StatusCode::BAD_REQUEST).await; + assert_eq!(body["errors"][0]["detail"], expected_detail); + } +} + +#[tokio::test] +async fn create_run_from_manifest_resolves_generated_id_after_variable_snapshot() { + let state = TestAppStateBuilder::new() + .env_lookup(|_| None) + .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")]) + .build(); + let variable = state + .stores + .variables + .set("OWNER", "payments", None) + .await + .expect("test variable should persist"); + let manifest: RunManifest = serde_json::from_value(minimal_manifest_json( + r#"digraph Test { + graph [goal="Test"] + start [shape=Mdiamond] + work [prompt="Ship {{ vars.OWNER }}"] + exit [shape=Msquare] + start -> work -> exit + }"#, + )) + .unwrap(); + let submitted_manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + + let response = Box::pin(handler::runs::create_run_from_manifest( + state, + handler::runs::CreateRunFromManifestRequest { + manifest, + submitted_manifest_bytes, + explicit_run_id: None, + explicit_title_supplied: false, + actor: Principal::System { + system_kind: SystemActorKind::Engine, + }, + headers: HeaderMap::new(), + automation: None, + }, + )) + .await; + + let body = response_json!(response, StatusCode::CREATED).await; + let run_id = body["id"].as_str().unwrap().parse::().unwrap(); + assert!(run_id.created_at() >= variable.updated_at); +} + #[tokio::test] async fn fake_automation_materializer_injection_captures_input_and_returns_manifest() { let materialized_manifest: RunManifest = From 61764bf15bfe4bb657e87b2ad6dfc01e564b527c Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 03:35:28 +0000 Subject: [PATCH 29/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): verify (failed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 8 ⚒️ Generated with [Fabro](https://fabro.sh) From 4b56aa4f1ac0ceafca5c4ed84603cdd6a3a01e51 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 03:55:16 +0000 Subject: [PATCH 30/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): fixup (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 9 ⚒️ Generated with [Fabro](https://fabro.sh) From 8282581ac73b6f68a66aaf55b7e46807d1748e0a Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 30 Jul 2026 03:59:55 +0000 Subject: [PATCH 31/42] fabro(01KYQN78K19NY7PNSCDYP6CG9G): verify (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KYQN78K19NY7PNSCDYP6CG9G Fabro-Completed: 10 ⚒️ Generated with [Fabro](https://fabro.sh) --- lib/apps/fabro-server/src/run_manifest.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index df7f20603..57b99ca45 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -29,8 +29,7 @@ use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{EnvironmentProvider, McpServerSettings, RunGoal, RunNamespace}; use fabro_types::{ - ManifestPath, RunId, RunNoticeLevel, RunProvenance, SandboxProviderKind, ServerSettings, - WorkflowSettings, + ManifestPath, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings, WorkflowSettings, }; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; From 913cb190b73ea1fd0ccd6910cc3aabf208dd1d76 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Fri, 31 Jul 2026 15:06:05 -0400 Subject: [PATCH 32/42] Sanitize Bedrock tool identifiers during encoding --- Cargo.lock | 1 + lib/components/fabro-llm/Cargo.toml | 1 + .../src/codec/bedrock_converse/decode.rs | 15 ++ .../src/codec/bedrock_converse/encode.rs | 125 +++++++++++++++- .../src/codec/bedrock_converse/mod.rs | 1 + .../src/codec/bedrock_converse/sanitize.rs | 135 ++++++++++++++++++ .../src/codec/bedrock_converse/stream.rs | 16 +++ 7 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs diff --git a/Cargo.lock b/Cargo.lock index a485c5cc4..c1561e49b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", + "sha2 0.10.9", "strum 0.28.0", "thiserror 2.0.18", "tokio", diff --git a/lib/components/fabro-llm/Cargo.toml b/lib/components/fabro-llm/Cargo.toml index b10321253..d7bcb8df7 100644 --- a/lib/components/fabro-llm/Cargo.toml +++ b/lib/components/fabro-llm/Cargo.toml @@ -21,6 +21,7 @@ anyhow.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true strum.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs index 36ec5878d..f9cf0f87e 100644 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs +++ b/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs @@ -279,6 +279,21 @@ mod tests { assert!(decode_content_block(&serde_json::json!({"text": ""})).is_none()); } + #[test] + fn tool_use_names_are_preserved_verbatim() { + let block = serde_json::json!({ + "toolUse": { + "toolUseId": "tool-1", + "name": "search???", + "input": {} + } + }); + let Some(ContentPart::ToolCall(tool_call)) = decode_content_block(&block) else { + panic!("expected tool call"); + }; + assert_eq!(tool_call.name, "search???"); + } + #[test] fn reasoning_text_block_round_trips_signature() { let block = serde_json::json!({ diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs index 39c9c5c10..131a97cd3 100644 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs +++ b/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs @@ -4,6 +4,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use serde_json::{Map, Value, json}; +use super::sanitize; use crate::codec::{CodecCtx, EncodedRequest, extract_system_prompt, merge_named_provider_options}; use crate::error::Error; use crate::types::{ContentPart, Message, Request, Role, ToolChoice}; @@ -129,7 +130,7 @@ fn encode_message(message: &Message) -> Option { let text = message.text(); blocks.push(json!({ "toolResult": { - "toolUseId": tool_call_id, + "toolUseId": sanitize::tool_use_id(tool_call_id), "content": [{ "text": text }], } })); @@ -185,8 +186,8 @@ fn encode_content_part(part: &ContentPart) -> Option { }; Some(json!({ "toolUse": { - "toolUseId": tool_call.id, - "name": tool_call.name, + "toolUseId": sanitize::tool_use_id(&tool_call.id), + "name": sanitize::tool_name(&tool_call.name), "input": input, } })) @@ -197,7 +198,10 @@ fn encode_content_part(part: &ContentPart) -> Option { other => json!([{ "json": other }]), }; let mut block = Map::new(); - block.insert("toolUseId".to_string(), json!(result.tool_call_id)); + block.insert( + "toolUseId".to_string(), + json!(sanitize::tool_use_id(&result.tool_call_id)), + ); block.insert("content".to_string(), content); if result.is_error { block.insert("status".to_string(), json!("error")); @@ -522,6 +526,119 @@ mod tests { assert_eq!(tool_use["input"], json!({})); } + #[test] + fn historical_tool_names_are_sanitized_without_mutating_the_request() { + let mut request = base_request("claude"); + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ContentPart::ToolCall(ToolCall::new( + "tool-1", + "search???", + json!({}), + ))], + name: None, + tool_call_id: None, + }]; + + let encoded = encode_with(&request); + let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"]; + assert_eq!(tool_use["name"], "search___"); + + let ContentPart::ToolCall(original) = &request.messages[0].content[0] else { + panic!("expected original tool call"); + }; + assert_eq!(original.name, "search???"); + } + + #[test] + fn sanitized_tool_use_ids_remain_paired() { + for id in ["bad id!".to_string(), "x".repeat(100)] { + let mut request = base_request("claude"); + request.messages = vec![ + Message { + role: Role::Assistant, + content: vec![ContentPart::ToolCall(ToolCall::new( + &id, + "search", + json!({}), + ))], + name: None, + tool_call_id: None, + }, + Message { + role: Role::Tool, + content: vec![ContentPart::ToolResult(ToolResult::success( + &id, + json!("done"), + ))], + name: None, + tool_call_id: Some(id.clone()), + }, + ]; + + let encoded = encode_with(&request); + let tool_use_id = &encoded.body["messages"][0]["content"][0]["toolUse"]["toolUseId"]; + let tool_result_id = + &encoded.body["messages"][1]["content"][0]["toolResult"]["toolUseId"]; + assert_eq!(tool_use_id, tool_result_id); + assert!(tool_use_id.as_str().is_some_and(|value| value.len() <= 64)); + } + } + + #[test] + fn tool_role_fallback_sanitizes_the_tool_use_id() { + let mut request = base_request("claude"); + request.messages = vec![Message { + role: Role::Tool, + content: vec![], + name: None, + tool_call_id: Some("bad id!".to_string()), + }]; + + let encoded = encode_with(&request); + assert_eq!( + encoded.body["messages"][0]["content"][0]["toolResult"]["toolUseId"], + "bad_id_" + ); + } + + #[test] + fn overlength_tool_names_encode_within_the_bedrock_limit() { + let mut request = base_request("claude"); + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ContentPart::ToolCall(ToolCall::new( + "tool-1", + "x".repeat(100), + json!({}), + ))], + name: None, + tool_call_id: None, + }]; + + let encoded = encode_with(&request); + let name = encoded.body["messages"][0]["content"][0]["toolUse"]["name"] + .as_str() + .unwrap(); + assert_eq!(name.len(), 64); + } + + #[test] + fn tool_definition_names_remain_unsanitized() { + let mut request = base_request("claude"); + request.tools = Some(vec![ToolDefinition::function( + "weird.name", + "Deliberately invalid for Bedrock", + json!({"type": "object"}), + )]); + + let encoded = encode_with(&request); + assert_eq!( + encoded.body["toolConfig"]["tools"][0]["toolSpec"]["name"], + "weird.name" + ); + } + #[test] fn thinking_parts_restructure_into_reasoning_text_blocks() { let mut request = base_request("claude"); diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs index 12b315094..c55c0a3b3 100644 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs +++ b/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs @@ -12,6 +12,7 @@ mod decode; mod encode; +mod sanitize; mod stream; use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs new file mode 100644 index 000000000..a28e3c911 --- /dev/null +++ b/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs @@ -0,0 +1,135 @@ +//! Bedrock Converse tool identifier sanitization. +//! +//! Tool names must match `[a-zA-Z0-9_-]+`; tool-use IDs additionally allow +//! `.` and `:`. Both are limited to 64 characters. These helpers rewrite only +//! the Bedrock wire view: the canonical transcript retains provider output +//! verbatim. Every encoded tool-use ID site must use the same helper so +//! `toolUse` and `toolResult` blocks remain paired. + +use std::borrow::Cow; + +use sha2::{Digest, Sha256}; + +const MAX_LENGTH: usize = 64; +const HASH_HEX_LENGTH: usize = 16; +const PREFIX_LENGTH: usize = MAX_LENGTH - 1 - HASH_HEX_LENGTH; + +pub(super) fn tool_name(name: &str) -> Cow<'_, str> { + sanitize(name, "unknown_tool", is_tool_name_char) +} + +pub(super) fn tool_use_id(id: &str) -> Cow<'_, str> { + sanitize(id, "unknown_tool_use_id", is_tool_use_id_char) +} + +fn sanitize<'a>( + value: &'a str, + empty_fallback: &'static str, + is_allowed: fn(char) -> bool, +) -> Cow<'a, str> { + if value.is_empty() { + return Cow::Borrowed(empty_fallback); + } + if value.len() <= MAX_LENGTH && value.chars().all(is_allowed) { + return Cow::Borrowed(value); + } + + let mut sanitized = String::with_capacity(value.len()); + for character in value.chars() { + sanitized.push(if is_allowed(character) { + character + } else { + '_' + }); + } + + if sanitized.len() <= MAX_LENGTH { + return Cow::Owned(sanitized); + } + + Cow::Owned(truncate_with_hash(&sanitized, value)) +} + +fn is_tool_name_char(character: char) -> bool { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-') +} + +fn is_tool_use_id_char(character: char) -> bool { + is_tool_name_char(character) || matches!(character, '.' | ':') +} + +fn truncate_with_hash(sanitized: &str, original: &str) -> String { + debug_assert!(sanitized.is_ascii()); + let digest = Sha256::digest(original.as_bytes()); + let digest_hex = format!("{digest:x}"); + format!( + "{}-{}", + &sanitized[..PREFIX_LENGTH], + &digest_hex[..HASH_HEX_LENGTH] + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_values_are_returned_borrowed() { + for name in ["search", "TaskList", "a-b_c9"] { + assert!(matches!(tool_name(name), Cow::Borrowed(value) if value == name)); + } + + let max_length = "a".repeat(64); + assert!(matches!( + tool_name(&max_length), + Cow::Borrowed(value) if value == max_length + )); + + let id = "functions.read_file:4"; + assert!(matches!( + tool_use_id(id), + Cow::Borrowed(value) if value == id + )); + assert_eq!(tool_name(id), "functions_read_file_4"); + } + + #[test] + fn invalid_characters_are_replaced() { + assert_eq!(tool_name("search???"), "search___"); + assert_eq!(tool_name("bad name"), "bad_name"); + assert_eq!(tool_use_id("bad id!"), "bad_id_"); + } + + #[test] + fn non_ascii_characters_become_single_underscores() { + let sanitized = tool_name("before🙂after"); + assert_eq!(sanitized, "before_after"); + assert!(sanitized.is_ascii()); + } + + #[test] + fn empty_values_use_nonempty_fallbacks() { + assert_eq!(tool_name(""), "unknown_tool"); + assert_eq!(tool_use_id(""), "unknown_tool_use_id"); + } + + #[test] + fn overlength_values_use_deterministic_hash_suffixes() { + let boundary = "a".repeat(65); + let first = tool_name(&boundary); + let second = tool_name(&boundary); + assert!(matches!(&first, Cow::Owned(_))); + assert_eq!(first, second); + assert_eq!(first.len(), 64); + assert!( + first + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + ); + + let shared_prefix = "x".repeat(99); + let left = tool_name(&format!("{shared_prefix}a")).into_owned(); + let right = tool_name(&format!("{shared_prefix}b")).into_owned(); + assert_ne!(left, right); + } +} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs index 0facf2583..0d413cabf 100644 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs +++ b/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs @@ -406,6 +406,22 @@ mod tests { assert!(!tool_call.arguments.is_null()); } + #[test] + fn streamed_tool_use_names_are_preserved_verbatim() { + let mut d = decoder(); + feed(&mut d, "messageStart", r#"{"role":"assistant"}"#); + feed( + &mut d, + "contentBlockStart", + r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"search???"}},"contentBlockIndex":0}"#, + ); + let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); + let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else { + panic!("expected ToolCallEnd"); + }; + assert_eq!(tool_call.name, "search???"); + } + #[test] fn tool_use_accumulates_string_input_fragments() { let mut d = decoder(); From f75c7a1ba3266ea755edd1d52d325e5dd063cfae Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 16:33:20 -0400 Subject: [PATCH 33/42] fix: make structured output repair errors actionable --- .../fabro-workflow/src/handler/command.rs | 8 +- .../fabro-workflow/src/handler/llm/api.rs | 88 +++- .../src/handler/structured_output.rs | 401 +++++++++++++++++- 3 files changed, 473 insertions(+), 24 deletions(-) diff --git a/lib/components/fabro-workflow/src/handler/command.rs b/lib/components/fabro-workflow/src/handler/command.rs index 6cf7d5741..c8d76bc3f 100644 --- a/lib/components/fabro-workflow/src/handler/command.rs +++ b/lib/components/fabro-workflow/src/handler/command.rs @@ -176,9 +176,10 @@ impl Handler for CommandHandler { structured_output::validate_response_text(schema, &finalized.output_text), ) }); - let mut outcome = if let Some((_, Err(error))) = &validation { + let mut outcome = if let Some((schema, Err(error))) = &validation { Outcome::fail_deterministic(schema_validation_failure_reason( script, + schema, error, &finalized.output_text, )) @@ -283,13 +284,14 @@ fn encode_stdin_value(value: serde_json::Value) -> serde_json::Result> { fn schema_validation_failure_reason( script: &str, + schema: &structured_output::OutputSchemaKind, error: &StructuredOutputError, output_text: &str, ) -> String { let mut reason = format!("Script output failed output_schema validation: {script}"); - for message in error.messages() { + for message in error.rendered_messages(Some(schema)) { reason.push_str("\n- "); - reason.push_str(message); + reason.push_str(&message); } append_output_tail(&mut reason, output_text); reason diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 5dad09d34..5d3b59112 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -1480,6 +1480,7 @@ impl CodergenBackend for AgentApiBackend { .as_ref() .map(structured_output::prompt_response_format); let mut repair_attempts = 0_i64; + let mut previous_validation_error = None; let mut total_usage = TokenCounts::default(); let mut total_cost = None; let mut inference_duration = Duration::ZERO; @@ -1527,8 +1528,11 @@ impl CodergenBackend for AgentApiBackend { structured_output::exhausted_failure_reason(node.output_retries()), )); } + let repair_message = + error.repair_message(schema, previous_validation_error.as_ref()); + previous_validation_error = Some(error); messages.push(Message::assistant(response_text)); - messages.push(Message::user(error.repair_message(schema))); + messages.push(Message::user(repair_message)); repair_attempts += 1; continue; } @@ -1716,6 +1720,7 @@ impl CodergenBackend for AgentApiBackend { let mut response = last_assistant_response(&live.session); if let Some(schema) = &output_schema { let mut repair_attempts = 0_i64; + let mut previous_validation_error = None; loop { let last_file_touched = last_touched_file(&live.file_tracking); match validate_agent_output_sources( @@ -1734,7 +1739,9 @@ impl CodergenBackend for AgentApiBackend { structured_output::exhausted_failure_reason(node.output_retries()), )); } - let repair_message = error.repair_message(schema); + let repair_message = + error.repair_message(schema, previous_validation_error.as_ref()); + previous_validation_error = Some(error); let repair_result = live .session .process_input_with_runtime( @@ -2241,6 +2248,13 @@ reasoning = false ) } + fn nested_output_schema_attr() -> AttrValue { + AttrValue::String( + r#"{"type":"object","required":["findings"],"properties":{"findings":{"type":"array","items":{"type":"object","required":["rationale"],"properties":{"rationale":{"type":"string"}}}}}}"# + .to_string(), + ) + } + #[test] fn agent_backend_stores_config() { let backend = AgentApiBackend::new( @@ -3733,6 +3747,76 @@ enabled = true assert_eq!(usage.tokens().output_tokens, 7); } + #[tokio::test] + async fn agent_run_identifies_a_schema_error_repeated_during_repair() { + let server = MockServer::start(); + let first = server.mock(|when, then| { + when.method(POST) + .path("/chat/completions") + .body_includes(r#""stream":true"#) + .body_excludes(r#""role":"assistant""#); + then.status(200) + .header("content-type", "text/event-stream") + .body(chat_completion_stream(r#"{"findings":[{}]}"#, 20, 3)); + }); + let first_repair = server.mock(|when, then| { + when.method(POST) + .path("/chat/completions") + .body_includes("JSON Pointer `/findings/0/rationale`") + .body_excludes("unchanged from your previous repair"); + then.status(200) + .header("content-type", "text/event-stream") + .body(chat_completion_stream(r#"{"findings":[{}]}"#, 21, 4)); + }); + let second_repair = server.mock(|when, then| { + when.method(POST) + .path("/chat/completions") + .body_includes("JSON Pointer `/findings/0/rationale`") + .body_includes("unchanged from your previous repair"); + then.status(200) + .header("content-type", "text/event-stream") + .body(chat_completion_stream( + r#"{"findings":[{"rationale":"done"}]}"#, + 22, + 5, + )); + }); + let backend = mock_api_backend(&server); + let mut node = Node::new("audit"); + node.attrs + .insert("output_schema".to_string(), nested_output_schema_attr()); + node.attrs + .insert("output_retries".to_string(), AttrValue::Integer(2)); + let context = Context::new(); + let emitter = Arc::new(Emitter::new(fabro_types::RunId::new())); + let workspace = tempfile::tempdir().unwrap(); + let sandbox: Arc = + Arc::new(LocalSandbox::new(workspace.path().to_path_buf())); + + let result = backend + .run(CodergenRunRequest { + node: &node, + prompt: "Audit the result", + context: &context, + thread_id: None, + emitter: &emitter, + sandbox: &sandbox, + tool_hooks: None, + cancel_token: CancellationToken::new(), + agent_tool_runtime: fabro_agent::AgentToolRuntime::default(), + }) + .await + .unwrap(); + + first.assert_calls(1); + first_repair.assert_calls(1); + second_repair.assert_calls(1); + let CodergenResult::Text { text, .. } = result else { + panic!("run should return text"); + }; + assert_eq!(text, r#"{"findings":[{"rationale":"done"}]}"#); + } + #[tokio::test] async fn agent_output_repair_continues_on_the_original_models_fallback_plan() { let server = MockServer::start(); diff --git a/lib/components/fabro-workflow/src/handler/structured_output.rs b/lib/components/fabro-workflow/src/handler/structured_output.rs index fc89f6581..1c01ee21f 100644 --- a/lib/components/fabro-workflow/src/handler/structured_output.rs +++ b/lib/components/fabro-workflow/src/handler/structured_output.rs @@ -1,8 +1,12 @@ +use std::fmt::Write as _; use std::sync::{Arc, LazyLock}; use fabro_graphviz::graph::Node; use fabro_llm::types::{ResponseFormat, ResponseFormatType}; -use jsonschema::Validator; +use jsonschema::error::{TypeKind, ValidationErrorKind}; +use jsonschema::paths::Location; +use jsonschema::types::JsonType; +use jsonschema::{ValidationError, Validator}; use serde_json::Value; use crate::error::Error; @@ -45,24 +49,178 @@ pub(crate) enum StructuredOutputErrorKind { SchemaValidation, } +const MAX_SCHEMA_FRAGMENT_CHARS: usize = 320; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SchemaValidationIssue { + instance_path: Location, + schema_path: Location, + evaluation_path: Location, + keyword: String, + detail: SchemaValidationIssueDetail, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SchemaValidationIssueDetail { + Required { + property: String, + }, + Type { + expected: Vec, + actual: String, + }, + Enum { + options: String, + }, + AdditionalProperties { + unexpected: Vec, + }, + Other { + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum StructuredOutputErrorDetails { + Message(String), + SchemaValidation(Vec), +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct StructuredOutputError { - kind: StructuredOutputErrorKind, - messages: Vec, + kind: StructuredOutputErrorKind, + details: StructuredOutputErrorDetails, +} + +impl SchemaValidationIssue { + fn from_error(error: &ValidationError<'_>) -> Self { + let detail = match error.kind() { + ValidationErrorKind::Required { property } => SchemaValidationIssueDetail::Required { + property: property + .as_str() + .map_or_else(|| property.to_string(), str::to_owned), + }, + ValidationErrorKind::Type { kind } => SchemaValidationIssueDetail::Type { + expected: expected_json_types(kind), + actual: JsonType::from(error.instance().as_ref()).to_string(), + }, + ValidationErrorKind::Enum { options } => SchemaValidationIssueDetail::Enum { + options: bounded_json(options), + }, + ValidationErrorKind::AdditionalProperties { unexpected } => { + SchemaValidationIssueDetail::AdditionalProperties { + unexpected: unexpected.clone(), + } + } + _ => SchemaValidationIssueDetail::Other { + message: error.masked().to_string(), + }, + }; + Self { + instance_path: error.instance_path().clone(), + schema_path: error.schema_path().clone(), + evaluation_path: error.evaluation_path().clone(), + keyword: error.kind().keyword().to_string(), + detail, + } + } + + fn render(&self, schema: Option<&Value>) -> String { + let mut message = match &self.detail { + SchemaValidationIssueDetail::Required { property } => { + let target_path = self.instance_path.join(property); + format!( + "Missing required property {} at JSON Pointer `{target_path}`. Add it to the object at {}.", + Value::String(property.clone()), + pointer_phrase(&self.instance_path), + ) + } + SchemaValidationIssueDetail::Type { expected, actual } => format!( + "At {}, expected JSON type {}, but got {actual}.", + pointer_phrase(&self.instance_path), + format_expected_types(expected), + ), + SchemaValidationIssueDetail::Enum { options } => format!( + "At {}, the value is not one of the allowed enum values {options}.", + pointer_phrase(&self.instance_path), + ), + SchemaValidationIssueDetail::AdditionalProperties { unexpected } => { + let properties = unexpected + .iter() + .map(|property| { + let property_path = self.instance_path.join(property); + format!("{} at `{property_path}`", Value::String(property.clone())) + }) + .collect::>() + .join(", "); + format!( + "Unexpected properties in the object at {}: {properties}.", + pointer_phrase(&self.instance_path), + ) + } + SchemaValidationIssueDetail::Other { message } => format!( + "At {}: {}.", + pointer_phrase(&self.instance_path), + message.trim_end_matches('.'), + ), + }; + + let _ = write!( + message, + " Schema rule {} (`{}` keyword)", + pointer_code(&self.schema_path), + self.keyword, + ); + if let Some(fragment) = schema + .and_then(|schema| schema.pointer(self.schema_path.as_str())) + .map(bounded_json) + { + message.push_str(": "); + message.push_str(&fragment); + } + message.push('.'); + + if self.evaluation_path != self.schema_path { + let _ = write!( + message, + " Evaluation path: {}.", + pointer_code(&self.evaluation_path), + ); + } + message + } + + fn same_problem_as(&self, other: &Self) -> bool { + if self.instance_path != other.instance_path + || self.schema_path != other.schema_path + || self.keyword != other.keyword + { + return false; + } + match (&self.detail, &other.detail) { + ( + SchemaValidationIssueDetail::Required { property: left }, + SchemaValidationIssueDetail::Required { property: right }, + ) => left == right, + (SchemaValidationIssueDetail::Required { .. }, _) + | (_, SchemaValidationIssueDetail::Required { .. }) => false, + _ => true, + } + } } impl StructuredOutputError { fn new(kind: StructuredOutputErrorKind, message: impl Into) -> Self { Self { kind, - messages: vec![message.into()], + details: StructuredOutputErrorDetails::Message(message.into()), } } - fn validation(messages: Vec) -> Self { + fn validation(issues: Vec) -> Self { Self { - kind: StructuredOutputErrorKind::SchemaValidation, - messages, + kind: StructuredOutputErrorKind::SchemaValidation, + details: StructuredOutputErrorDetails::SchemaValidation(issues), } } @@ -72,9 +230,24 @@ impl StructuredOutputError { self.kind } + #[cfg(test)] #[must_use] - pub(crate) fn messages(&self) -> &[String] { - &self.messages + pub(crate) fn messages(&self) -> Vec { + self.rendered_messages(None) + } + + #[must_use] + pub(crate) fn rendered_messages(&self, schema: Option<&OutputSchemaKind>) -> Vec { + match &self.details { + StructuredOutputErrorDetails::Message(message) => vec![message.clone()], + StructuredOutputErrorDetails::SchemaValidation(issues) => { + let schema = schema.and_then(|schema| match schema { + OutputSchemaKind::Routing => None, + OutputSchemaKind::JsonSchema { schema, .. } => Some(schema), + }); + issues.iter().map(|issue| issue.render(schema)).collect() + } + } } #[must_use] @@ -87,7 +260,11 @@ impl StructuredOutputError { } #[must_use] - pub(crate) fn repair_message(&self, schema: &OutputSchemaKind) -> String { + pub(crate) fn repair_message( + &self, + schema: &OutputSchemaKind, + previous_error: Option<&Self>, + ) -> String { let expectation = match schema { OutputSchemaKind::Routing => format!( "Return a single JSON object with at least one routing field: {}.", @@ -98,16 +275,95 @@ impl StructuredOutputError { } }; let errors = self - .messages + .rendered_messages(Some(schema)) .iter() .map(|message| format!("- {message}")) .collect::>() .join("\n"); + let mut sections = + vec!["Your previous response did not satisfy the node's output_schema.".to_string()]; + if previous_error.is_some_and(|previous| self.shares_schema_issue_with(previous)) { + sections.push( + "At least one validation problem below is unchanged from your previous repair. \ + Correct the exact JSON Pointer shown." + .to_string(), + ); + } + sections.push(format!("Validation errors:\n{errors}")); + sections.push(expectation); + if self.kind == StructuredOutputErrorKind::SchemaValidation { + sections.push( + "Apply each correction at the exact JSON Pointer shown and return the complete object." + .to_string(), + ); + } + sections.push( + "Do not include Markdown fences or explanatory prose; reply only with the corrected JSON object." + .to_string(), + ); + sections.join("\n\n") + } + + fn shares_schema_issue_with(&self, other: &Self) -> bool { + let ( + StructuredOutputErrorDetails::SchemaValidation(current), + StructuredOutputErrorDetails::SchemaValidation(previous), + ) = (&self.details, &other.details) + else { + return false; + }; + current + .iter() + .any(|issue| previous.iter().any(|other| issue.same_problem_as(other))) + } +} + +fn expected_json_types(kind: &TypeKind) -> Vec { + match kind { + TypeKind::Single(json_type) => vec![json_type.to_string()], + TypeKind::Multiple(json_types) => json_types.iter().map(|kind| kind.to_string()).collect(), + } +} + +fn format_expected_types(expected: &[String]) -> String { + let expected = expected + .iter() + .map(|kind| Value::String(kind.clone()).to_string()) + .collect::>(); + match expected.as_slice() { + [] => "an allowed type".to_string(), + [expected] => expected.clone(), + _ => format!("one of {}", expected.join(", ")), + } +} + +fn pointer_phrase(path: &Location) -> String { + if path.as_str().is_empty() { + "the document root".to_string() + } else { + format!("JSON Pointer `{path}`") + } +} + +fn pointer_code(path: &Location) -> String { + if path.as_str().is_empty() { + "``".to_string() + } else { + format!("`{path}`") + } +} + +fn bounded_json(value: &Value) -> String { + let rendered = value.to_string(); + if rendered.chars().count() <= MAX_SCHEMA_FRAGMENT_CHARS { + rendered + } else { format!( - "Your previous response did not satisfy the node's output_schema.\n\n\ - Validation errors:\n{errors}\n\n\ - {expectation}\n\ - Do not include Markdown fences or explanatory prose; reply only with the corrected JSON object." + "{}…", + rendered + .chars() + .take(MAX_SCHEMA_FRAGMENT_CHARS) + .collect::() ) } } @@ -373,15 +629,15 @@ fn validate_value_against_validator( validator: &Validator, value: &Value, ) -> Result<(), StructuredOutputError> { - let errors = validator + let issues = validator .iter_errors(value) - .map(|error| error.to_string()) .take(5) + .map(|error| SchemaValidationIssue::from_error(&error)) .collect::>(); - if errors.is_empty() { + if issues.is_empty() { Ok(()) } else { - Err(StructuredOutputError::validation(errors)) + Err(StructuredOutputError::validation(issues)) } } @@ -682,6 +938,113 @@ mod tests { ); } + #[test] + fn missing_nested_property_reports_the_required_target_pointer() { + let schema = schema(serde_json::json!({ + "type": "object", + "required": ["findings"], + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "required": ["rationale"], + "properties": { + "rationale": { "type": "string" } + } + } + } + } + })); + + let error = validate_response_text(&schema, r#"{"findings":[{}]}"#).unwrap_err(); + + assert_eq!(error.rendered_messages(Some(&schema)), vec![ + "Missing required property \"rationale\" at JSON Pointer `/findings/0/rationale`. \ + Add it to the object at JSON Pointer `/findings/0`. Schema rule \ + `/properties/findings/items/required` (`required` keyword): [\"rationale\"]." + .to_string(), + ],); + } + + #[test] + fn type_and_enum_errors_report_instance_and_schema_pointers() { + let schema = schema(serde_json::json!({ + "type": "object", + "properties": { + "line": { "type": "integer" }, + "severity": { "enum": ["HIGH", "MEDIUM", "LOW"] } + } + })); + + let error = + validate_response_text(&schema, r#"{"line":"85","severity":"CRITICAL"}"#).unwrap_err(); + + assert_eq!(error.rendered_messages(Some(&schema)), vec![ + "At JSON Pointer `/line`, expected JSON type \"integer\", but got string. \ + Schema rule `/properties/line/type` (`type` keyword): \"integer\"." + .to_string(), + "At JSON Pointer `/severity`, the value is not one of the allowed enum values \ + [\"HIGH\",\"MEDIUM\",\"LOW\"]. Schema rule `/properties/severity/enum` \ + (`enum` keyword): [\"HIGH\",\"MEDIUM\",\"LOW\"]." + .to_string(), + ],); + } + + #[test] + fn additional_property_error_reports_each_property_pointer() { + let schema = schema(serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "findings": { "type": "array" } + } + })); + + let error = validate_response_text(&schema, r#"{"findings":[],"rationale":"wrong level"}"#) + .unwrap_err(); + + assert_eq!(error.rendered_messages(Some(&schema)), vec![ + "Unexpected properties in the object at the document root: \"rationale\" at \ + `/rationale`. Schema rule `/additionalProperties` (`additionalProperties` \ + keyword): false." + .to_string(), + ],); + } + + #[test] + fn repeated_schema_error_calls_out_the_unchanged_pointer() { + let schema = schema(serde_json::json!({ + "type": "object", + "required": ["findings"], + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "required": ["rationale"] + } + } + } + })); + let previous = validate_response_text(&schema, r#"{"findings":[{}]}"#).unwrap_err(); + let current = validate_response_text(&schema, r#"{"findings":[{}]}"#).unwrap_err(); + + let repair = current.repair_message(&schema, Some(&previous)); + + assert!( + repair.contains( + "At least one validation problem below is unchanged from your previous repair. \ + Correct the exact JSON Pointer shown." + ), + "unexpected repair message: {repair}", + ); + assert!( + repair.contains("JSON Pointer `/findings/0/rationale`"), + "unexpected repair message: {repair}", + ); + } + #[test] fn invalid_custom_schema_is_rejected_when_parsing_node_attr() { let mut node = Node::new("audit"); From 594bf6e632fba5b0214d9fc58da6d267a8ea1b7e Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Fri, 31 Jul 2026 17:43:22 -0400 Subject: [PATCH 34/42] refactor(llm): simplify Bedrock tool sanitization internals - Return String from the sanitize helpers instead of Cow: every call site feeds the result into json!, which allocates anyway, so the borrowed fast path only cost extra branches and Cow-variant tests. - Route all toolUse/toolResult construction through private tool_use_block/tool_result_block constructors that own the sanitize calls, so the toolUse/toolResult pairing invariant is enforced by construction rather than by call-site discipline. - Drop a test assertion the type system already guarantees (encoding takes &Request, so it cannot mutate the input) and assert wiring tests against the sanitize helpers instead of re-pinning the exact replacement literals in a second file. No wire-format changes. Co-Authored-By: Claude Fable 5 --- .../src/codec/bedrock_converse/encode.rs | 67 ++++++++++--------- .../src/codec/bedrock_converse/sanitize.rs | 65 +++++++----------- 2 files changed, 62 insertions(+), 70 deletions(-) diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs index 131a97cd3..bb1a4ba3b 100644 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs +++ b/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs @@ -128,12 +128,11 @@ fn encode_message(message: &Message) -> Option { if blocks.is_empty() && message.role == Role::Tool { if let Some(tool_call_id) = &message.tool_call_id { let text = message.text(); - blocks.push(json!({ - "toolResult": { - "toolUseId": sanitize::tool_use_id(tool_call_id), - "content": [{ "text": text }], - } - })); + blocks.push(tool_result_block( + tool_call_id, + json!([{ "text": text }]), + false, + )); } } @@ -184,29 +183,18 @@ fn encode_content_part(part: &ContentPart) -> Option { Value::Object(_) => tool_call.arguments.clone(), _ => json!({}), }; - Some(json!({ - "toolUse": { - "toolUseId": sanitize::tool_use_id(&tool_call.id), - "name": sanitize::tool_name(&tool_call.name), - "input": input, - } - })) + Some(tool_use_block(&tool_call.id, &tool_call.name, input)) } ContentPart::ToolResult(result) => { let content = match &result.content { Value::String(text) => json!([{ "text": text }]), other => json!([{ "json": other }]), }; - let mut block = Map::new(); - block.insert( - "toolUseId".to_string(), - json!(sanitize::tool_use_id(&result.tool_call_id)), - ); - block.insert("content".to_string(), content); - if result.is_error { - block.insert("status".to_string(), json!("error")); - } - Some(json!({ "toolResult": Value::Object(block) })) + Some(tool_result_block( + &result.tool_call_id, + content, + result.is_error, + )) } ContentPart::Thinking(thinking) => { if thinking.redacted { @@ -230,6 +218,28 @@ fn encode_content_part(part: &ContentPart) -> Option { } } +/// Build a `toolUse` block. All tool blocks must be constructed through +/// [`tool_use_block`] and [`tool_result_block`] so identifier sanitization +/// keeps `toolUse` and `toolResult` paired on the wire. +fn tool_use_block(id: &str, name: &str, input: Value) -> Value { + let mut block = Map::new(); + block.insert("toolUseId".to_string(), json!(sanitize::tool_use_id(id))); + block.insert("name".to_string(), json!(sanitize::tool_name(name))); + block.insert("input".to_string(), input); + json!({ "toolUse": Value::Object(block) }) +} + +/// Build a `toolResult` block; see [`tool_use_block`] for the pairing contract. +fn tool_result_block(id: &str, content: Value, is_error: bool) -> Value { + let mut block = Map::new(); + block.insert("toolUseId".to_string(), json!(sanitize::tool_use_id(id))); + block.insert("content".to_string(), content); + if is_error { + block.insert("status".to_string(), json!("error")); + } + json!({ "toolResult": Value::Object(block) }) +} + /// Convert common MIME types into Bedrock's media `format` enum values. fn media_format<'a>(media_type: Option<&str>, default: &'a str) -> &'a str { match media_type { @@ -527,7 +537,7 @@ mod tests { } #[test] - fn historical_tool_names_are_sanitized_without_mutating_the_request() { + fn historical_tool_names_are_sanitized_on_the_wire() { let mut request = base_request("claude"); request.messages = vec![Message { role: Role::Assistant, @@ -542,12 +552,7 @@ mod tests { let encoded = encode_with(&request); let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"]; - assert_eq!(tool_use["name"], "search___"); - - let ContentPart::ToolCall(original) = &request.messages[0].content[0] else { - panic!("expected original tool call"); - }; - assert_eq!(original.name, "search???"); + assert_eq!(tool_use["name"], sanitize::tool_name("search???")); } #[test] @@ -598,7 +603,7 @@ mod tests { let encoded = encode_with(&request); assert_eq!( encoded.body["messages"][0]["content"][0]["toolResult"]["toolUseId"], - "bad_id_" + sanitize::tool_use_id("bad id!") ); } diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs index a28e3c911..5ba139fc2 100644 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs +++ b/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs @@ -3,10 +3,9 @@ //! Tool names must match `[a-zA-Z0-9_-]+`; tool-use IDs additionally allow //! `.` and `:`. Both are limited to 64 characters. These helpers rewrite only //! the Bedrock wire view: the canonical transcript retains provider output -//! verbatim. Every encoded tool-use ID site must use the same helper so -//! `toolUse` and `toolResult` blocks remain paired. - -use std::borrow::Cow; +//! verbatim. The encoder routes every tool block through its +//! `tool_use_block`/`tool_result_block` constructors so `toolUse` and +//! `toolResult` blocks remain paired. use sha2::{Digest, Sha256}; @@ -14,40 +13,35 @@ const MAX_LENGTH: usize = 64; const HASH_HEX_LENGTH: usize = 16; const PREFIX_LENGTH: usize = MAX_LENGTH - 1 - HASH_HEX_LENGTH; -pub(super) fn tool_name(name: &str) -> Cow<'_, str> { +pub(super) fn tool_name(name: &str) -> String { sanitize(name, "unknown_tool", is_tool_name_char) } -pub(super) fn tool_use_id(id: &str) -> Cow<'_, str> { +pub(super) fn tool_use_id(id: &str) -> String { sanitize(id, "unknown_tool_use_id", is_tool_use_id_char) } -fn sanitize<'a>( - value: &'a str, - empty_fallback: &'static str, - is_allowed: fn(char) -> bool, -) -> Cow<'a, str> { +fn sanitize(value: &str, empty_fallback: &'static str, is_allowed: fn(char) -> bool) -> String { if value.is_empty() { - return Cow::Borrowed(empty_fallback); - } - if value.len() <= MAX_LENGTH && value.chars().all(is_allowed) { - return Cow::Borrowed(value); + return empty_fallback.to_string(); } - let mut sanitized = String::with_capacity(value.len()); - for character in value.chars() { - sanitized.push(if is_allowed(character) { - character - } else { - '_' - }); - } + let sanitized: String = value + .chars() + .map(|character| { + if is_allowed(character) { + character + } else { + '_' + } + }) + .collect(); if sanitized.len() <= MAX_LENGTH { - return Cow::Owned(sanitized); + sanitized + } else { + truncate_with_hash(&sanitized, value) } - - Cow::Owned(truncate_with_hash(&sanitized, value)) } fn is_tool_name_char(character: char) -> bool { @@ -74,22 +68,16 @@ mod tests { use super::*; #[test] - fn valid_values_are_returned_borrowed() { + fn valid_values_pass_through_unchanged() { for name in ["search", "TaskList", "a-b_c9"] { - assert!(matches!(tool_name(name), Cow::Borrowed(value) if value == name)); + assert_eq!(tool_name(name), name); } let max_length = "a".repeat(64); - assert!(matches!( - tool_name(&max_length), - Cow::Borrowed(value) if value == max_length - )); + assert_eq!(tool_name(&max_length), max_length); let id = "functions.read_file:4"; - assert!(matches!( - tool_use_id(id), - Cow::Borrowed(value) if value == id - )); + assert_eq!(tool_use_id(id), id); assert_eq!(tool_name(id), "functions_read_file_4"); } @@ -118,7 +106,6 @@ mod tests { let boundary = "a".repeat(65); let first = tool_name(&boundary); let second = tool_name(&boundary); - assert!(matches!(&first, Cow::Owned(_))); assert_eq!(first, second); assert_eq!(first.len(), 64); assert!( @@ -128,8 +115,8 @@ mod tests { ); let shared_prefix = "x".repeat(99); - let left = tool_name(&format!("{shared_prefix}a")).into_owned(); - let right = tool_name(&format!("{shared_prefix}b")).into_owned(); + let left = tool_name(&format!("{shared_prefix}a")); + let right = tool_name(&format!("{shared_prefix}b")); assert_ne!(left, right); } } From d0ebc856b7aa82cd1273d403b6a2e3a4607e146b Mon Sep 17 00:00:00 2001 From: "fabro-releases[bot]" Date: Sat, 1 Aug 2026 09:55:00 +0000 Subject: [PATCH 35/42] Bump version to 0.312.0-nightly.0 --- Cargo.lock | 102 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1561e49b..b6dc71b35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2304,7 +2304,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "chrono", "fabro-automation", @@ -2327,7 +2327,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2352,7 +2352,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2371,11 +2371,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -2391,7 +2391,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2493,7 +2493,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2522,7 +2522,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2552,7 +2552,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -2567,7 +2567,7 @@ dependencies = [ [[package]] name = "fabro-db" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2579,7 +2579,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2598,7 +2598,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2612,7 +2612,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2634,7 +2634,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2656,7 +2656,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2671,7 +2671,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -2694,7 +2694,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2704,7 +2704,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2723,7 +2723,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -2738,7 +2738,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2780,7 +2780,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2791,7 +2791,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "fabro-api", @@ -2809,7 +2809,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2829,7 +2829,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2856,7 +2856,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "chrono", "fabro-db", @@ -2874,7 +2874,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2890,7 +2890,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2912,7 +2912,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2920,7 +2920,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "cc", "libc", @@ -2929,7 +2929,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2945,7 +2945,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3082,7 +3082,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -3104,18 +3104,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" [[package]] name = "fabro-store" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -3145,7 +3145,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -3171,7 +3171,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -3185,7 +3185,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3210,7 +3210,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3231,7 +3231,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "chrono", "clap", @@ -3268,7 +3268,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -3291,7 +3291,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -3304,7 +3304,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3321,7 +3321,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3340,7 +3340,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -8505,7 +8505,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "axum", "base64", @@ -8524,7 +8524,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 78ab08a99..bc92c1ea6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.311.0-nightly.0" +version = "0.312.0-nightly.0" license = "MIT" [workspace.dependencies] From 8db771bbd0294b27735804557892b07a51af9913 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Sat, 1 Aug 2026 08:57:47 -0400 Subject: [PATCH 36/42] Simplify parallel duration and event failure logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel stage summary rendered a Duration tile directly below StageMetaBar, which already shows the same stage's duration with a live ticking clock and a started-at tooltip. The two disagreed while running: the meta bar counted up, the tile showed the static word "running". The cancelled-stage bug lived only in the duplicate. Drop the tile. The meta bar owns duration for every stage renderer, and it was already correct for cancelled, pending and skipped stages. That removes the three-way duration branch, the "--" sentinel decode, and the ACTIVE_STAGE_STATES and formatDurationMs imports. With the tile gone, ParallelOverview.durationMs is dead, as were successCount, failureCount and isComplete — the renderer counts the branch rows it draws. ParallelOverview reduces to branch identity. For run event write failures, log the first at error with run_id and event name, the rest at debug, and summarize new losses at flush. A broken sink fails for every event, so a bare error would emit one "investigate me" line per event for the life of the run. Co-Authored-By: Claude Opus 5 (1M context) --- .../stage-renderers/helpers.test.ts | 5 --- .../app/components/stage-renderers/helpers.ts | 28 ++++------------ .../parallel-children.test.tsx | 15 ++++++--- .../stage-renderers/parallel-children.tsx | 23 +++---------- .../fabro-workflow/src/event/sink.rs | 33 ++++++++++++++++++- 5 files changed, 53 insertions(+), 51 deletions(-) diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts index 33c661081..a7be45a24 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts @@ -195,15 +195,11 @@ describe("parseParallelOverview", () => { const overview = parseParallelOverview(events); expect(overview).toEqual({ branchCount: 3, - successCount: 2, - failureCount: 1, - durationMs: 12000, results: [ { id: "branch-a", index: null, itemLabel: null, status: "succeeded" }, { id: "branch-b", index: null, itemLabel: null, status: "succeeded" }, { id: "branch-c", index: null, itemLabel: null, status: "failed" }, ], - isComplete: true, }); }); @@ -249,7 +245,6 @@ describe("parseParallelOverview", () => { }), ]; const overview = parseParallelOverview(events); - expect(overview.isComplete).toBe(false); expect(overview.branchCount).toBe(4); expect(overview.results).toEqual([]); }); diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts index 868dcfc4d..afbed444f 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts @@ -172,35 +172,28 @@ export interface ParallelBranchSummary { export interface ParallelOverview { branchCount: number | null; - successCount: number | null; - failureCount: number | null; - durationMs: number | null; results: ParallelBranchSummary[]; - isComplete: boolean; } /** * Roll up the `parallel.started` (announces branch count) and - * `parallel.completed` (carries the rolled-up results) events for a parallel + * `parallel.completed` (carries the per-branch results) events for a parallel * stage. Pre-completion, only the announce data is available. + * + * Only branch identity is parsed. The event's own `success_count`, + * `failure_count` and `duration_ms` rollups are deliberately ignored: the + * renderer counts the branch rows it actually draws, and duration comes from + * the stage record via `StageMetaBar`. */ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview { let branchCount: number | null = null; - let successCount: number | null = null; - let failureCount: number | null = null; - let durationMs: number | null = null; let results: ParallelBranchSummary[] = []; - let isComplete = false; for (const event of events) { const props: UnknownRecord = event.properties ?? {}; if (event.event === "parallel.started") { branchCount = getNumber(props, "branch_count") ?? branchCount; } else if (event.event === "parallel.completed") { - isComplete = true; - successCount = getNumber(props, "success_count") ?? successCount; - failureCount = getNumber(props, "failure_count") ?? failureCount; - durationMs = getNumber(props, "duration_ms") ?? durationMs; const rawResults = getArray(props, "results") ?? []; results = rawResults .map((entry) => { @@ -218,14 +211,7 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview } } - return { - branchCount, - successCount, - failureCount, - durationMs, - results, - isComplete, - }; + return { branchCount, results }; } export interface ReducerTranscript { diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx index 4354d7fb6..2ebc5c822 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx @@ -144,18 +144,23 @@ describe("ParallelChildren", () => { expect(statValue(renderer, "Failed")).toBe("0"); }); - test("uses the stage duration when cancellation interrupts the parallel summary", () => { + test("shows the recorded stage duration when cancellation interrupts the fan-out", () => { const renderer = renderParallel( [startedEvent(2)], [], - { - ...parallelStage, + makeStage({ + id: "fork@1", + name: "fork", + nodeId: "fork", + handler: "parallel", status: StageState.CANCELLED, duration: "53m 29s", - }, + }), ); - expect(statValue(renderer, "Duration")).toBe("53m 29s"); + // No `parallel.completed` event is emitted for an interrupted fan-out, so + // the stage record is the only duration there is. + expect(textContent(renderer.root)).toContain("53m 29s"); }); test("keeps looped fork links scoped to the selected fork visit", () => { diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx index 02f939d30..fe9d9b04d 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx @@ -5,13 +5,7 @@ import { StageState } from "@qltysh/fabro-api-client"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import type { Stage } from "../stage-sidebar"; -import { - ACTIVE_STAGE_STATES, - formatStageLabel, - stageStatusLabel, - stageStatusTone, -} from "../../lib/stage-sidebar"; -import { formatDurationMs } from "../../lib/format"; +import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar"; import { StageMetaBar } from "./meta-bar"; import { parseParallelOverview } from "./helpers"; import type { ParallelBranchSummary } from "./helpers"; @@ -187,18 +181,13 @@ export function ParallelChildren({ else if (row.status === StageState.FAILED) failureCount += 1; } - let duration = stage.duration === "--" ? "—" : stage.duration; - if (overview.durationMs != null) { - duration = formatDurationMs(overview.durationMs); - } else if (ACTIVE_STAGE_STATES.has(stage.status)) { - duration = "running"; - } - return (
+ {/* The meta bar owns duration for every stage renderer, including the + live clock while running, so the tiles below stay outcome-only. */} -
+
0 ? "danger" : "default"} /> -
diff --git a/lib/components/fabro-workflow/src/event/sink.rs b/lib/components/fabro-workflow/src/event/sink.rs index 52eb229d1..25d1896ba 100644 --- a/lib/components/fabro-workflow/src/event/sink.rs +++ b/lib/components/fabro-workflow/src/event/sink.rs @@ -163,14 +163,45 @@ impl RunEventLogger { let (tx, mut rx) = mpsc::unbounded_channel(); tokio::spawn(async move { + // A dropped run event is unrecoverable history loss, so the first + // one is an ERROR worth investigating. A broken sink fails for + // every event that follows, so report the rest as a count at flush + // instead of one ERROR per event. Flush runs per stage and per + // agent turn, so only losses since the last summary are reported. + let mut write_failures: u64 = 0; + let mut summarized_failures: u64 = 0; while let Some(command) = rx.recv().await { match command { RunEventCommand::Event(event) => { if let Err(err) = sink.write_run_event(&event).await { - tracing::error!(error = %err, "Failed to write run event"); + write_failures += 1; + if write_failures == 1 { + tracing::error!( + run_id = %event.run_id, + event = %event.body.event_name(), + error = %err, + "Failed to write run event", + ); + } else { + tracing::debug!( + run_id = %event.run_id, + event = %event.body.event_name(), + failures = write_failures, + error = %err, + "Failed to write run event", + ); + } } } RunEventCommand::Flush(tx) => { + if write_failures > summarized_failures { + tracing::error!( + lost = write_failures - summarized_failures, + total = write_failures, + "Run events were lost to write failures", + ); + summarized_failures = write_failures; + } let _ = tx.send(()); } } From 42dcb410f27b4093dab9669ae1f0490fab062847 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Sat, 1 Aug 2026 00:18:20 -0400 Subject: [PATCH 37/42] Remove obsolete manifest Docker image argument --- docs/public/api-reference/fabro-api.yaml | 3 - .../fabro-cli/src/commands/run/overrides.rs | 2 - lib/apps/fabro-cli/src/manifest_args.rs | 2 - lib/apps/fabro-server/src/run_manifest.rs | 3 - .../fabro-server/src/run_tool_manifest.rs | 2 - lib/components/fabro-manifest/src/lib.rs | 67 +++++++++++++------ .../tests/manifest_args_round_trip.rs | 12 ++++ 7 files changed, 59 insertions(+), 32 deletions(-) create mode 100644 lib/foundation/fabro-api/tests/manifest_args_round_trip.rs diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 80681f93a..9a118fdd9 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -9184,9 +9184,6 @@ components: environment: type: string description: Named environment slug to select for the run. - docker_image: - type: string - description: Per-run environment image override. verbose: type: boolean dry_run: diff --git a/lib/apps/fabro-cli/src/commands/run/overrides.rs b/lib/apps/fabro-cli/src/commands/run/overrides.rs index 40fa46f6a..bd154ad87 100644 --- a/lib/apps/fabro-cli/src/commands/run/overrides.rs +++ b/lib/apps/fabro-cli/src/commands/run/overrides.rs @@ -75,7 +75,6 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result Result Option { preserve_sandbox: args.preserve_sandbox.then_some(true), provider: args.provider.clone(), environment: args.environment.clone(), - docker_image: None, input: args.inputs.values.clone(), verbose: args.verbose.then_some(true), }; @@ -27,7 +26,6 @@ pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option Option Option model: spec.model.as_deref(), provider: spec.provider.as_deref(), environment: spec.environment.as_deref(), - docker_image: None, preserve_sandbox: spec.preserve_sandbox, dry_run: spec.dry_run, auto_approve: spec.auto_approve, diff --git a/lib/components/fabro-manifest/src/lib.rs b/lib/components/fabro-manifest/src/lib.rs index 13bae0051..91014a781 100644 --- a/lib/components/fabro-manifest/src/lib.rs +++ b/lib/components/fabro-manifest/src/lib.rs @@ -61,7 +61,6 @@ pub struct RunOverrideInput<'a> { pub model: Option<&'a str>, pub provider: Option<&'a str>, pub environment: Option<&'a str>, - pub docker_image: Option<&'a str>, pub preserve_sandbox: Option, pub dry_run: Option, pub auto_approve: Option, @@ -79,23 +78,19 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer { fallbacks: MergeMap::default(), controls: None, }); - let environment = (input.environment.is_some() - || input.docker_image.is_some() - || input.preserve_sandbox.is_some()) - .then(|| RunEnvironmentLayer { - id: input.environment.map(ToOwned::to_owned), - image: input.docker_image.map(|image| EnvironmentImageLayer { - docker: Some(image.to_string()), - ..EnvironmentImageLayer::default() - }), - lifecycle: input - .preserve_sandbox - .map(|preserve| EnvironmentLifecycleLayer { - preserve: Some(preserve), - ..EnvironmentLifecycleLayer::default() - }), - ..RunEnvironmentLayer::default() - }); + let environment = + (input.environment.is_some() || input.preserve_sandbox.is_some()).then(|| { + RunEnvironmentLayer { + id: input.environment.map(ToOwned::to_owned), + lifecycle: input + .preserve_sandbox + .map(|preserve| EnvironmentLifecycleLayer { + preserve: Some(preserve), + ..EnvironmentLifecycleLayer::default() + }), + ..RunEnvironmentLayer::default() + } + }); let execution = (input.dry_run.is_some() || input.auto_approve.is_some()).then(|| RunExecutionLayer { mode: input.dry_run.map(|dry_run| { @@ -894,7 +889,6 @@ pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool { && args.preserve_sandbox.is_none() && args.provider.is_none() && args.environment.is_none() - && args.docker_image.is_none() && args.input.is_empty() && args.verbose.is_none() } @@ -966,7 +960,6 @@ mod tests { model: Some("gpt-5.4-mini"), provider: Some("openai"), environment: Some("local"), - docker_image: None, preserve_sandbox: Some(true), dry_run: Some(true), auto_approve: Some(false), @@ -1028,6 +1021,40 @@ mod tests { ); } + #[test] + fn sparse_run_overrides_preserve_only_has_no_image() { + let overrides = build_sparse_run_overrides(RunOverrideInput { + preserve_sandbox: Some(true), + ..RunOverrideInput::default() + }) + .expect("preserve override"); + let environment = overrides.environment.expect("environment override"); + + assert!(environment.image.is_none()); + assert_eq!( + environment.lifecycle.expect("lifecycle override").preserve, + Some(true) + ); + } + + #[test] + fn sparse_run_overrides_environment_only_has_no_image() { + let overrides = build_sparse_run_overrides(RunOverrideInput { + environment: Some("local"), + ..RunOverrideInput::default() + }) + .expect("environment override"); + let environment = overrides.environment.expect("environment override"); + + assert_eq!(environment.id.as_deref(), Some("local")); + assert!(environment.image.is_none()); + } + + #[test] + fn sparse_run_overrides_default_is_empty() { + assert!(build_sparse_run_overrides(RunOverrideInput::default()).is_none()); + } + // Regression coverage for https://github.com/fabro-sh/fabro/issues/476. #[test] fn build_manifest_bundles_agent_output_schema_file() { diff --git a/lib/foundation/fabro-api/tests/manifest_args_round_trip.rs b/lib/foundation/fabro-api/tests/manifest_args_round_trip.rs new file mode 100644 index 000000000..b03b95f6f --- /dev/null +++ b/lib/foundation/fabro-api/tests/manifest_args_round_trip.rs @@ -0,0 +1,12 @@ +use fabro_api::types; +use serde_json::json; + +#[test] +fn removed_docker_image_manifest_arg_is_ignored() { + let args: types::ManifestArgs = serde_json::from_value(json!({ + "docker_image": "ghcr.io/fabro/custom:latest" + })) + .unwrap(); + + assert_eq!(serde_json::to_value(args).unwrap(), json!({})); +} From e4541f8eb383bb8b8a08732915353f489980633b Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Sat, 1 Aug 2026 00:18:32 -0400 Subject: [PATCH 38/42] Regenerate TypeScript API client --- lib/packages/fabro-api-client/src/models/manifest-args.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/packages/fabro-api-client/src/models/manifest-args.ts b/lib/packages/fabro-api-client/src/models/manifest-args.ts index 41fb58300..c0ae3985e 100644 --- a/lib/packages/fabro-api-client/src/models/manifest-args.ts +++ b/lib/packages/fabro-api-client/src/models/manifest-args.ts @@ -24,10 +24,6 @@ export interface ManifestArgs { * Named environment slug to select for the run. */ 'environment'?: string; - /** - * Per-run environment image override. - */ - 'docker_image'?: string; 'verbose'?: boolean; 'dry_run'?: boolean; 'auto_approve'?: boolean; From 0e0dfe4f9dd9970f85dbed347c18183dbbb6ebd1 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 1 Aug 2026 09:04:28 -0400 Subject: [PATCH 39/42] refactor: simplify structured output error rendering Follow-up review of the repair-error work. Behavior is the same or better; the machinery is smaller. Fixes a false "unchanged from your previous repair" nudge. same_problem_as fell through to `_ => true`, so any two non-Required issues at the same instance path, schema path and keyword compared equal. A model that removed one unexpected property and added another was told it had changed nothing. SchemaValidationIssue already derives PartialEq, so the 17-line comparison is now `previous.contains(issue)`. Drops the hand-written Type and Enum rendering. jsonschema already renders both, and its messages name the offending value, which the hand-written ones did not. Also switches masked() back to to_string(): masking replaced the bad value with a placeholder, working against the goal of an actionable message, and buys no privacy since the full response is already in the prompt. Resolves the schema fragment when the issue is captured rather than threading Option<&OutputSchemaKind> through rendering. That reverts the command.rs change and drops the test-only messages() shim. The fragment is now attached only to Other, where it adds information; for required, type, enum and additionalProperties it just repeated the prose. Also: caps the model-controlled unexpected-property list so a wide object cannot turn the repair prompt into megabytes; drops evaluation_path, which was dead except under $ref, where it printed a pointer that does not resolve; drops the keyword field, already named by the schema path; and records the previous error only after the agent session accepted the repair, since failover rebuilds the session from the original prompt. Co-Authored-By: Claude Fable 5 --- .../fabro-workflow/src/handler/command.rs | 6 +- .../fabro-workflow/src/handler/llm/api.rs | 6 +- .../src/handler/structured_output.rs | 254 +++++++----------- 3 files changed, 106 insertions(+), 160 deletions(-) diff --git a/lib/components/fabro-workflow/src/handler/command.rs b/lib/components/fabro-workflow/src/handler/command.rs index c8d76bc3f..a8ac957fe 100644 --- a/lib/components/fabro-workflow/src/handler/command.rs +++ b/lib/components/fabro-workflow/src/handler/command.rs @@ -176,10 +176,9 @@ impl Handler for CommandHandler { structured_output::validate_response_text(schema, &finalized.output_text), ) }); - let mut outcome = if let Some((schema, Err(error))) = &validation { + let mut outcome = if let Some((_, Err(error))) = &validation { Outcome::fail_deterministic(schema_validation_failure_reason( script, - schema, error, &finalized.output_text, )) @@ -284,12 +283,11 @@ fn encode_stdin_value(value: serde_json::Value) -> serde_json::Result> { fn schema_validation_failure_reason( script: &str, - schema: &structured_output::OutputSchemaKind, error: &StructuredOutputError, output_text: &str, ) -> String { let mut reason = format!("Script output failed output_schema validation: {script}"); - for message in error.rendered_messages(Some(schema)) { + for message in error.messages() { reason.push_str("\n- "); reason.push_str(&message); } diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 5d3b59112..acc35a52f 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -1741,7 +1741,6 @@ impl CodergenBackend for AgentApiBackend { } let repair_message = error.repair_message(schema, previous_validation_error.as_ref()); - previous_validation_error = Some(error); let repair_result = live .session .process_input_with_runtime( @@ -1752,6 +1751,11 @@ impl CodergenBackend for AgentApiBackend { live.record_input_timing(); match repair_result { Ok(()) => { + // Only once the model has actually seen the + // repair can a later identical failure mean it + // ignored the correction. Failover rebuilds the + // session from the original prompt instead. + previous_validation_error = Some(error); live.record_input_usage().await; repair_attempts += 1; response = last_assistant_response(&live.session); diff --git a/lib/components/fabro-workflow/src/handler/structured_output.rs b/lib/components/fabro-workflow/src/handler/structured_output.rs index 1c01ee21f..a208dd2cc 100644 --- a/lib/components/fabro-workflow/src/handler/structured_output.rs +++ b/lib/components/fabro-workflow/src/handler/structured_output.rs @@ -3,9 +3,8 @@ use std::sync::{Arc, LazyLock}; use fabro_graphviz::graph::Node; use fabro_llm::types::{ResponseFormat, ResponseFormatType}; -use jsonschema::error::{TypeKind, ValidationErrorKind}; +use jsonschema::error::ValidationErrorKind; use jsonschema::paths::Location; -use jsonschema::types::JsonType; use jsonschema::{ValidationError, Validator}; use serde_json::Value; @@ -51,32 +50,34 @@ pub(crate) enum StructuredOutputErrorKind { const MAX_SCHEMA_FRAGMENT_CHARS: usize = 320; +/// `additionalProperties` errors carry one entry per unexpected key, and the +/// keys come from model output. Cap them so a wide object can't turn the repair +/// prompt into megabytes. +const MAX_UNEXPECTED_PROPERTIES: usize = 10; + #[derive(Debug, Clone, PartialEq, Eq)] struct SchemaValidationIssue { - instance_path: Location, - schema_path: Location, - evaluation_path: Location, - keyword: String, - detail: SchemaValidationIssueDetail, + instance_path: Location, + schema_path: Location, + detail: SchemaValidationIssueDetail, } +/// `Required` and `AdditionalProperties` get bespoke rendering because +/// `jsonschema` names the offending property without ever locating it. Every +/// other keyword already renders a message that names both the value and the +/// constraint, so it goes through `Other` with the schema fragment attached. #[derive(Debug, Clone, PartialEq, Eq)] enum SchemaValidationIssueDetail { Required { property: String, }, - Type { - expected: Vec, - actual: String, - }, - Enum { - options: String, - }, AdditionalProperties { unexpected: Vec, + total: usize, }, Other { - message: String, + message: String, + schema_fragment: Option, }, } @@ -93,72 +94,67 @@ pub(crate) struct StructuredOutputError { } impl SchemaValidationIssue { - fn from_error(error: &ValidationError<'_>) -> Self { + fn from_error(error: &ValidationError<'_>, schema: Option<&Value>) -> Self { let detail = match error.kind() { ValidationErrorKind::Required { property } => SchemaValidationIssueDetail::Required { property: property .as_str() .map_or_else(|| property.to_string(), str::to_owned), }, - ValidationErrorKind::Type { kind } => SchemaValidationIssueDetail::Type { - expected: expected_json_types(kind), - actual: JsonType::from(error.instance().as_ref()).to_string(), - }, - ValidationErrorKind::Enum { options } => SchemaValidationIssueDetail::Enum { - options: bounded_json(options), - }, ValidationErrorKind::AdditionalProperties { unexpected } => { SchemaValidationIssueDetail::AdditionalProperties { - unexpected: unexpected.clone(), + total: unexpected.len(), + unexpected: unexpected + .iter() + .take(MAX_UNEXPECTED_PROPERTIES) + .cloned() + .collect(), } } _ => SchemaValidationIssueDetail::Other { - message: error.masked().to_string(), + message: error.to_string(), + schema_fragment: schema + .and_then(|schema| schema.pointer(error.schema_path().as_str())) + .map(bounded_json), }, }; Self { instance_path: error.instance_path().clone(), schema_path: error.schema_path().clone(), - evaluation_path: error.evaluation_path().clone(), - keyword: error.kind().keyword().to_string(), detail, } } - fn render(&self, schema: Option<&Value>) -> String { + fn render(&self) -> String { let mut message = match &self.detail { - SchemaValidationIssueDetail::Required { property } => { - let target_path = self.instance_path.join(property); - format!( - "Missing required property {} at JSON Pointer `{target_path}`. Add it to the object at {}.", - Value::String(property.clone()), - pointer_phrase(&self.instance_path), - ) - } - SchemaValidationIssueDetail::Type { expected, actual } => format!( - "At {}, expected JSON type {}, but got {actual}.", - pointer_phrase(&self.instance_path), - format_expected_types(expected), - ), - SchemaValidationIssueDetail::Enum { options } => format!( - "At {}, the value is not one of the allowed enum values {options}.", + SchemaValidationIssueDetail::Required { property } => format!( + "Missing required property {} at JSON Pointer `{}`. Add it to the object at {}.", + Value::String(property.clone()), + self.instance_path.join(property), pointer_phrase(&self.instance_path), ), - SchemaValidationIssueDetail::AdditionalProperties { unexpected } => { - let properties = unexpected + SchemaValidationIssueDetail::AdditionalProperties { unexpected, total } => { + let mut properties = unexpected .iter() .map(|property| { - let property_path = self.instance_path.join(property); - format!("{} at `{property_path}`", Value::String(property.clone())) + format!( + "{} at `{}`", + Value::String(property.clone()), + self.instance_path.join(property), + ) }) .collect::>() .join(", "); + let remaining = total - unexpected.len(); + if remaining > 0 { + let _ = write!(properties, ", and {remaining} more"); + } format!( "Unexpected properties in the object at {}: {properties}.", pointer_phrase(&self.instance_path), ) } - SchemaValidationIssueDetail::Other { message } => format!( + SchemaValidationIssueDetail::Other { message, .. } => format!( "At {}: {}.", pointer_phrase(&self.instance_path), message.trim_end_matches('.'), @@ -167,46 +163,20 @@ impl SchemaValidationIssue { let _ = write!( message, - " Schema rule {} (`{}` keyword)", - pointer_code(&self.schema_path), - self.keyword, + " Schema rule: {}", + pointer_phrase(&self.schema_path) ); - if let Some(fragment) = schema - .and_then(|schema| schema.pointer(self.schema_path.as_str())) - .map(bounded_json) + if let SchemaValidationIssueDetail::Other { + schema_fragment: Some(fragment), + .. + } = &self.detail { message.push_str(": "); - message.push_str(&fragment); + message.push_str(fragment); } message.push('.'); - - if self.evaluation_path != self.schema_path { - let _ = write!( - message, - " Evaluation path: {}.", - pointer_code(&self.evaluation_path), - ); - } message } - - fn same_problem_as(&self, other: &Self) -> bool { - if self.instance_path != other.instance_path - || self.schema_path != other.schema_path - || self.keyword != other.keyword - { - return false; - } - match (&self.detail, &other.detail) { - ( - SchemaValidationIssueDetail::Required { property: left }, - SchemaValidationIssueDetail::Required { property: right }, - ) => left == right, - (SchemaValidationIssueDetail::Required { .. }, _) - | (_, SchemaValidationIssueDetail::Required { .. }) => false, - _ => true, - } - } } impl StructuredOutputError { @@ -230,22 +200,12 @@ impl StructuredOutputError { self.kind } - #[cfg(test)] #[must_use] pub(crate) fn messages(&self) -> Vec { - self.rendered_messages(None) - } - - #[must_use] - pub(crate) fn rendered_messages(&self, schema: Option<&OutputSchemaKind>) -> Vec { match &self.details { StructuredOutputErrorDetails::Message(message) => vec![message.clone()], StructuredOutputErrorDetails::SchemaValidation(issues) => { - let schema = schema.and_then(|schema| match schema { - OutputSchemaKind::Routing => None, - OutputSchemaKind::JsonSchema { schema, .. } => Some(schema), - }); - issues.iter().map(|issue| issue.render(schema)).collect() + issues.iter().map(SchemaValidationIssue::render).collect() } } } @@ -275,7 +235,7 @@ impl StructuredOutputError { } }; let errors = self - .rendered_messages(Some(schema)) + .messages() .iter() .map(|message| format!("- {message}")) .collect::>() @@ -284,8 +244,7 @@ impl StructuredOutputError { vec!["Your previous response did not satisfy the node's output_schema.".to_string()]; if previous_error.is_some_and(|previous| self.shares_schema_issue_with(previous)) { sections.push( - "At least one validation problem below is unchanged from your previous repair. \ - Correct the exact JSON Pointer shown." + "At least one validation problem below is unchanged from your previous repair." .to_string(), ); } @@ -312,28 +271,7 @@ impl StructuredOutputError { else { return false; }; - current - .iter() - .any(|issue| previous.iter().any(|other| issue.same_problem_as(other))) - } -} - -fn expected_json_types(kind: &TypeKind) -> Vec { - match kind { - TypeKind::Single(json_type) => vec![json_type.to_string()], - TypeKind::Multiple(json_types) => json_types.iter().map(|kind| kind.to_string()).collect(), - } -} - -fn format_expected_types(expected: &[String]) -> String { - let expected = expected - .iter() - .map(|kind| Value::String(kind.clone()).to_string()) - .collect::>(); - match expected.as_slice() { - [] => "an allowed type".to_string(), - [expected] => expected.clone(), - _ => format!("one of {}", expected.join(", ")), + current.iter().any(|issue| previous.contains(issue)) } } @@ -345,27 +283,13 @@ fn pointer_phrase(path: &Location) -> String { } } -fn pointer_code(path: &Location) -> String { - if path.as_str().is_empty() { - "``".to_string() - } else { - format!("`{path}`") - } -} - fn bounded_json(value: &Value) -> String { - let rendered = value.to_string(); - if rendered.chars().count() <= MAX_SCHEMA_FRAGMENT_CHARS { - rendered - } else { - format!( - "{}…", - rendered - .chars() - .take(MAX_SCHEMA_FRAGMENT_CHARS) - .collect::() - ) + let mut rendered = value.to_string(); + if let Some((offset, _)) = rendered.char_indices().nth(MAX_SCHEMA_FRAGMENT_CHARS) { + rendered.truncate(offset); + rendered.push('…'); } + rendered } #[derive(Debug, Clone, PartialEq)] @@ -458,8 +382,8 @@ pub(crate) fn validate_response_text( ) -> Result { match schema { OutputSchemaKind::Routing => validate_routing_response_text(text), - OutputSchemaKind::JsonSchema { validator, .. } => { - validate_custom_response_text(validator, text) + OutputSchemaKind::JsonSchema { schema, validator } => { + validate_custom_response_text(validator, schema, text) } } } @@ -580,7 +504,7 @@ fn validate_routing_response_text( if !contains_routing_field(obj) { continue; } - validate_value_against_validator(routing_validator(), &parsed)?; + validate_value_against_validator(routing_validator(), &parsed, None)?; return Ok(ValidatedStructuredOutput { value: parsed }); } @@ -595,6 +519,7 @@ fn validate_routing_response_text( fn validate_custom_response_text( validator: &Validator, + schema: &Value, text: &str, ) -> Result { // Prose after the object can contain braces, so the last candidate is not @@ -605,7 +530,7 @@ fn validate_custom_response_text( for candidate in candidates.iter().rev() { match serde_json::from_str::(candidate) { Ok(parsed) => { - validate_value_against_validator(validator, &parsed)?; + validate_value_against_validator(validator, &parsed, Some(schema))?; return Ok(ValidatedStructuredOutput { value: parsed }); } Err(err) if invalid_json.is_none() => invalid_json = Some(err.to_string()), @@ -628,11 +553,12 @@ fn validate_custom_response_text( fn validate_value_against_validator( validator: &Validator, value: &Value, + schema: Option<&Value>, ) -> Result<(), StructuredOutputError> { let issues = validator .iter_errors(value) .take(5) - .map(|error| SchemaValidationIssue::from_error(&error)) + .map(|error| SchemaValidationIssue::from_error(&error, schema)) .collect::>(); if issues.is_empty() { Ok(()) @@ -959,10 +885,10 @@ mod tests { let error = validate_response_text(&schema, r#"{"findings":[{}]}"#).unwrap_err(); - assert_eq!(error.rendered_messages(Some(&schema)), vec![ + assert_eq!(error.messages(), vec![ "Missing required property \"rationale\" at JSON Pointer `/findings/0/rationale`. \ - Add it to the object at JSON Pointer `/findings/0`. Schema rule \ - `/properties/findings/items/required` (`required` keyword): [\"rationale\"]." + Add it to the object at JSON Pointer `/findings/0`. Schema rule: JSON Pointer \ + `/properties/findings/items/required`." .to_string(), ],); } @@ -980,13 +906,13 @@ mod tests { let error = validate_response_text(&schema, r#"{"line":"85","severity":"CRITICAL"}"#).unwrap_err(); - assert_eq!(error.rendered_messages(Some(&schema)), vec![ - "At JSON Pointer `/line`, expected JSON type \"integer\", but got string. \ - Schema rule `/properties/line/type` (`type` keyword): \"integer\"." + assert_eq!(error.messages(), vec![ + "At JSON Pointer `/line`: \"85\" is not of type \"integer\". \ + Schema rule: JSON Pointer `/properties/line/type`: \"integer\"." .to_string(), - "At JSON Pointer `/severity`, the value is not one of the allowed enum values \ - [\"HIGH\",\"MEDIUM\",\"LOW\"]. Schema rule `/properties/severity/enum` \ - (`enum` keyword): [\"HIGH\",\"MEDIUM\",\"LOW\"]." + "At JSON Pointer `/severity`: \"CRITICAL\" is not one of \"HIGH\", \"MEDIUM\" or \ + \"LOW\". Schema rule: JSON Pointer `/properties/severity/enum`: \ + [\"HIGH\",\"MEDIUM\",\"LOW\"]." .to_string(), ],); } @@ -1004,10 +930,9 @@ mod tests { let error = validate_response_text(&schema, r#"{"findings":[],"rationale":"wrong level"}"#) .unwrap_err(); - assert_eq!(error.rendered_messages(Some(&schema)), vec![ + assert_eq!(error.messages(), vec![ "Unexpected properties in the object at the document root: \"rationale\" at \ - `/rationale`. Schema rule `/additionalProperties` (`additionalProperties` \ - keyword): false." + `/rationale`. Schema rule: JSON Pointer `/additionalProperties`." .to_string(), ],); } @@ -1034,8 +959,7 @@ mod tests { assert!( repair.contains( - "At least one validation problem below is unchanged from your previous repair. \ - Correct the exact JSON Pointer shown." + "At least one validation problem below is unchanged from your previous repair." ), "unexpected repair message: {repair}", ); @@ -1045,6 +969,26 @@ mod tests { ); } + #[test] + fn a_different_problem_at_the_same_location_is_not_called_unchanged() { + let schema = schema(serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "findings": { "type": "array" } + } + })); + let previous = validate_response_text(&schema, r#"{"stray":1}"#).unwrap_err(); + let current = validate_response_text(&schema, r#"{"different":1}"#).unwrap_err(); + + let repair = current.repair_message(&schema, Some(&previous)); + + assert!( + !repair.contains("unchanged from your previous repair"), + "unexpected repair message: {repair}", + ); + } + #[test] fn invalid_custom_schema_is_rejected_when_parsing_node_attr() { let mut node = Node::new("audit"); From 14cf4f3e948ab2fedf81d7a3f81015825d83da2d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 1 Aug 2026 09:10:28 -0400 Subject: [PATCH 40/42] Simplify MCP executable monitoring Exit the process from `main` for every command instead of returning. The `mcp start` command parks Tokio's stdin reader on a read only the MCP host can end, so dropping the runtime waits forever. Exiting in `main` also keeps the CLI telemetry event, which the previous exit inside the MCP command skipped. That removes the reason for the `McpServerExit` enum, whose only job was to carry an implementation detail out to the CLI so it could exit. Watch the executable through its device and inode on Unix. That is a complete file identity, so the length and modification time no longer add anything. Drop the PATH scan: `current_exe` reports the symlink itself on macOS, so it detects a Homebrew relink without it. This also drops the `fabro-static` dependency and a clippy suppression. Bound the shutdown wait after an upgrade is detected. The transport closes by writing to a stdout the host may already have stopped reading, which could hang the exit the change is supposed to trigger. Log a warning when upgrade detection cannot start, rather than disabling it silently. Share one spawn helper between the two raw stdio tests, and link the test executable instead of copying 200 MB of binary. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 2 +- lib/apps/fabro-cli/src/commands/mcp/mod.rs | 12 +- lib/apps/fabro-cli/src/main.rs | 6 +- lib/apps/fabro-cli/tests/it/cmd/mcp.rs | 114 ++++++------ lib/apps/fabro-mcp-server/Cargo.toml | 2 +- lib/apps/fabro-mcp-server/src/config.rs | 4 +- .../src/executable_monitor.rs | 167 ++++++------------ lib/apps/fabro-mcp-server/src/lib.rs | 6 +- lib/apps/fabro-mcp-server/src/server.rs | 72 ++++---- 9 files changed, 165 insertions(+), 220 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ae6ec28d..eb15bbfc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2838,7 +2838,6 @@ dependencies = [ "fabro-manifest", "fabro-model", "fabro-server", - "fabro-static", "fabro-tool", "fabro-types", "fabro-util", @@ -2852,6 +2851,7 @@ dependencies = [ "tempfile", "tokio", "toml 0.8.23", + "tracing", ] [[package]] diff --git a/lib/apps/fabro-cli/src/commands/mcp/mod.rs b/lib/apps/fabro-cli/src/commands/mcp/mod.rs index ef7233a66..f6a64a0ca 100644 --- a/lib/apps/fabro-cli/src/commands/mcp/mod.rs +++ b/lib/apps/fabro-cli/src/commands/mcp/mod.rs @@ -1,8 +1,6 @@ use std::fmt::Write as _; -use std::process; use anyhow::{Context as _, Result}; -use fabro_mcp_server::McpServerExit; use crate::args::{McpAgent, McpCommand, McpNamespace, ServerConnectionArgs}; use crate::command_context::CommandContext; @@ -11,15 +9,7 @@ use crate::server_client; pub(crate) async fn dispatch(ns: McpNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { McpCommand::Start(args) => { - let exit = - fabro_mcp_server::start(server_settings(base_ctx, &args.connection)?).await?; - if exit == McpServerExit::ExecutableReplaced { - // Tokio's stdin worker can remain blocked after the MCP service - // closes. Exit at the CLI boundary so the host can reconnect to - // the replacement executable. - process::exit(0); - } - Ok(()) + fabro_mcp_server::start(server_settings(base_ctx, &args.connection)?).await } McpCommand::Config(args) => { let json = fabro_mcp_server::config_json(&config_settings(&args.connection))?; diff --git a/lib/apps/fabro-cli/src/main.rs b/lib/apps/fabro-cli/src/main.rs index 0d0aa1c79..f01e1cc65 100644 --- a/lib/apps/fabro-cli/src/main.rs +++ b/lib/apps/fabro-cli/src/main.rs @@ -120,8 +120,12 @@ async fn main() { "{:?}", miette::Report::new(CliDiagnostic::new(err, !json_mode)) ); - std::process::exit(exit_code); } + + // Exit rather than returning. A command can leave a blocked worker thread + // behind — `mcp start` parks Tokio's stdin reader on a read only the MCP + // host can end — and dropping the runtime would wait on it forever. + std::process::exit(exit_code); } fn install_miette_hook() { diff --git a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs index 1d2ed68d2..253134714 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use std::fs; use std::io::{BufRead as _, Write as _}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::{Child, ChildStdin, Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -519,40 +519,14 @@ async fn stdio_server_initializes_and_lists_run_tools() { fn stdio_start_writes_only_json_rpc_to_stdout() { let context = test_context!(); let fixture = mcp_stdio_fixture(&context, &[]); - let mut cmd = Command::new(&fixture.command[0]); - cmd.args(&fixture.command[1..]) - .env_clear() - .envs(&fixture.env) - .current_dir(&fixture.current_dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = cmd.spawn().unwrap(); - let mut stdin = child.stdin.take().unwrap(); - writeln!( - stdin, - r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18","capabilities":{{}},"clientInfo":{{"name":"fabro-test","version":"0.0.0"}}}}}}"# - ) - .unwrap(); + let (mut child, _stdin, response) = + spawn_stdio_server(&fixture, Path::new(&fixture.command[0])); - let stdout = child.stdout.take().unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - thread::spawn(move || { - let mut line = String::new(); - let result = std::io::BufReader::new(stdout).read_line(&mut line); - let _ = tx.send(result.map(|_| line)); - }); - - let line = rx - .recv_timeout(Duration::from_secs(5)) - .expect("initialize response should arrive") - .expect("stdout should be readable"); - let value: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); - assert_eq!(value["jsonrpc"], "2.0"); - assert_eq!(value["result"]["serverInfo"]["name"], "fabro"); + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["result"]["serverInfo"]["name"], "fabro"); assert_eq!( - value["result"]["serverInfo"]["version"], + response["result"]["serverInfo"]["version"], env!("CARGO_PKG_VERSION") ); @@ -566,40 +540,17 @@ fn stdio_server_exits_when_executable_is_replaced() { let context = test_context!(); let fixture = mcp_stdio_fixture(&context, &[]); let directory = tempfile::tempdir().expect("replacement directory should exist"); + // A symlink stands in for a Homebrew install: the server follows it to the + // real binary, so replacing the link changes the identity it watches without + // copying a multi-hundred-megabyte executable. let executable = directory.path().join("fabro"); - fs::copy(&fixture.command[0], &executable).expect("Fabro executable should be copied"); - let mut cmd = Command::new(&executable); - cmd.args(&fixture.command[1..]) - .env_clear() - .envs(&fixture.env) - .current_dir(&fixture.current_dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + std::os::unix::fs::symlink(&fixture.command[0], &executable) + .expect("Fabro executable should be linked"); - let mut child = cmd.spawn().expect("MCP server should start"); - let mut stdin = child.stdin.take().expect("MCP stdin should be available"); - writeln!( - stdin, - r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18","capabilities":{{}},"clientInfo":{{"name":"fabro-test","version":"0.0.0"}}}}}}"# - ) - .expect("initialize request should be written"); - - let stdout = child.stdout.take().expect("MCP stdout should be available"); - let (tx, rx) = std::sync::mpsc::channel(); - thread::spawn(move || { - let mut line = String::new(); - let result = std::io::BufReader::new(stdout).read_line(&mut line); - let _ = tx.send(result.map(|_| line)); - }); - let response = rx - .recv_timeout(Duration::from_secs(5)) - .expect("initialize response should arrive") - .expect("MCP stdout should be readable"); - let response: serde_json::Value = serde_json::from_str(response.trim()).unwrap(); + // `_stdin` holds the pipe open so replacement, not EOF, stops the server. + let (mut child, _stdin, response) = spawn_stdio_server(&fixture, &executable); assert_eq!(response["result"]["serverInfo"]["name"], "fabro"); - // Keep stdin open so replacement, rather than EOF, stops the server. let replacement = directory.path().join("fabro-replacement"); fs::write(&replacement, b"replacement").expect("replacement file should be written"); fs::rename(replacement, &executable).expect("Fabro executable should be replaced"); @@ -2537,6 +2488,45 @@ fn mcp_stdio_fixture(context: &fabro_test::TestContext, extra_args: &[&str]) -> } } +const MCP_INITIALIZE_REQUEST: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"fabro-test","version":"0.0.0"}}}"#; + +/// Starts `fabro mcp start` as a raw child process, sends `initialize`, and +/// returns the decoded response. Unlike `spawn_mcp_client`, the caller keeps +/// the `Child` and its stdin, so it can observe how and when the server exits. +fn spawn_stdio_server( + fixture: &McpStdioFixture, + program: &Path, +) -> (Child, ChildStdin, serde_json::Value) { + let mut child = Command::new(program) + .args(&fixture.command[1..]) + .env_clear() + .envs(&fixture.env) + .current_dir(&fixture.current_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("MCP server should start"); + + let mut stdin = child.stdin.take().expect("MCP stdin should be available"); + writeln!(stdin, "{MCP_INITIALIZE_REQUEST}").expect("initialize request should be written"); + + let stdout = child.stdout.take().expect("MCP stdout should be available"); + let (tx, rx) = std::sync::mpsc::channel(); + thread::spawn(move || { + let mut line = String::new(); + let result = std::io::BufReader::new(stdout).read_line(&mut line); + let _ = tx.send(result.map(|_| line)); + }); + let line = rx + .recv_timeout(Duration::from_secs(5)) + .expect("initialize response should arrive") + .expect("MCP stdout should be readable"); + + let response = serde_json::from_str(line.trim()).expect("response should be JSON"); + (child, stdin, response) +} + fn write_mcp_server_settings( context: &mut fabro_test::TestContext, storage_dir: &Path, diff --git a/lib/apps/fabro-mcp-server/Cargo.toml b/lib/apps/fabro-mcp-server/Cargo.toml index da0b9978b..758a11fd3 100644 --- a/lib/apps/fabro-mcp-server/Cargo.toml +++ b/lib/apps/fabro-mcp-server/Cargo.toml @@ -21,7 +21,6 @@ fabro-manifest = { path = "../../components/fabro-manifest" } fabro-config = { path = "../../foundation/fabro-config" } fabro-model = { path = "../../foundation/fabro-model" } fabro-server = { path = "../fabro-server" } -fabro-static = { path = "../../foundation/fabro-static" } fabro-tool = { path = "../../components/fabro-tool" } fabro-types = { path = "../../foundation/fabro-types" } fabro-util = { path = "../../foundation/fabro-util" } @@ -33,6 +32,7 @@ serde_json.workspace = true strum.workspace = true tokio.workspace = true toml.workspace = true +tracing.workspace = true [dev-dependencies] httpmock = "0.8" diff --git a/lib/apps/fabro-mcp-server/src/config.rs b/lib/apps/fabro-mcp-server/src/config.rs index 9eb8a67c2..ec7154348 100644 --- a/lib/apps/fabro-mcp-server/src/config.rs +++ b/lib/apps/fabro-mcp-server/src/config.rs @@ -9,9 +9,7 @@ use anyhow::{Context as _, Result, anyhow}; use serde_json::map::Entry; use serde_json::{Map, Value, json}; -use crate::{McpAgent, McpConfigSettings, McpInitSettings}; - -const SERVER_NAME: &str = "fabro"; +use crate::{McpAgent, McpConfigSettings, McpInitSettings, SERVER_NAME}; pub fn config_json(settings: &McpConfigSettings) -> Result { serde_json::to_string_pretty(&generic_config(settings)) diff --git a/lib/apps/fabro-mcp-server/src/executable_monitor.rs b/lib/apps/fabro-mcp-server/src/executable_monitor.rs index 9ba92149b..ae0eb6b30 100644 --- a/lib/apps/fabro-mcp-server/src/executable_monitor.rs +++ b/lib/apps/fabro-mcp-server/src/executable_monitor.rs @@ -5,143 +5,100 @@ //! process keeps its old API response decoder after the `fabro` file on disk is //! upgraded. -use std::path::PathBuf; -use std::time::{Duration, SystemTime}; -use std::{env, io}; +use std::fs::Metadata; +use std::path::{self, PathBuf}; +use std::time::Duration; +use std::{env, fs, io}; -use fabro_static::EnvVars; -use tokio::time::Instant; -use tokio::{fs, time}; +use tokio::time::{self, Instant, MissedTickBehavior}; const CHECK_INTERVAL: Duration = Duration::from_secs(1); pub(crate) struct ExecutableMonitor { path: PathBuf, - identity: ExecutableIdentity, + identity: Identity, } impl ExecutableMonitor { - pub(crate) async fn current() -> io::Result { - let path = invoked_executable_path().await?; - Self::new(path).await + pub(crate) fn current() -> io::Result { + Self::new(invoked_executable_path()?) } - async fn new(path: PathBuf) -> io::Result { - let identity = ExecutableIdentity::from_metadata(&fs::metadata(&path).await?); + fn new(path: PathBuf) -> io::Result { + let identity = identity(&fs::metadata(&path)?); Ok(Self { path, identity }) } pub(crate) async fn wait_until_replaced(self) { let mut interval = time::interval_at(Instant::now() + CHECK_INTERVAL, CHECK_INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); loop { interval.tick().await; - if self.was_replaced().await { + if self.was_replaced() { return; } } } - async fn was_replaced(&self) -> bool { - fs::metadata(&self.path).await.map_or(true, |metadata| { - ExecutableIdentity::from_metadata(&metadata) != self.identity - }) + /// Reads the identity synchronously. This is a `stat` of a page-cached + /// inode once per second, so handing it to Tokio's blocking pool would + /// cost more than the call itself and would keep a pool thread resident + /// for the life of the server. + fn was_replaced(&self) -> bool { + !fs::metadata(&self.path).is_ok_and(|metadata| identity(&metadata) == self.identity) } } -#[derive(Debug, Clone, PartialEq, Eq)] -struct ExecutableIdentity { - len: u64, - modified: Option, - #[cfg(unix)] - device: u64, - #[cfg(unix)] - inode: u64, +/// Identifies the file behind an executable path. Upgrades always swap a new +/// file into place — `fabro upgrade` renames over the old one and Homebrew +/// repoints a symlink — so the identity changes even though the path does not. +#[cfg(unix)] +type Identity = (u64, u64); + +#[cfg(unix)] +fn identity(metadata: &Metadata) -> Identity { + use std::os::unix::fs::MetadataExt as _; + + (metadata.dev(), metadata.ino()) } -impl ExecutableIdentity { - fn from_metadata(metadata: &std::fs::Metadata) -> Self { - #[cfg(unix)] - use std::os::unix::fs::MetadataExt as _; +#[cfg(not(unix))] +type Identity = (u64, Option); - Self { - len: metadata.len(), - modified: metadata.modified().ok(), - #[cfg(unix)] - device: metadata.dev(), - #[cfg(unix)] - inode: metadata.ino(), - } - } +#[cfg(not(unix))] +fn identity(metadata: &Metadata) -> Identity { + (metadata.len(), metadata.modified().ok()) } -#[expect( - clippy::disallowed_methods, - reason = "MCP startup resolves its invoked executable through the process PATH so it can detect Homebrew symlink updates" -)] -async fn invoked_executable_path() -> io::Result { - let invoked = env::args_os() - .next() - .map(PathBuf::from) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "process argv[0] is unavailable"))?; - - if invoked.components().count() > 1 { - return absolute_path(invoked); - } - - if let Some(path) = env::var_os(EnvVars::PATH) { - for directory in env::split_paths(&path) { - let candidate = absolute_path(directory.join(&invoked))?; - if fs::metadata(&candidate) - .await - .is_ok_and(|metadata| is_executable_file(&metadata)) - { - return Ok(candidate); - } - } - } - - env::current_exe() -} - -fn absolute_path(path: PathBuf) -> io::Result { - if path.is_absolute() { - Ok(path) - } else { - env::current_dir().map(|cwd| cwd.join(path)) - } -} - -fn is_executable_file(metadata: &std::fs::Metadata) -> bool { - if !metadata.is_file() { - return false; - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - - metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(unix))] - { - true +/// Resolves the executable path to watch. +/// +/// `argv[0]` wins when it carries a directory, because it names the path the +/// host actually launched, symlink included. MCP hosts normally launch a bare +/// `fabro` found on `PATH`, which leaves `current_exe`: it reports the symlink +/// on macOS, and the Homebrew symlink's own target on Linux. +fn invoked_executable_path() -> io::Result { + match env::args_os().next().map(PathBuf::from) { + Some(invoked) if invoked.components().count() > 1 => path::absolute(invoked), + _ => env::current_exe(), } } #[cfg(test)] mod tests { + use tokio::fs as async_fs; + use super::*; #[tokio::test] async fn unchanged_executable_is_current() { let directory = tempfile::tempdir().expect("temp directory should exist"); let executable = directory.path().join("fabro"); - fs::write(&executable, b"current") + async_fs::write(&executable, b"current") .await .expect("fixture executable should be written"); - let monitor = ExecutableMonitor::new(executable).await.unwrap(); + let monitor = ExecutableMonitor::new(executable).unwrap(); - assert!(!monitor.was_replaced().await); + assert!(!monitor.was_replaced()); } #[tokio::test] @@ -149,42 +106,34 @@ mod tests { let directory = tempfile::tempdir().expect("temp directory should exist"); let executable = directory.path().join("fabro"); let replacement = directory.path().join("fabro-new"); - fs::write(&executable, b"old") + async_fs::write(&executable, b"old") .await .expect("old fixture executable should be written"); - fs::write(&replacement, b"new executable") + async_fs::write(&replacement, b"new executable") .await .expect("new fixture executable should be written"); - let monitor = ExecutableMonitor::new(executable.clone()).await.unwrap(); + let monitor = ExecutableMonitor::new(executable.clone()).unwrap(); - fs::rename(&replacement, &executable) + async_fs::rename(&replacement, &executable) .await .expect("fixture executable should be replaced"); - assert!(monitor.was_replaced().await); + assert!(monitor.was_replaced()); } #[tokio::test] async fn removed_executable_is_detected() { let directory = tempfile::tempdir().expect("temp directory should exist"); let executable = directory.path().join("fabro"); - fs::write(&executable, b"current") + async_fs::write(&executable, b"current") .await .expect("fixture executable should be written"); - let monitor = ExecutableMonitor::new(executable.clone()).await.unwrap(); + let monitor = ExecutableMonitor::new(executable.clone()).unwrap(); - fs::remove_file(executable) + async_fs::remove_file(executable) .await .expect("fixture executable should be removed"); - assert!(monitor.was_replaced().await); - } - - #[test] - fn executable_check_rejects_directories() { - let directory = tempfile::tempdir().expect("temp directory should exist"); - let metadata = std::fs::metadata(directory.path()).unwrap(); - - assert!(!is_executable_file(&metadata)); + assert!(monitor.was_replaced()); } } diff --git a/lib/apps/fabro-mcp-server/src/lib.rs b/lib/apps/fabro-mcp-server/src/lib.rs index 7aa598e80..068f99c14 100644 --- a/lib/apps/fabro-mcp-server/src/lib.rs +++ b/lib/apps/fabro-mcp-server/src/lib.rs @@ -11,7 +11,11 @@ use std::sync::Arc; use anyhow::Result; pub use config::{config_json, init_agent}; use fabro_client::Client; -pub use server::{McpServerExit, start}; +pub use server::start; + +/// The name this MCP server reports over the wire and registers under in agent +/// config files. +pub(crate) const SERVER_NAME: &str = "fabro"; pub type FabroClientFuture = Pin> + Send>>; diff --git a/lib/apps/fabro-mcp-server/src/server.rs b/lib/apps/fabro-mcp-server/src/server.rs index 7fdc64d2e..924ddf9d5 100644 --- a/lib/apps/fabro-mcp-server/src/server.rs +++ b/lib/apps/fabro-mcp-server/src/server.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use anyhow::Result; use fabro_tool::fabro_client::ClientBackend; @@ -12,10 +13,12 @@ use rmcp::transport::stdio; use rmcp::{ErrorData, ServerHandler, serve_server, tool, tool_handler, tool_router}; use serde::Serialize; use tokio::sync::OnceCell; +use tokio::time; +use tracing::warn; -use crate::FabroMcpServerSettings; use crate::executable_monitor::ExecutableMonitor; use crate::manifest_builder::McpRunManifestBuilder; +use crate::{FabroMcpServerSettings, SERVER_NAME}; #[derive(Clone)] pub(crate) struct FabroMcpServer { @@ -25,45 +28,52 @@ pub(crate) struct FabroMcpServer { tool_router: ToolRouter, } -/// The reason a running MCP stdio server returned to its caller. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum McpServerExit { - /// The MCP service stopped without an executable replacement. - ServiceStopped, - /// The executable on disk changed while the MCP service was running. - ExecutableReplaced, -} +/// How long to wait for the MCP service to stop after an upgrade is detected. +/// Bounded because the transport closes by writing to a stdout the host may +/// already have stopped reading. +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); -pub async fn start(settings: FabroMcpServerSettings) -> Result { - let executable_monitor = ExecutableMonitor::current().await.ok(); - let server = FabroMcpServer::new(Arc::new(settings)); - let service = serve_server(server, stdio()).await?; - let exit = if let Some(executable_monitor) = executable_monitor { - let cancellation = service.cancellation_token(); - let mut service_wait = Box::pin(service.waiting()); - tokio::select! { - result = &mut service_wait => { - result?; - McpServerExit::ServiceStopped - } - () = executable_monitor.wait_until_replaced() => { - cancellation.cancel(); - service_wait.await?; - McpServerExit::ExecutableReplaced - } +pub async fn start(settings: FabroMcpServerSettings) -> Result<()> { + let monitor = match ExecutableMonitor::current() { + Ok(monitor) => Some(monitor), + Err(error) => { + warn!( + %error, + "Upgrade detection is unavailable; this MCP server will keep running after an \ + upgrade replaces it" + ); + None } - } else { - service.waiting().await?; - McpServerExit::ServiceStopped }; - Ok(exit) + let service = serve_server(FabroMcpServer::new(Arc::new(settings)), stdio()).await?; + let Some(monitor) = monitor else { + service.waiting().await?; + return Ok(()); + }; + + let cancellation = service.cancellation_token(); + let mut service_wait = Box::pin(service.waiting()); + tokio::select! { + result = &mut service_wait => { + result?; + } + () = monitor.wait_until_replaced() => { + // An upgrade replaced the executable, so stop serving and let the + // host reconnect to the new one. The CLI exits the process rather + // than returning, because Tokio's stdin worker stays blocked on a + // read that only the host can end. + cancellation.cancel(); + let _ = time::timeout(SHUTDOWN_TIMEOUT, service_wait).await; + } + } + Ok(()) } #[tool_handler(router = self.tool_router)] impl ServerHandler for FabroMcpServer { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new("fabro", FABRO_VERSION).with_title("Fabro")) + .with_server_info(Implementation::new(SERVER_NAME, FABRO_VERSION).with_title("Fabro")) .with_instructions("Use these tools to create, inspect, control, wait for, and read events from Fabro workflow runs.") } } From c738130e531c8ef6b08e2d9f1bc002eec87a0f91 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 1 Aug 2026 09:18:38 -0400 Subject: [PATCH 41/42] fix: sort unexpected properties before comparing repair attempts Addresses Copilot review feedback on the repeated-failure check. serde_json runs with preserve_order, and jsonschema builds the additionalProperties `unexpected` list by walking the instance in document order. So the same leftover keys emitted in a different order produced a different Vec and compared as a different problem, which suppressed the "unchanged from your previous repair" nudge. Sorting at capture also makes the MAX_UNEXPECTED_PROPERTIES truncation pick the same subset every time instead of an order-dependent one, and stabilizes the rendered message. Co-Authored-By: Claude Fable 5 --- .../src/handler/structured_output.rs | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/components/fabro-workflow/src/handler/structured_output.rs b/lib/components/fabro-workflow/src/handler/structured_output.rs index a208dd2cc..845c7b070 100644 --- a/lib/components/fabro-workflow/src/handler/structured_output.rs +++ b/lib/components/fabro-workflow/src/handler/structured_output.rs @@ -102,13 +102,17 @@ impl SchemaValidationIssue { .map_or_else(|| property.to_string(), str::to_owned), }, ValidationErrorKind::AdditionalProperties { unexpected } => { + // `unexpected` arrives in the order the model emitted the keys, + // so sort before truncating. That keeps the retained subset and + // the rendered message stable, and lets two attempts that left + // the same keys in place compare equal whatever order they used. + let total = unexpected.len(); + let mut sorted = unexpected.clone(); + sorted.sort_unstable(); + sorted.truncate(MAX_UNEXPECTED_PROPERTIES); SchemaValidationIssueDetail::AdditionalProperties { - total: unexpected.len(), - unexpected: unexpected - .iter() - .take(MAX_UNEXPECTED_PROPERTIES) - .cloned() - .collect(), + unexpected: sorted, + total, } } _ => SchemaValidationIssueDetail::Other { @@ -969,6 +973,26 @@ mod tests { ); } + #[test] + fn the_same_unexpected_properties_in_a_new_order_are_still_unchanged() { + let schema = schema(serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "findings": { "type": "array" } + } + })); + let previous = validate_response_text(&schema, r#"{"beta":1,"alpha":1}"#).unwrap_err(); + let current = validate_response_text(&schema, r#"{"alpha":1,"beta":1}"#).unwrap_err(); + + let repair = current.repair_message(&schema, Some(&previous)); + + assert!( + repair.contains("unchanged from your previous repair"), + "unexpected repair message: {repair}", + ); + } + #[test] fn a_different_problem_at_the_same_location_is_not_called_unchanged() { let schema = schema(serde_json::json!({ From 2adb44707de10814cf7edb097af9cadf218319f1 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 1 Aug 2026 09:18:57 -0400 Subject: [PATCH 42/42] Address Copilot review comments Raise the replacement test's deadline to 20s. The server takes up to 1s to notice the replacement and then bounds its own shutdown at 5s, so the old 5s deadline sat below the worst case and could fail a healthy server on a loaded runner. A passing run still exits in about a second. Reword the SHUTDOWN_TIMEOUT comment. Co-Authored-By: Claude Opus 5 (1M context) --- lib/apps/fabro-cli/tests/it/cmd/mcp.rs | 5 ++++- lib/apps/fabro-mcp-server/src/server.rs | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs index 253134714..e810bd4d9 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/mcp.rs @@ -555,7 +555,10 @@ fn stdio_server_exits_when_executable_is_replaced() { fs::write(&replacement, b"replacement").expect("replacement file should be written"); fs::rename(replacement, &executable).expect("Fabro executable should be replaced"); - let deadline = Instant::now() + Duration::from_secs(5); + // The server takes up to 1s to notice the replacement and then bounds its + // own shutdown at 5s, so 6s is the worst case. Allow more so a loaded runner + // cannot fail a healthy server; a passing run exits in about a second. + let deadline = Instant::now() + Duration::from_secs(20); let status = loop { if let Some(status) = child.try_wait().expect("MCP server should be polled") { break status; diff --git a/lib/apps/fabro-mcp-server/src/server.rs b/lib/apps/fabro-mcp-server/src/server.rs index 924ddf9d5..6a9c9941d 100644 --- a/lib/apps/fabro-mcp-server/src/server.rs +++ b/lib/apps/fabro-mcp-server/src/server.rs @@ -29,8 +29,8 @@ pub(crate) struct FabroMcpServer { } /// How long to wait for the MCP service to stop after an upgrade is detected. -/// Bounded because the transport closes by writing to a stdout the host may -/// already have stopped reading. +/// The wait is bounded because the transport closes by writing to stdout, which +/// blocks if the host has stopped reading. const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); pub async fn start(settings: FabroMcpServerSettings) -> Result<()> {