From 8fba42ed186b9d894d42efb5ebb6acd6f356391f Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 19:15:49 +0000 Subject: [PATCH 01/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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 a2b64dea2a6d2a4694f405ff1685bd4901f57680 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 08:44:43 -0400 Subject: [PATCH 10/57] fix(model): prioritize Modal over Kimi --- lib/foundation/fabro-model/src/catalog.rs | 15 ++++++++++++--- .../fabro-model/src/catalog/providers/modal.toml | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 21aa04888..5cf218a34 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3580,7 +3580,7 @@ enabled = true assert_eq!(provider.codec, CodecKind::OpenAiCompatible); assert_eq!(provider.agent_profile, AgentProfileKind::Kimi); assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); - assert_eq!(provider.priority, 30); + assert_eq!(provider.priority, 75); assert!(provider.auth.is_none()); assert_eq!( provider.extra_headers, @@ -3695,7 +3695,7 @@ enabled = true } #[test] - fn builtin_kimi_k3_selection_prefers_direct_kimi_then_modal_over_openrouter() { + fn builtin_kimi_k3_selection_prefers_modal_then_direct_kimi_over_openrouter() { let kimi = ProviderId::new("kimi"); let modal = ProviderId::new("modal"); let openrouter = ProviderId::new("openrouter"); @@ -3716,7 +3716,16 @@ enabled = true None, &HashSet::from([kimi.clone(), modal.clone(), openrouter.clone()]), ) - .expect("direct Kimi should win portable Kimi K3 selection"); + .expect("Modal should win portable Kimi K3 selection"); + assert_eq!(selected.provider, modal); + + let selected = catalog + .select( + "kimi-k3", + None, + &HashSet::from([kimi.clone(), openrouter.clone()]), + ) + .expect("direct Kimi should win when Modal is unavailable"); assert_eq!(selected.provider, kimi); let selected = catalog diff --git a/lib/foundation/fabro-model/src/catalog/providers/modal.toml b/lib/foundation/fabro-model/src/catalog/providers/modal.toml index 332dd9bfc..d00e40bd1 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/modal.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/modal.toml @@ -3,7 +3,7 @@ display_name = "Modal" adapter = "openai_compatible" agent_profile = "kimi" api_key_url = "https://modal.com/docs/guide/endpoints#proxy-tokens" -priority = 30 +priority = 75 enabled = false [providers.modal.extra_headers] From d4cefb16c937b45a60c4bd39a24198c8455ef295 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 08:47:00 -0400 Subject: [PATCH 11/57] fix(web): update Modal and OpenRouter logos Render Modal with the theme color and replace the legacy OpenRouter mark with the current 2026 brand glyph. --- apps/fabro-web/public/images/providers/modal.svg | 2 +- apps/fabro-web/public/images/providers/openrouter.svg | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/fabro-web/public/images/providers/modal.svg b/apps/fabro-web/public/images/providers/modal.svg index 6499237e9..8360e8aa7 100644 --- a/apps/fabro-web/public/images/providers/modal.svg +++ b/apps/fabro-web/public/images/providers/modal.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/fabro-web/public/images/providers/openrouter.svg b/apps/fabro-web/public/images/providers/openrouter.svg index 4069034cd..ec0d854e6 100644 --- a/apps/fabro-web/public/images/providers/openrouter.svg +++ b/apps/fabro-web/public/images/providers/openrouter.svg @@ -1,6 +1,3 @@ - - - - - + + From 24f9ac6bd18787c65ec4c4188413c88475bba948 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 09:00:52 -0400 Subject: [PATCH 12/57] refactor(model): rename Kimi provider to Moonshot --- .../providers/{kimi.svg => moonshot.svg} | 7 ++- .../administration/server-configuration.mdx | 2 +- docs/public/changelog/2026-02-28.mdx | 2 +- docs/public/changelog/2026-07-22.mdx | 2 +- docs/public/core-concepts/models.mdx | 6 +- docs/public/execution/run-configuration.mdx | 4 +- docs/public/reference/sdk.mdx | 2 +- lib/apps/fabro-cli/src/commands/install.rs | 2 +- lib/apps/fabro-cli/src/commands/model.rs | 2 +- .../fabro-cli/src/shared/provider_auth.rs | 2 +- lib/apps/fabro-server/src/run_manifest.rs | 41 +++++++------ lib/apps/fabro-server/src/server/tests.rs | 14 ++--- lib/apps/fabro-server/tests/it/api/install.rs | 14 +++-- lib/components/fabro-agent/src/compaction.rs | 6 +- .../fabro-agent/src/profiles/kimi.rs | 6 +- .../fabro-agent/src/profiles/mod.rs | 2 +- .../fabro-agent/src/profiles/openai.rs | 8 +-- ...t_file_and_web_search_prompt_snapshot.snap | 2 +- ...sts__openai_edit_file_prompt_snapshot.snap | 2 +- .../fabro-agent/tests/it/parity_matrix.rs | 14 ++--- .../fabro-llm/src/adapter_registry.rs | 6 +- lib/components/fabro-llm/src/client.rs | 17 +++--- lib/components/fabro-llm/src/codec/mod.rs | 2 +- .../src/codec/openai_compatible/mod.rs | 2 +- .../src/codec/openai_compatible/request.rs | 10 ++-- .../fabro-llm/src/providers/anthropic.rs | 9 +-- lib/components/fabro-llm/tests/integration.rs | 2 +- .../fabro-llm/tests/it/wire/anthropic.rs | 14 ++--- .../tests/it/wire/openai_compatible.rs | 12 ++-- ...c__custom_named_stream_error_identity.snap | 2 +- ...thropic__custom_named_stream_identity.snap | 2 +- .../fabro-workflow/src/handler/llm/api.rs | 2 +- .../fabro-workflow/src/model_fallback.rs | 10 ++-- lib/foundation/fabro-config/src/layers/llm.rs | 58 ++++++++++--------- lib/foundation/fabro-config/src/parse.rs | 8 +-- lib/foundation/fabro-model/src/billing.rs | 7 ++- lib/foundation/fabro-model/src/catalog.rs | 24 ++++---- .../providers/{kimi.toml => moonshot.toml} | 24 ++++---- lib/foundation/fabro-model/src/ids.rs | 4 +- 39 files changed, 186 insertions(+), 169 deletions(-) rename apps/fabro-web/public/images/providers/{kimi.svg => moonshot.svg} (98%) rename lib/foundation/fabro-model/src/catalog/providers/{kimi.toml => moonshot.toml} (70%) diff --git a/apps/fabro-web/public/images/providers/kimi.svg b/apps/fabro-web/public/images/providers/moonshot.svg similarity index 98% rename from apps/fabro-web/public/images/providers/kimi.svg rename to apps/fabro-web/public/images/providers/moonshot.svg index 3cdf7c868..0670e864e 100644 --- a/apps/fabro-web/public/images/providers/kimi.svg +++ b/apps/fabro-web/public/images/providers/moonshot.svg @@ -1,3 +1,8 @@ - + diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index c806406f7..23013c749 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -383,7 +383,7 @@ fabro secret set GEMINI_API_KEY AI... | `ANTHROPIC_API_KEY` | Anthropic (Claude) | | `OPENAI_API_KEY` | OpenAI (GPT) | | `GEMINI_API_KEY` | Google (Gemini) | -| `KIMI_API_KEY` | Kimi | +| `KIMI_API_KEY` | Moonshot AI | | `ZAI_API_KEY` | Zai (GLM) | | `MINIMAX_API_KEY` | Minimax | | `INCEPTION_API_KEY` | Inception (Mercury) | diff --git a/docs/public/changelog/2026-02-28.mdx b/docs/public/changelog/2026-02-28.mdx index 261594e32..78fadc46b 100644 --- a/docs/public/changelog/2026-02-28.mdx +++ b/docs/public/changelog/2026-02-28.mdx @@ -26,7 +26,7 @@ Previously, you could only see the final output of a run. Now you can trace exac -- Kimi, Z.AI, and Minimax LLM providers added to model catalog +- Moonshot AI, Z.AI, and Minimax LLM providers added to model catalog - Runs use time-sortable ULIDs instead of UUIDs, making it easier to find recent runs in logs and the API - Asymmetric JWT authentication between fabro-web and the API server, with GitHub identity in the token diff --git a/docs/public/changelog/2026-07-22.mdx b/docs/public/changelog/2026-07-22.mdx index a84beaea1..261e7f4b1 100644 --- a/docs/public/changelog/2026-07-22.mdx +++ b/docs/public/changelog/2026-07-22.mdx @@ -29,7 +29,7 @@ Schema upgrades now create a consistent `fabro.sqlite3.pre-migration.bak` snapsh -- Added Kimi K3 through the direct Kimi provider and OpenRouter +- Added Kimi K3 through the direct Moonshot AI provider and OpenRouter - Added GLM 5.2 through Z.AI and OpenRouter - Left-to-right run graphs now zoom to 400% and preserve separate viewport positions for left-to-right and top-to-bottom layouts diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index a4befa37f..47be54ee4 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -57,8 +57,8 @@ Fabro performs this selection once when creating a run and persists the chosen p | `gemini-3.5-flash` | gemini | `gemini-35-flash` | 1M | $1.50 / $9.00 | 150 tok/s | | `gemini-3-flash-preview` | gemini | `gemini-flash` | 1M | $0.50 / $3.00 | 150 tok/s | | `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 | +| `kimi-k2.5` | moonshot | | 262K | $0.60 / $3.00 | 50 tok/s | +| `kimi-k3` | moonshot | `kimi` | 1M | $3.00 / $15.00 | 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 | @@ -227,7 +227,7 @@ When no model or provider is specified, Fabro chooses the default offering on th | `anthropic` | `claude-sonnet-4-6` | | `openai` | `gpt-5.6-sol` | | `gemini` | `gemini-3.5-flash` | -| `kimi` | `kimi-k3` | +| `moonshot` | `kimi-k3` | | `poolside` | `laguna-s-2.1` | | `zai` | `glm-5.2` | | `minimax` | `minimax-m2.5` | diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index dd8819052..56e719ce0 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -151,7 +151,7 @@ For a qualified fallback, the selector may be that provider's canonical model ID ```toml title="run.toml" [run.model.fallbacks] "kimi-k3" = [ - "kimi:kimi-k3", + "moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus", ] @@ -169,7 +169,7 @@ For example, a server that selects Modal as the primary `kimi-k3` offering can d ```toml title="settings.toml" [run.model.fallbacks] -"kimi-k3" = ["kimi:kimi-k3", "openrouter:kimi-k3", "claude-opus"] +"kimi-k3" = ["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"] "glm-5.2" = ["gpt-sol"] "gpt-sol" = ["claude-opus"] "claude-opus" = ["gpt-sol"] diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index feeea0c0a..929840cd5 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -366,7 +366,7 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia | `ANTHROPIC_API_KEY` | Anthropic | | `OPENAI_API_KEY` | OpenAI | | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Gemini | -| `KIMI_API_KEY` | Kimi | +| `KIMI_API_KEY` | Moonshot AI | | `ZAI_API_KEY` | ZAI | | `MINIMAX_API_KEY` | Minimax | | `INCEPTION_API_KEY` | Inception | diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index 3486b35f5..2db9d4470 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -3515,7 +3515,7 @@ root = "{}" assert!(ids.contains(&ProviderId::anthropic())); assert!(ids.contains(&ProviderId::openai())); assert!(ids.contains(&ProviderId::gemini())); - assert!(ids.contains(&ProviderId::new("kimi"))); + assert!(ids.contains(&ProviderId::new("moonshot"))); assert!(ids.contains(&ProviderId::new("zai"))); assert!(ids.contains(&ProviderId::new("minimax"))); assert!(ids.contains(&ProviderId::new("inception"))); diff --git a/lib/apps/fabro-cli/src/commands/model.rs b/lib/apps/fabro-cli/src/commands/model.rs index 774feae42..dcff19c81 100644 --- a/lib/apps/fabro-cli/src/commands/model.rs +++ b/lib/apps/fabro-cli/src/commands/model.rs @@ -723,7 +723,7 @@ mod tests { .body( serde_json::json!({ "model_id": "kimi-k2.5", - "provider": "kimi", + "provider": "moonshot", "status": "skip" }) .to_string(), diff --git a/lib/apps/fabro-cli/src/shared/provider_auth.rs b/lib/apps/fabro-cli/src/shared/provider_auth.rs index 1d3992f1f..f61663e84 100644 --- a/lib/apps/fabro-cli/src/shared/provider_auth.rs +++ b/lib/apps/fabro-cli/src/shared/provider_auth.rs @@ -386,7 +386,7 @@ mod tests { ProviderId::anthropic(), ProviderId::openai(), ProviderId::gemini(), - ProviderId::new("kimi"), + ProviderId::new("moonshot"), ProviderId::new("zai"), ProviderId::new("minimax"), ProviderId::new("inception"), diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index fd9a1f50c..ac3b94071 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -1625,7 +1625,7 @@ enabled = true let mut checks = Vec::new(); let configured = std::collections::BTreeMap::from([( "kimi-k3".to_string(), - model_refs(&["kimi:kimi-k3", "openrouter:kimi-k3"]), + model_refs(&["moonshot:kimi-k3", "openrouter:kimi-k3"]), )]); assert!(run_model_fallback_check( @@ -1637,13 +1637,12 @@ enabled = true let check = checks.last().expect("fallback check should be present"); assert_eq!(check.status, CheckStatus::Warning); - assert!( - check - .details - .iter() - .any(|detail| detail.warn - && detail.text.contains("provider `kimi` is not configured")) - ); + assert!(check.details.iter().any(|detail| { + detail.warn + && detail + .text + .contains("provider `moonshot` is not configured") + })); } #[test] @@ -1686,15 +1685,15 @@ enabled = true }) } - fn ready_kimi_and_openrouter_state( + fn ready_moonshot_and_openrouter_state( server: &httpmock::MockServer, ) -> Arc { - let kimi_url = server.url("/kimi/v1"); + let moonshot_url = server.url("/moonshot/v1"); let openrouter_url = server.url("/openrouter/v1"); let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!( r#" -[providers.kimi] -base_url = "{kimi_url}" +[providers.moonshot] +base_url = "{moonshot_url}" [providers.openrouter] base_url = "{openrouter_url}" @@ -1706,7 +1705,7 @@ enabled = true crate::test_support::TestAppStateBuilder::new() .llm_catalog_settings(llm_catalog_settings) .vault_entries([ - (EnvVars::KIMI_API_KEY, "test-kimi-key"), + (EnvVars::KIMI_API_KEY, "test-moonshot-key"), (EnvVars::OPENROUTER_API_KEY, "test-openrouter-key"), ]) .build() @@ -1723,7 +1722,7 @@ enabled = true .unwrap_or_default(); ready_providers.sort(); assert_eq!(ready_providers, vec![ - ProviderId::new("kimi"), + ProviderId::new("moonshot"), ProviderId::new("openrouter") ]); @@ -2748,7 +2747,7 @@ digraph Demo { .json_body(openai_compatible_completion("anthropic/claude-fable-5")); }) .await; - let state = ready_kimi_and_openrouter_state(&server); + let state = ready_moonshot_and_openrouter_state(&server); let (response, _ok) = preflight_for_model(&state, "claude-fable").await; @@ -2772,18 +2771,18 @@ digraph Demo { #[tokio::test] async fn preflight_uses_ready_providers_for_unknown_unqualified_model() { let server = httpmock::MockServer::start_async().await; - let kimi_probe = server + let moonshot_probe = server .mock_async(|when, then| { when.method(httpmock::Method::POST) - .path("/kimi/v1/chat/completions") - .header("authorization", "Bearer test-kimi-key") + .path("/moonshot/v1/chat/completions") + .header("authorization", "Bearer test-moonshot-key") .json_body_includes(r#"{"model":"provider-private-preview"}"#); then.status(200) .header("content-type", "application/json") .json_body(openai_compatible_completion("provider-private-preview")); }) .await; - let state = ready_kimi_and_openrouter_state(&server); + let state = ready_moonshot_and_openrouter_state(&server); let (response, _ok) = preflight_for_model(&state, "provider-private-preview").await; @@ -2802,10 +2801,10 @@ digraph Demo { .iter() .map(|detail| detail.text.as_str()) .find(|detail| detail.starts_with("Provider: ")), - Some("Provider: kimi") + Some("Provider: moonshot") ); assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Pass); - kimi_probe.assert_async().await; + moonshot_probe.assert_async().await; } #[test] diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index b3da5be63..cbe469354 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -7098,7 +7098,7 @@ async fn list_models_exposes_reasoning_effort_controls() { let req = Request::builder() .method("GET") - .uri(api("/models?provider=kimi")) + .uri(api("/models?provider=moonshot")) .body(Body::empty()) .unwrap(); @@ -15910,7 +15910,7 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() { then.status(500); }); let state = TestAppStateBuilder::new() - .provider_base_url("kimi", upstream.url("/v1")) + .provider_base_url("moonshot", upstream.url("/v1")) .vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")]) .build(); let app = crate::test_support::build_test_router(state); @@ -15923,7 +15923,7 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "provider": "kimi", + "provider": "moonshot", "model": "kimi-k3", "reasoning_effort": effort, "stream": stream, @@ -15982,7 +15982,7 @@ async fn create_completion_returns_disjoint_usage_buckets() { })); }); let state = TestAppStateBuilder::new() - .provider_base_url("kimi", upstream.base_url()) + .provider_base_url("moonshot", upstream.base_url()) .vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")]) .build(); let app = crate::test_support::build_test_router(state); @@ -15993,7 +15993,7 @@ async fn create_completion_returns_disjoint_usage_buckets() { .header("content-type", "application/json") .body(Body::from( json!({ - "provider": "kimi", + "provider": "moonshot", "model": "kimi-k3", "stream": false, "messages": [{ @@ -16122,7 +16122,7 @@ async fn create_completion_structured_output_forwards_reasoning_effort() { })); }); let state = TestAppStateBuilder::new() - .provider_base_url("kimi", upstream.base_url()) + .provider_base_url("moonshot", upstream.base_url()) .vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")]) .build(); let app = crate::test_support::build_test_router(state); @@ -16133,7 +16133,7 @@ async fn create_completion_structured_output_forwards_reasoning_effort() { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "provider": "kimi", + "provider": "moonshot", "model": "kimi-k3", "reasoning_effort": "high", "stream": false, diff --git a/lib/apps/fabro-server/tests/it/api/install.rs b/lib/apps/fabro-server/tests/it/api/install.rs index 8dde87fdd..cfd23dc14 100644 --- a/lib/apps/fabro-server/tests/it/api/install.rs +++ b/lib/apps/fabro-server/tests/it/api/install.rs @@ -143,7 +143,7 @@ async fn mock_anthropic_install_validation(server: &MockServer) -> httpmock::Moc .await } -async fn mock_kimi_install_validation(server: &MockServer) -> httpmock::Mock<'_> { +async fn mock_moonshot_install_validation(server: &MockServer) -> httpmock::Mock<'_> { server .mock_async(|when, then| { when.method("POST") @@ -1355,11 +1355,13 @@ async fn token_install_finish_invokes_shutdown_callback_after_accepting() { #[tokio::test] async fn install_llm_accepts_catalog_openai_compatible_provider() { let llm_mock = MockServer::start_async().await; - mock_kimi_install_validation(&llm_mock).await; + mock_moonshot_install_validation(&llm_mock).await; let app = build_install_router( - InstallAppState::for_test("test-install-token") - .with_provider_base_url(ProviderId::new("kimi"), format!("{}/v1", llm_mock.url(""))), + InstallAppState::for_test("test-install-token").with_provider_base_url( + ProviderId::new("moonshot"), + format!("{}/v1", llm_mock.url("")), + ), ); let test_response = app @@ -1371,7 +1373,7 @@ async fn install_llm_accepts_catalog_openai_compatible_provider() { .header("authorization", "Bearer test-install-token") .header("content-type", "application/json") .body(Body::from( - r#"{"provider":"kimi","api_key":"kimi-test-key"}"#, + r#"{"provider":"moonshot","api_key":"kimi-test-key"}"#, )) .unwrap(), ) @@ -1388,7 +1390,7 @@ async fn install_llm_accepts_catalog_openai_compatible_provider() { .header("authorization", "Bearer test-install-token") .header("content-type", "application/json") .body(Body::from( - r#"{"providers":[{"provider":"kimi","api_key":"kimi-test-key"}]}"#, + r#"{"providers":[{"provider":"moonshot","api_key":"kimi-test-key"}]}"#, )) .unwrap(), ) diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index 2b09192b4..ad634955d 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -443,12 +443,12 @@ mod tests { #[test] fn summary_budget_for_always_reasoning_route_without_effort_adds_headroom() { - let kimi = ProviderId::new("kimi"); - let model = catalog_model(&kimi, "kimi-k2.5"); + let moonshot = ProviderId::new("moonshot"); + let model = catalog_model(&moonshot, "kimi-k2.5"); assert!(model.supports_reasoning()); assert!(!model.supports_reasoning_effort()); assert_eq!( - builtin_summary_max_tokens(&kimi, "kimi-k2.5"), + builtin_summary_max_tokens(&moonshot, "kimi-k2.5"), SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS ); } diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index a37131ff5..e04a7d19c 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -100,7 +100,7 @@ impl KimiProfile { Self { base: BaseProfile { profile_kind: AgentProfileKind::Kimi, - provider_id: ProviderId::new("kimi"), + provider_id: ProviderId::new("moonshot"), model: model.into(), catalog: None, registry, @@ -178,8 +178,8 @@ mod tests { #[test] fn kimi_models_select_the_kimi_profile_on_every_provider() { for (catalog, provider, model) in [ - (catalog(), "kimi", "kimi-k3"), - (catalog(), "kimi", "kimi-k2.5"), + (catalog(), "moonshot", "kimi-k3"), + (catalog(), "moonshot", "kimi-k2.5"), (catalog_with_openrouter(), "openrouter", "kimi-k3"), (catalog_with_openrouter(), "openrouter", "kimi-k2.6"), ] { diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index b2402f066..dbc0d0c54 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -548,7 +548,7 @@ mod tests { let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search); let deps = ProfileDeps::standalone(options); OpenAiProfile::with_native_tools("kimi-k2.5", &deps).with_route( - ProviderId::new("kimi"), + ProviderId::new("moonshot"), Arc::new(Catalog::from_builtin().unwrap()), ) } diff --git a/lib/components/fabro-agent/src/profiles/openai.rs b/lib/components/fabro-agent/src/profiles/openai.rs index 5f1029e87..d3010c859 100644 --- a/lib/components/fabro-agent/src/profiles/openai.rs +++ b/lib/components/fabro-agent/src/profiles/openai.rs @@ -229,19 +229,19 @@ mod tests { } #[test] - fn kimi_provider_prompt_uses_catalog_display_name() { + fn moonshot_provider_prompt_uses_catalog_display_name() { let profile = - OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("kimi"), test_catalog()); + OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("moonshot"), test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); - assert!(prompt.contains("powered by Kimi")); + assert!(prompt.contains("powered by Moonshot AI")); assert!(!prompt.contains("powered by OpenAI")); } #[test] fn openai_compatible_profile_uses_json_schema_edit_tool() { let profile = - OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("kimi"), test_catalog()); + OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("moonshot"), test_catalog()); let names = profile.tool_registry().names(); assert!(names.contains(&"edit_file".to_string())); diff --git a/lib/components/fabro-agent/src/profiles/snapshots/fabro_agent__profiles__tests__openai_edit_file_and_web_search_prompt_snapshot.snap b/lib/components/fabro-agent/src/profiles/snapshots/fabro_agent__profiles__tests__openai_edit_file_and_web_search_prompt_snapshot.snap index d2406f238..43e52f68b 100644 --- a/lib/components/fabro-agent/src/profiles/snapshots/fabro_agent__profiles__tests__openai_edit_file_and_web_search_prompt_snapshot.snap +++ b/lib/components/fabro-agent/src/profiles/snapshots/fabro_agent__profiles__tests__openai_edit_file_and_web_search_prompt_snapshot.snap @@ -2,7 +2,7 @@ source: lib/components/fabro-agent/src/profiles/mod.rs expression: system_prompt(&openai_edit_file_profile(true)) --- -You are a coding agent powered by Kimi, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful. +You are a coding agent powered by Moonshot AI, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful. You can receive user prompts and context such as files in the workspace, communicate with the user by streaming thinking and responses, and emit function calls to run terminal commands and edit files. diff --git a/lib/components/fabro-agent/src/profiles/snapshots/fabro_agent__profiles__tests__openai_edit_file_prompt_snapshot.snap b/lib/components/fabro-agent/src/profiles/snapshots/fabro_agent__profiles__tests__openai_edit_file_prompt_snapshot.snap index 478a8b2d4..2d192032c 100644 --- a/lib/components/fabro-agent/src/profiles/snapshots/fabro_agent__profiles__tests__openai_edit_file_prompt_snapshot.snap +++ b/lib/components/fabro-agent/src/profiles/snapshots/fabro_agent__profiles__tests__openai_edit_file_prompt_snapshot.snap @@ -2,7 +2,7 @@ source: lib/components/fabro-agent/src/profiles/mod.rs expression: system_prompt(&openai_edit_file_profile(false)) --- -You are a coding agent powered by Kimi, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful. +You are a coding agent powered by Moonshot AI, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful. You can receive user prompts and context such as files in the workspace, communicate with the user by streaming thinking and responses, and emit function calls to run terminal commands and edit files. diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index e37a29003..7f64a29fb 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -31,7 +31,7 @@ struct OpenAiTwinOptions { fn summarizer_model_id(provider: &Provider) -> ModelHandle { match provider.as_str() { - ProviderId::OPENAI | "kimi" | "zai" | "minimax" | "inception" => ModelHandle::ByName { + ProviderId::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => ModelHandle::ByName { provider: ProviderId::openai(), model: "gpt-5.4-mini".to_string(), }, @@ -279,7 +279,7 @@ macro_rules! provider_tests { ); provider_test!( $scenario, - ProviderId::new("kimi"), + ProviderId::new("moonshot"), "kimi-k2.5", kimi, keys = ["KIMI_API_KEY"] @@ -413,7 +413,7 @@ provider_test!( ); provider_test!( web_fetch, - ProviderId::new("kimi"), + ProviderId::new("moonshot"), "kimi-k2.5", kimi, keys = ["KIMI_API_KEY", "OPENAI_API_KEY"] @@ -461,7 +461,7 @@ web_search_provider_test!( keys = ["GEMINI_API_KEY", "BRAVE_SEARCH_API_KEY"] ); web_search_provider_test!( - ProviderId::new("kimi"), + ProviderId::new("moonshot"), "kimi-k2.5", kimi, keys = ["KIMI_API_KEY", "BRAVE_SEARCH_API_KEY"] @@ -518,7 +518,7 @@ macro_rules! non_openai_provider_tests { ); provider_test!( $scenario, - ProviderId::new("kimi"), + ProviderId::new("moonshot"), "kimi-k2.5", kimi, keys = ["KIMI_API_KEY"] @@ -800,7 +800,7 @@ reasoning_effort_tests!( keys = ["GEMINI_API_KEY"] ); reasoning_effort_tests!( - ProviderId::new("kimi"), + ProviderId::new("moonshot"), "kimi-k2.5", kimi_reasoning_effort, keys = ["KIMI_API_KEY"] @@ -882,7 +882,7 @@ loop_detection_tests!( keys = ["GEMINI_API_KEY"] ); loop_detection_tests!( - ProviderId::new("kimi"), + ProviderId::new("moonshot"), "kimi-k2.5", kimi_loop_detection, keys = ["KIMI_API_KEY"] diff --git a/lib/components/fabro-llm/src/adapter_registry.rs b/lib/components/fabro-llm/src/adapter_registry.rs index 26ff6d70b..1a09a31e7 100644 --- a/lib/components/fabro-llm/src/adapter_registry.rs +++ b/lib/components/fabro-llm/src/adapter_registry.rs @@ -425,10 +425,10 @@ mod tests { fn openai_compatible_factory_uses_provider_id_for_name() { let config = AdapterConfig { base_url: Some("https://api.moonshot.ai/v1".to_string()), - ..AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string())) + ..AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string())) }; let adapter = factory_for(AdapterKind::OpenAiCompatible)(config).unwrap(); - assert_eq!(adapter.name(), "kimi"); + assert_eq!(adapter.name(), "moonshot"); } #[test] @@ -489,7 +489,7 @@ mod tests { #[test] fn openai_compatible_factory_errors_without_base_url() { - let config = AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string())); + let config = AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string())); let Err(err) = factory_for(AdapterKind::OpenAiCompatible)(config) else { panic!("expected missing base_url error"); }; diff --git a/lib/components/fabro-llm/src/client.rs b/lib/components/fabro-llm/src/client.rs index e71fd9309..1c0d3e46e 100644 --- a/lib/components/fabro-llm/src/client.rs +++ b/lib/components/fabro-llm/src/client.rs @@ -1529,13 +1529,16 @@ base_url = "{}/v1" let mut client = Client::new(HashMap::new(), None, vec![]); client.catalog = Some(Arc::clone(&catalog)); client - .register_provider(Arc::new(MockProvider::new("kimi", "should not dispatch"))) + .register_provider(Arc::new(MockProvider::new( + "moonshot", + "should not dispatch", + ))) .await .unwrap(); let mut request = test_request(); request.model = "kimi-k2.5".to_string(); - request.provider = Some("kimi".to_string()); + request.provider = Some("moonshot".to_string()); request.reasoning_effort = Some(ReasoningEffort::High); let err = client.complete(&request).await.unwrap_err(); @@ -1554,13 +1557,13 @@ base_url = "{}/v1" let mut client = Client::new(HashMap::new(), None, vec![]); client.catalog = Some(Arc::clone(&catalog)); client - .register_provider(Arc::new(MockProvider::new("kimi", "accepted"))) + .register_provider(Arc::new(MockProvider::new("moonshot", "accepted"))) .await .unwrap(); let mut request = test_request(); request.model = "kimi-k3".to_string(); - request.provider = Some("kimi".to_string()); + request.provider = Some("moonshot".to_string()); request.reasoning_effort = Some(ReasoningEffort::High); let response = client.complete(&request).await.unwrap(); @@ -1724,7 +1727,7 @@ base_url = "{}/v1" let catalog = catalog_with(""); let client = Client::from_credentials( vec![ApiCredential { - provider: ProviderId::new("kimi"), + provider: ProviderId::new("moonshot"), auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())), extra_headers: HashMap::new(), base_url: None, @@ -1737,8 +1740,8 @@ base_url = "{}/v1" .await .unwrap(); - assert_eq!(client.provider_names(), vec!["kimi"]); - assert_eq!(client.default_provider(), Some("kimi")); + assert_eq!(client.provider_names(), vec!["moonshot"]); + assert_eq!(client.default_provider(), Some("moonshot")); } #[tokio::test] diff --git a/lib/components/fabro-llm/src/codec/mod.rs b/lib/components/fabro-llm/src/codec/mod.rs index 97bf842b9..0e90c471f 100644 --- a/lib/components/fabro-llm/src/codec/mod.rs +++ b/lib/components/fabro-llm/src/codec/mod.rs @@ -73,7 +73,7 @@ pub(crate) struct CodecCtx<'a> { /// the stream model fallback uses `request.model`). pub request: &'a Request, /// Identity stamped into `Response.provider`, and the `provider_options` - /// namespace key for the openai_compatible codec (kimi/zai/…). + /// namespace key for the openai_compatible codec (moonshot/zai/…). pub provider_name: &'a str, /// The model id to send on the wire — catalog `api_id`, resolved by the /// route (today `api_id == id` everywhere). diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs b/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs index 42a2fde09..6bcae4492 100644 --- a/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs @@ -1,6 +1,6 @@ //! The OpenAI Chat Completions (`/chat/completions`) codec. //! -//! Serves every "OpenAI-compatible" route (kimi, zai, minimax, venice, +//! Serves every "OpenAI-compatible" route (moonshot, zai, minimax, venice, //! inception, ollama, litellm, …). Pure translation: no HTTP, auth, or base //! URL — the adapter shell owns those. Count-tokens and error mapping use the //! `Codec` trait defaults (this dialect has no count route and uses the shared diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/request.rs b/lib/components/fabro-llm/src/codec/openai_compatible/request.rs index f6bbf3b16..8db03603f 100644 --- a/lib/components/fabro-llm/src/codec/openai_compatible/request.rs +++ b/lib/components/fabro-llm/src/codec/openai_compatible/request.rs @@ -110,7 +110,7 @@ fn apply_cache_breakpoints(messages: &mut [ChatMessage]) { /// Merge `provider_options.` fields into the serialized API /// request body. /// -/// The provider name is configurable (e.g. "groq", "together", "kimi"), +/// The provider name is configurable (e.g. "groq", "together", "moonshot"), /// allowing each instance to have its own namespace in `provider_options`. pub(super) fn merge_provider_options( body: &mut serde_json::Value, @@ -221,7 +221,7 @@ mod tests { let mut request = minimal_request(); request.reasoning_effort = Some(ReasoningEffort::High); - let body = encode_body(&request, "kimi", false); + let body = encode_body(&request, "moonshot", false); assert_eq!(body["reasoning_effort"], "high"); } @@ -229,7 +229,7 @@ mod tests { #[test] fn encode_omits_sampling_params_for_models_that_reject_them() { let model = Catalog::builtin() - .get_on_provider(&ProviderId::new("kimi"), "kimi-k3") + .get_on_provider(&ProviderId::new("moonshot"), "kimi-k3") .unwrap(); let mut request = minimal_request(); request.model = model.id.to_string(); @@ -238,7 +238,7 @@ mod tests { let params = CodecParams::default(); let ctx = CodecCtx { request: &request, - provider_name: "kimi", + provider_name: "moonshot", deployment_id: model.id.as_str(), model: Some(model), params: ¶ms, @@ -262,7 +262,7 @@ mod tests { let deployment_id = request.model.clone(); let ctx = CodecCtx { request: &request, - provider_name: "kimi", + provider_name: "moonshot", deployment_id: &deployment_id, model: None, params: ¶ms, diff --git a/lib/components/fabro-llm/src/providers/anthropic.rs b/lib/components/fabro-llm/src/providers/anthropic.rs index 83894e9a9..723e12441 100644 --- a/lib/components/fabro-llm/src/providers/anthropic.rs +++ b/lib/components/fabro-llm/src/providers/anthropic.rs @@ -169,7 +169,8 @@ impl Adapter { /// Collect a streaming response into a single [`Response`]. /// - /// Used by non-Anthropic providers (e.g. Kimi) that require `stream=true`. + /// Used by non-Anthropic providers (e.g. Moonshot) that require + /// `stream=true`. async fn complete_via_stream(&self, request: &Request) -> Result { use futures::StreamExt; @@ -266,7 +267,7 @@ impl ProviderAdapter for Adapter { self.validate_request(request)?; let route = self.route_config(); - // Non-Anthropic providers (e.g. Kimi) require stream=true even for + // Non-Anthropic providers (e.g. Moonshot) require stream=true even for // blocking calls. Collect the stream into a single Response. if route.force_streaming { return self.complete_via_stream(request).await; @@ -367,8 +368,8 @@ mod tests { #[test] fn adapter_with_name() { - let adapter = Adapter::new("key").with_name("kimi"); - assert_eq!(adapter.name(), "kimi"); + let adapter = Adapter::new("key").with_name("moonshot"); + assert_eq!(adapter.name(), "moonshot"); } #[test] diff --git a/lib/components/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs index 3c80ef796..8362d101b 100644 --- a/lib/components/fabro-llm/tests/integration.rs +++ b/lib/components/fabro-llm/tests/integration.rs @@ -178,7 +178,7 @@ async fn openai_gpt_5_5_pro_complete() { async fn kimi_k3_reasoning_tool_round_trip() { let api_key = std::env::var(EnvVars::KIMI_API_KEY).expect("KIMI_API_KEY must be set"); let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.moonshot.ai/v1") - .with_name("kimi") + .with_name("moonshot") .with_catalog(Arc::new(Catalog::from_builtin().unwrap())); let tool = ToolDefinition::function( "multiply", diff --git a/lib/components/fabro-llm/tests/it/wire/anthropic.rs b/lib/components/fabro-llm/tests/it/wire/anthropic.rs index ba73edaef..706893f16 100644 --- a/lib/components/fabro-llm/tests/it/wire/anthropic.rs +++ b/lib/components/fabro-llm/tests/it/wire/anthropic.rs @@ -835,11 +835,11 @@ async fn stream_without_message_stop_emits_no_finish() { } // --------------------------------------------------------------------------- -// Custom-named route (the Kimi-over-anthropic shape) +// Custom-named route (the Moonshot Kimi-over-anthropic shape) // --------------------------------------------------------------------------- -/// Shared setup for a custom-named anthropic-dialect (Kimi) stream route; the -/// request and event halves are pinned by separate tests. +/// Shared setup for a custom-named Moonshot Kimi stream route using the +/// Anthropic dialect; separate tests pin the request and event halves. async fn custom_named_stream_capture() -> (WireCapture, Vec) { let sse = support::sse_transcript(&[ ( @@ -865,15 +865,15 @@ async fn custom_named_stream_capture() -> (WireCapture, Vec) ("message_stop", r#"{"type":"message_stop"}"#), ]); stream_capture( - adapter().with_name("kimi"), + adapter().with_name("moonshot"), &base_request("kimi-test"), &sse, ) .await } -/// A custom-named (Kimi) route authenticates with a bearer token and sends no -/// `anthropic-version` header. This pins that route shape on the wire. +/// A custom-named Moonshot route authenticates with a bearer token and sends +/// no `anthropic-version` header. This pins that route shape on the wire. #[tokio::test] async fn custom_named_stream_route() { let (capture, _) = custom_named_stream_capture().await; @@ -903,7 +903,7 @@ async fn custom_named_stream_error_identity() { ), ]); let (_capture, events) = stream_capture( - adapter().with_name("kimi"), + adapter().with_name("moonshot"), &base_request("kimi-test"), &sse, ) 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..490aff18a 100644 --- a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs +++ b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs @@ -253,7 +253,7 @@ async fn encode_kimi_k3_uses_catalog_reasoning_and_sampling_controls() { ..base_request(MODEL) }; let capture = encode_capture_with(&request, move |adapter| { - adapter.with_name("kimi").with_catalog(catalog) + adapter.with_name("moonshot").with_catalog(catalog) }) .await; @@ -398,9 +398,9 @@ async fn encode_openrouter_claude_auto_cache_opt_out() { async fn encode_provider_options_keyed_by_adapter_name() { let request = corpus_provider_options( MODEL, - serde_json::json!({"kimi": {"repetition_penalty": 1.2}}), + serde_json::json!({"moonshot": {"repetition_penalty": 1.2}}), ); - let capture = encode_capture_with(&request, |adapter| adapter.with_name("kimi")).await; + let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await; fabro_test::fabro_json_snapshot!(capture.body); } @@ -411,7 +411,7 @@ async fn encode_provider_options_other_namespace_ignored() { MODEL, serde_json::json!({"openai": {"repetition_penalty": 1.2}}), ); - let capture = encode_capture_with(&request, |adapter| adapter.with_name("kimi")).await; + let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await; fabro_test::fabro_json_snapshot!(capture.body); } @@ -904,11 +904,11 @@ async fn stream_without_done_or_content_synthesizes_nothing() { async fn custom_named_complete_identity() { let server = MockServer::start(); let (mock, _slot) = mount_capture(&server, "/chat/completions", minimal_body()); - let adapter = adapter(&server).with_name("kimi"); + let adapter = adapter(&server).with_name("moonshot"); let response = adapter .complete(&base_request(MODEL)) .await .expect("complete should succeed"); mock.assert(); - assert_eq!(response.provider, "kimi"); + assert_eq!(response.provider, "moonshot"); } diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap index 9fb304d73..d9e90487b 100644 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap @@ -7,7 +7,7 @@ expression: rendered "type": "stream_start" }, { - "stream_item_error": "Server error from kimi: Overloaded", + "stream_item_error": "Server error from moonshot: Overloaded", "retryable": true, "failover_eligible": true } diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap index 6bb0e2544..83360e789 100644 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap +++ b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap @@ -32,7 +32,7 @@ expression: rendered "response": { "id": "msg_kimi", "model": "kimi-test", - "provider": "kimi", + "provider": "moonshot", "message": { "role": "assistant", "content": [ diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 5dad09d34..e3245eed6 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -3330,7 +3330,7 @@ enabled = true let policy = ModelFallbackPolicy::new(std::collections::BTreeMap::from([( "kimi-k3".to_string(), vec![ - FallbackTarget::new("kimi", "kimi-k3"), + FallbackTarget::new("moonshot", "kimi-k3"), FallbackTarget::new("openrouter", "kimi-k3"), FallbackTarget::new("anthropic", "claude-opus-5"), ], diff --git a/lib/components/fabro-workflow/src/model_fallback.rs b/lib/components/fabro-workflow/src/model_fallback.rs index b795cb6d5..f6de2fcd4 100644 --- a/lib/components/fabro-workflow/src/model_fallback.rs +++ b/lib/components/fabro-workflow/src/model_fallback.rs @@ -474,7 +474,7 @@ enabled = true let eligible = [ProviderId::new("openrouter")]; let configured = BTreeMap::from([( "kimi-k3".to_string(), - references(&["kimi:kimi-k3", "openrouter:kimi-k3"]), + references(&["moonshot:kimi-k3", "openrouter:kimi-k3"]), )]); let resolved = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap(); @@ -491,7 +491,7 @@ enabled = true requested_model, provider, .. - }] if requested_model == "kimi-k3" && provider == &ProviderId::new("kimi") + }] if requested_model == "kimi-k3" && provider == &ProviderId::new("moonshot") )); } @@ -512,13 +512,13 @@ enabled = true }; let eligible = [ ProviderId::new("modal"), - ProviderId::new("kimi"), + ProviderId::new("moonshot"), ProviderId::new("openrouter"), ]; let configured = BTreeMap::from([ ( "kimi-k3".to_string(), - references(&["kimi:kimi-k3", "openrouter:kimi-k3", "claude-opus"]), + references(&["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"]), ), ("glm-5.2".to_string(), references(&["gpt-sol"])), ("gpt-sol".to_string(), references(&["claude-opus"])), @@ -541,7 +541,7 @@ enabled = true .expect("requested model should have a chain") }; assert_eq!(chain("kimi-k3"), [ - FallbackTarget::new("kimi", "kimi-k3"), + FallbackTarget::new("moonshot", "kimi-k3"), FallbackTarget::new("openrouter", "kimi-k3"), FallbackTarget::new("openrouter", "claude-opus-5"), ]); diff --git a/lib/foundation/fabro-config/src/layers/llm.rs b/lib/foundation/fabro-config/src/layers/llm.rs index 1e49fe1c9..d232c40b7 100644 --- a/lib/foundation/fabro-config/src/layers/llm.rs +++ b/lib/foundation/fabro-config/src/layers/llm.rs @@ -3,16 +3,16 @@ //! Holds the trusted, mergeable LLM provider/model catalog data: //! //! ```toml -//! [llm.providers.kimi] -//! display_name = "Kimi" +//! [llm.providers.moonshot] +//! display_name = "Moonshot AI" //! adapter = "openai_compatible" //! base_url = "https://api.moonshot.ai/v1" //! auth = { credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] } //! priority = 60 //! enabled = true -//! aliases = ["moonshot"] +//! aliases = ["moonshot-ai"] //! -//! [llm.providers.kimi.models."kimi-k2.5"] +//! [llm.providers.moonshot.models."kimi-k2.5"] //! ... //! ``` //! @@ -489,33 +489,39 @@ agent_profile = "gemini" #[test] fn parses_minimal_provider_entry() { let toml = r#" -[providers.kimi] -display_name = "Kimi" +[providers.moonshot] +display_name = "Moonshot AI" adapter = "openai_compatible" agent_profile = "openai" base_url = "https://api.moonshot.ai/v1" priority = 60 enabled = true -aliases = ["moonshot"] +aliases = ["moonshot-ai"] -[providers.kimi.auth] +[providers.moonshot.auth] credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] "#; let layer: LlmLayer = toml::from_str(toml).unwrap(); - let kimi = layer.providers.get("kimi").unwrap(); - assert_eq!(kimi.display_name.as_deref(), Some("Kimi")); - assert_eq!(kimi.adapter.as_deref(), Some("openai_compatible")); - assert_eq!(kimi.agent_profile, Some(AgentProfileKind::OpenAi)); - let auth = kimi.auth.as_ref().expect("expected api_key auth"); + let moonshot = layer.providers.get("moonshot").unwrap(); + assert_eq!(moonshot.display_name.as_deref(), Some("Moonshot AI")); + assert_eq!(moonshot.adapter.as_deref(), Some("openai_compatible")); + assert_eq!(moonshot.agent_profile, Some(AgentProfileKind::OpenAi)); + let auth = moonshot.auth.as_ref().expect("expected api_key auth"); assert_eq!(auth.header, ApiKeyHeaderPolicy::Bearer); assert_eq!(auth.credentials, vec![ CredentialRef::Env("KIMI_API_KEY".to_string()), CredentialRef::Vault("KIMI_API_KEY".to_string()), ]); - assert_eq!(kimi.base_url.as_deref(), Some("https://api.moonshot.ai/v1")); - assert_eq!(kimi.priority, Some(60)); - assert_eq!(kimi.enabled, Some(true)); - assert_eq!(kimi.aliases.as_deref(), Some(&["moonshot".to_string()][..])); + assert_eq!( + moonshot.base_url.as_deref(), + Some("https://api.moonshot.ai/v1") + ); + assert_eq!(moonshot.priority, Some(60)); + assert_eq!(moonshot.enabled, Some(true)); + assert_eq!( + moonshot.aliases.as_deref(), + Some(&["moonshot-ai".to_string()][..]) + ); } #[test] @@ -584,7 +590,7 @@ x-portkey-api-key = "sk-portkey-literal" fn parses_full_model_entry() { let toml = r#" [models."kimi-k2.5"] -provider = "kimi" +provider = "moonshot" api_id = "kimi-k2.5" display_name = "Kimi K2.5" family = "kimi" @@ -611,7 +617,7 @@ cache_input_cost_per_mtok = 0.15 "#; let layer: LlmLayer = toml::from_str(toml).unwrap(); let m = layer.models.get("kimi-k2.5").unwrap(); - assert_eq!(m.provider.as_deref(), Some("kimi")); + assert_eq!(m.provider.as_deref(), Some("moonshot")); assert_eq!(m.api_id.as_deref(), Some("kimi-k2.5")); assert_eq!(m.display_name.as_deref(), Some("Kimi K2.5")); assert_eq!(m.family.as_deref(), Some("kimi")); @@ -714,7 +720,7 @@ cache_input_cost_per_mtok = 9.0 #[test] fn rejects_unknown_provider_field() { let toml = r#" -[providers.kimi] +[providers.moonshot] adapter = "openai_compatible" unknown_field = true "#; @@ -725,7 +731,7 @@ unknown_field = true #[test] fn rejects_removed_provider_base_url_env_field() { let toml = r#" -[providers.kimi] +[providers.moonshot] adapter = "openai_compatible" base_url_env = "KIMI_BASE_URL" "#; @@ -902,7 +908,7 @@ mystery = 1 fn merge_map_field_merges_per_provider_id() { let mut high_map: std::collections::HashMap = std::collections::HashMap::new(); - high_map.insert("kimi".to_string(), ProviderSettings { + high_map.insert("moonshot".to_string(), ProviderSettings { base_url: Some("https://override".to_string()), ..ProviderSettings::default() }); @@ -910,7 +916,7 @@ mystery = 1 let mut low_map: std::collections::HashMap = std::collections::HashMap::new(); - low_map.insert("kimi".to_string(), ProviderSettings { + low_map.insert("moonshot".to_string(), ProviderSettings { adapter: Some("openai_compatible".to_string()), base_url: Some("https://defaults".to_string()), ..ProviderSettings::default() @@ -918,9 +924,9 @@ mystery = 1 let low: MergeMap = MergeMap::from(low_map); let merged = high.combine(low); - let kimi = merged.get("kimi").unwrap(); - assert_eq!(kimi.adapter.as_deref(), Some("openai_compatible")); - assert_eq!(kimi.base_url.as_deref(), Some("https://override")); + let moonshot = merged.get("moonshot").unwrap(); + assert_eq!(moonshot.adapter.as_deref(), Some("openai_compatible")); + assert_eq!(moonshot.base_url.as_deref(), Some("https://override")); } #[test] diff --git a/lib/foundation/fabro-config/src/parse.rs b/lib/foundation/fabro-config/src/parse.rs index 47b88b728..8ed2273bd 100644 --- a/lib/foundation/fabro-config/src/parse.rs +++ b/lib/foundation/fabro-config/src/parse.rs @@ -292,15 +292,15 @@ mod tests { #[test] fn accepts_new_llm_providers_subtree() { - let parsed = "[llm.providers.kimi]\nadapter = \"openai_compatible\"\n" + let parsed = "[llm.providers.moonshot]\nadapter = \"openai_compatible\"\n" .parse::() .unwrap(); - assert!(parsed.llm.unwrap().providers.contains_key("kimi")); + assert!(parsed.llm.unwrap().providers.contains_key("moonshot")); } #[test] fn accepts_new_llm_models_subtree() { - let parsed = "[llm.providers.kimi.models.\"foo\"]\n" + let parsed = "[llm.providers.moonshot.models.\"foo\"]\n" .parse::() .unwrap(); assert!( @@ -308,7 +308,7 @@ mod tests { .llm .unwrap() .providers - .get("kimi") + .get("moonshot") .unwrap() .models .contains_key("foo") diff --git a/lib/foundation/fabro-model/src/billing.rs b/lib/foundation/fabro-model/src/billing.rs index 6e4de19f0..f1c5867fa 100644 --- a/lib/foundation/fabro-model/src/billing.rs +++ b/lib/foundation/fabro-model/src/billing.rs @@ -1414,9 +1414,10 @@ output_cost_per_mtok = 2.0 #[test] fn old_provider_tagged_pricing_policy_is_rejected() { - let error = - serde_json::from_value::(openai_pricing_json("provider", "kimi")) - .unwrap_err(); + let error = serde_json::from_value::(openai_pricing_json( + "provider", "moonshot", + )) + .unwrap_err(); assert!(error.to_string().contains("algorithm")); } diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index 5cf218a34..f0d8719f4 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3695,8 +3695,8 @@ enabled = true } #[test] - fn builtin_kimi_k3_selection_prefers_modal_then_direct_kimi_over_openrouter() { - let kimi = ProviderId::new("kimi"); + fn builtin_kimi_k3_selection_prefers_modal_then_moonshot_over_openrouter() { + let moonshot = ProviderId::new("moonshot"); let modal = ProviderId::new("modal"); let openrouter = ProviderId::new("openrouter"); let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( @@ -3714,7 +3714,7 @@ enabled = true .select( "kimi-k3", None, - &HashSet::from([kimi.clone(), modal.clone(), openrouter.clone()]), + &HashSet::from([moonshot.clone(), modal.clone(), openrouter.clone()]), ) .expect("Modal should win portable Kimi K3 selection"); assert_eq!(selected.provider, modal); @@ -3723,10 +3723,10 @@ enabled = true .select( "kimi-k3", None, - &HashSet::from([kimi.clone(), openrouter.clone()]), + &HashSet::from([moonshot.clone(), openrouter.clone()]), ) - .expect("direct Kimi should win when Modal is unavailable"); - assert_eq!(selected.provider, kimi); + .expect("Moonshot should win when Modal is unavailable"); + assert_eq!(selected.provider, moonshot); let selected = catalog .select("kimi-k3", None, &HashSet::from([modal.clone(), openrouter])) @@ -4214,7 +4214,7 @@ enabled = true ); assert_eq!( catalog - .provider(&ProviderId::new("kimi")) + .provider(&ProviderId::new("moonshot")) .unwrap() .base_url .as_deref(), @@ -4273,7 +4273,7 @@ enabled = true ("anthropic", CodecKind::AnthropicMessages), ("openai", CodecKind::OpenAiResponses), ("gemini", CodecKind::GeminiGenerate), - ("kimi", CodecKind::OpenAiCompatible), + ("moonshot", CodecKind::OpenAiCompatible), ] { let provider_id = ProviderId::new(provider); assert_eq!(catalog.provider(&provider_id).unwrap().codec, expected); @@ -6729,7 +6729,7 @@ sampling_params = false insta::assert_debug_snapshot!(m, @r#" Model { id: "kimi-k2.5", - provider: kimi, + provider: moonshot, family: "kimi-k2", display_name: "Kimi K2.5", limits: ModelLimits { @@ -6785,7 +6785,7 @@ sampling_params = false insta::assert_debug_snapshot!(m, @r#" Model { id: "kimi-k3", - provider: kimi, + provider: moonshot, family: "kimi-k3", display_name: "Kimi K3", limits: ModelLimits { @@ -7136,11 +7136,11 @@ context_window = 1050000 } #[test] - fn closest_model_haiku_to_kimi() { + fn closest_model_haiku_to_moonshot() { let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); assert!( Catalog::builtin() - .closest(&ProviderId::new("kimi"), haiku) + .closest(&ProviderId::new("moonshot"), haiku) .is_none() ); } diff --git a/lib/foundation/fabro-model/src/catalog/providers/kimi.toml b/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml similarity index 70% rename from lib/foundation/fabro-model/src/catalog/providers/kimi.toml rename to lib/foundation/fabro-model/src/catalog/providers/moonshot.toml index 7826caab9..ddc1633e4 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/kimi.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml @@ -1,26 +1,26 @@ -[providers.kimi] -display_name = "Kimi" +[providers.moonshot] +display_name = "Moonshot AI" adapter = "openai_compatible" agent_profile = "kimi" api_key_url = "https://platform.kimi.ai/console/api-keys" base_url = "https://api.moonshot.ai/v1" priority = 70 -[providers.kimi.auth] +[providers.moonshot.auth] credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] -[providers.kimi.models."kimi-k2.5"] +[providers.moonshot.models."kimi-k2.5"] display_name = "Kimi K2.5" family = "kimi-k2" training = "2025-10-01" knowledge_cutoff = "October 2025" estimated_output_tps = 50 -[providers.kimi.models."kimi-k2.5".limits] +[providers.moonshot.models."kimi-k2.5".limits] context_window = 262144 max_output = 32768 -[providers.kimi.models."kimi-k2.5".features] +[providers.moonshot.models."kimi-k2.5".features] tools = true vision = true reasoning = true @@ -28,24 +28,24 @@ reasoning_by_default = true prompt_cache = true sampling_params = false -[providers.kimi.models."kimi-k2.5".costs] +[providers.moonshot.models."kimi-k2.5".costs] input_cost_per_mtok = 0.6 output_cost_per_mtok = 3.0 cache_input_cost_per_mtok = 0.1 -[providers.kimi.models."kimi-k3"] +[providers.moonshot.models."kimi-k3"] display_name = "Kimi K3" family = "kimi-k3" default = true aliases = ["kimi"] -[providers.kimi.models."kimi-k3".limits] +[providers.moonshot.models."kimi-k3".limits] context_window = 1048576 # K3 accepts explicit completion budgets up to 1048576, but Fabro also uses # max_output as the default request budget. Match Kimi's 131072-token default. max_output = 131072 -[providers.kimi.models."kimi-k3".features] +[providers.moonshot.models."kimi-k3".features] tools = true vision = true reasoning = true @@ -53,10 +53,10 @@ reasoning_effort = "always_adaptive" prompt_cache = true sampling_params = false -[providers.kimi.models."kimi-k3".controls] +[providers.moonshot.models."kimi-k3".controls] reasoning_effort = ["low", "high", "max"] -[providers.kimi.models."kimi-k3".costs] +[providers.moonshot.models."kimi-k3".costs] input_cost_per_mtok = 3.0 output_cost_per_mtok = 15.0 cache_input_cost_per_mtok = 0.3 diff --git a/lib/foundation/fabro-model/src/ids.rs b/lib/foundation/fabro-model/src/ids.rs index 02d6db0d7..b46d89cfe 100644 --- a/lib/foundation/fabro-model/src/ids.rs +++ b/lib/foundation/fabro-model/src/ids.rs @@ -174,9 +174,9 @@ mod tests { #[test] fn provider_id_is_transparent_string_in_json() { - let id = ProviderId::new("kimi"); + let id = ProviderId::new("moonshot"); let json = serde_json::to_string(&id).unwrap(); - assert_eq!(json, "\"kimi\""); + assert_eq!(json, "\"moonshot\""); let back: ProviderId = serde_json::from_str(&json).unwrap(); assert_eq!(back, id); } From fd55ff03adca311e7d24524b215f836f82a42f4b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 09:14:58 -0400 Subject: [PATCH 13/57] fix(model): prefer MOONSHOT_API_KEY Keep KIMI_API_KEY as a legacy fallback for env and vault credentials. --- .env.example | 2 +- .../administration/server-configuration.mdx | 2 +- docs/public/reference/sdk.mdx | 2 +- lib/apps/fabro-cli/tests/it/cmd/model_test.rs | 1 + lib/foundation/fabro-auth/src/resolve.rs | 54 +++++++++++++++++++ lib/foundation/fabro-config/src/layers/llm.rs | 18 ++++++- lib/foundation/fabro-model/src/catalog.rs | 20 +++++++ .../src/catalog/providers/moonshot.toml | 7 ++- lib/foundation/fabro-static/src/env_vars.rs | 2 + .../fabro-static/src/secret_registry.rs | 2 + 10 files changed, 104 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index ea69ab76b..1093d69ee 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ DAYTONA_API_KEY= FIREWORKS_API_KEY= GEMINI_API_KEY= INCEPTION_API_KEY= -KIMI_API_KEY= +MOONSHOT_API_KEY= MINIMAX_API_KEY= MODAL_KIMI_K3_BASE_URL= MODAL_TOKEN_ID= diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index 23013c749..2b526ada9 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -383,7 +383,7 @@ fabro secret set GEMINI_API_KEY AI... | `ANTHROPIC_API_KEY` | Anthropic (Claude) | | `OPENAI_API_KEY` | OpenAI (GPT) | | `GEMINI_API_KEY` | Google (Gemini) | -| `KIMI_API_KEY` | Moonshot AI | +| `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence | | `ZAI_API_KEY` | Zai (GLM) | | `MINIMAX_API_KEY` | Minimax | | `INCEPTION_API_KEY` | Inception (Mercury) | diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 929840cd5..402953e5c 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -366,7 +366,7 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia | `ANTHROPIC_API_KEY` | Anthropic | | `OPENAI_API_KEY` | OpenAI | | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Gemini | -| `KIMI_API_KEY` | Moonshot AI | +| `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence | | `ZAI_API_KEY` | ZAI | | `MINIMAX_API_KEY` | Minimax | | `INCEPTION_API_KEY` | Inception | diff --git a/lib/apps/fabro-cli/tests/it/cmd/model_test.rs b/lib/apps/fabro-cli/tests/it/cmd/model_test.rs index acaae9851..53fd86fb2 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/model_test.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/model_test.rs @@ -18,6 +18,7 @@ fn remove_provider_env(cmd: &mut Command) -> &mut Command { .env_remove("OPENAI_API_KEY") .env_remove("GEMINI_API_KEY") .env_remove("GOOGLE_API_KEY") + .env_remove("MOONSHOT_API_KEY") .env_remove("KIMI_API_KEY") .env_remove("ZAI_API_KEY") .env_remove("MINIMAX_API_KEY") diff --git a/lib/foundation/fabro-auth/src/resolve.rs b/lib/foundation/fabro-auth/src/resolve.rs index 4f6ada34b..dd12d1a1e 100644 --- a/lib/foundation/fabro-auth/src/resolve.rs +++ b/lib/foundation/fabro-auth/src/resolve.rs @@ -630,6 +630,60 @@ base_url = "https://example--kimi-k3.modal.run/v1" ); } + #[tokio::test] + async fn resolve_moonshot_api_request_prefers_moonshot_env_key() { + let dir = tempfile::tempdir().unwrap(); + let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); + let resolver = test_resolver( + vault, + Arc::new(|name| match name { + EnvVars::MOONSHOT_API_KEY => Some("moonshot-key".to_string()), + EnvVars::KIMI_API_KEY => Some("kimi-key".to_string()), + _ => None, + }), + ); + + let resolved = resolver + .resolve( + ProviderId::new("moonshot"), + CredentialUsage::ApiRequest, + &default_catalog(), + ) + .await + .unwrap(); + + let ResolvedCredential::Api(api) = resolved; + assert_eq!( + api.auth_header, + Some(ApiKeyHeader::Bearer("moonshot-key".to_string())) + ); + } + + #[tokio::test] + async fn resolve_moonshot_api_request_falls_back_to_kimi_env_key() { + let dir = tempfile::tempdir().unwrap(); + let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); + let resolver = test_resolver( + vault, + Arc::new(|name| (name == EnvVars::KIMI_API_KEY).then(|| "kimi-key".to_string())), + ); + + let resolved = resolver + .resolve( + ProviderId::new("moonshot"), + CredentialUsage::ApiRequest, + &default_catalog(), + ) + .await + .unwrap(); + + let ResolvedCredential::Api(api) = resolved; + assert_eq!( + api.auth_header, + Some(ApiKeyHeader::Bearer("kimi-key".to_string())) + ); + } + #[tokio::test] async fn resolve_openai_api_request_falls_back_to_codex_oauth_credential() { let dir = tempfile::tempdir().unwrap(); diff --git a/lib/foundation/fabro-config/src/layers/llm.rs b/lib/foundation/fabro-config/src/layers/llm.rs index d232c40b7..298968a62 100644 --- a/lib/foundation/fabro-config/src/layers/llm.rs +++ b/lib/foundation/fabro-config/src/layers/llm.rs @@ -7,11 +7,18 @@ //! display_name = "Moonshot AI" //! adapter = "openai_compatible" //! base_url = "https://api.moonshot.ai/v1" -//! auth = { credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] } //! priority = 60 //! enabled = true //! aliases = ["moonshot-ai"] //! +//! [llm.providers.moonshot.auth] +//! credentials = [ +//! "env:MOONSHOT_API_KEY", +//! "env:KIMI_API_KEY", +//! "vault:MOONSHOT_API_KEY", +//! "vault:KIMI_API_KEY", +//! ] +//! //! [llm.providers.moonshot.models."kimi-k2.5"] //! ... //! ``` @@ -499,7 +506,12 @@ enabled = true aliases = ["moonshot-ai"] [providers.moonshot.auth] -credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] +credentials = [ + "env:MOONSHOT_API_KEY", + "env:KIMI_API_KEY", + "vault:MOONSHOT_API_KEY", + "vault:KIMI_API_KEY", +] "#; let layer: LlmLayer = toml::from_str(toml).unwrap(); let moonshot = layer.providers.get("moonshot").unwrap(); @@ -509,7 +521,9 @@ credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] let auth = moonshot.auth.as_ref().expect("expected api_key auth"); assert_eq!(auth.header, ApiKeyHeaderPolicy::Bearer); assert_eq!(auth.credentials, vec![ + CredentialRef::Env("MOONSHOT_API_KEY".to_string()), CredentialRef::Env("KIMI_API_KEY".to_string()), + CredentialRef::Vault("MOONSHOT_API_KEY".to_string()), CredentialRef::Vault("KIMI_API_KEY".to_string()), ]); assert_eq!( diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index f0d8719f4..5c4bfaa10 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3694,6 +3694,26 @@ enabled = true ]); } + #[test] + fn builtin_moonshot_provider_prefers_moonshot_api_key() { + let moonshot = ProviderId::new("moonshot"); + let catalog = Catalog::builtin(); + let provider = catalog + .provider(&moonshot) + .expect("Moonshot provider should be present"); + + assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![ + CredentialRef::Env("MOONSHOT_API_KEY".to_string()), + CredentialRef::Env("KIMI_API_KEY".to_string()), + CredentialRef::Vault("MOONSHOT_API_KEY".to_string()), + CredentialRef::Vault("KIMI_API_KEY".to_string()), + ]); + assert_eq!( + catalog.provider_vault_secret_name(&moonshot), + Some("MOONSHOT_API_KEY") + ); + } + #[test] fn builtin_kimi_k3_selection_prefers_modal_then_moonshot_over_openrouter() { let moonshot = ProviderId::new("moonshot"); diff --git a/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml b/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml index ddc1633e4..aaa4d7ec4 100644 --- a/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml +++ b/lib/foundation/fabro-model/src/catalog/providers/moonshot.toml @@ -7,7 +7,12 @@ base_url = "https://api.moonshot.ai/v1" priority = 70 [providers.moonshot.auth] -credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] +credentials = [ + "env:MOONSHOT_API_KEY", + "env:KIMI_API_KEY", + "vault:MOONSHOT_API_KEY", + "vault:KIMI_API_KEY", +] [providers.moonshot.models."kimi-k2.5"] display_name = "Kimi K2.5" diff --git a/lib/foundation/fabro-static/src/env_vars.rs b/lib/foundation/fabro-static/src/env_vars.rs index 849ec6842..e6f00e3b9 100644 --- a/lib/foundation/fabro-static/src/env_vars.rs +++ b/lib/foundation/fabro-static/src/env_vars.rs @@ -56,6 +56,7 @@ impl EnvVars { pub const INCEPTION_API_KEY: &'static str = "INCEPTION_API_KEY"; pub const KIMI_API_KEY: &'static str = "KIMI_API_KEY"; pub const MINIMAX_API_KEY: &'static str = "MINIMAX_API_KEY"; + pub const MOONSHOT_API_KEY: &'static str = "MOONSHOT_API_KEY"; pub const MODAL_TOKEN_ID: &'static str = "MODAL_TOKEN_ID"; pub const MODAL_TOKEN_SECRET: &'static str = "MODAL_TOKEN_SECRET"; pub const OPENAI_API_KEY: &'static str = "OPENAI_API_KEY"; @@ -206,6 +207,7 @@ mod tests { EnvVars::INCEPTION_API_KEY, EnvVars::KIMI_API_KEY, EnvVars::MINIMAX_API_KEY, + EnvVars::MOONSHOT_API_KEY, EnvVars::OPENAI_API_KEY, EnvVars::OPENAI_BASE_URL, EnvVars::OPENAI_ORGANIZATION, diff --git a/lib/foundation/fabro-static/src/secret_registry.rs b/lib/foundation/fabro-static/src/secret_registry.rs index 03dec8e12..8c823b112 100644 --- a/lib/foundation/fabro-static/src/secret_registry.rs +++ b/lib/foundation/fabro-static/src/secret_registry.rs @@ -30,6 +30,7 @@ const OPTIONAL_VAULT_SECRETS: &[&str] = &[ EnvVars::INCEPTION_API_KEY, EnvVars::KIMI_API_KEY, EnvVars::MINIMAX_API_KEY, + EnvVars::MOONSHOT_API_KEY, EnvVars::OPENAI_API_KEY, EnvVars::OPENROUTER_API_KEY, EnvVars::POOLSIDE_API_KEY, @@ -97,6 +98,7 @@ mod tests { EnvVars::INCEPTION_API_KEY, EnvVars::KIMI_API_KEY, EnvVars::MINIMAX_API_KEY, + EnvVars::MOONSHOT_API_KEY, EnvVars::OPENAI_API_KEY, EnvVars::OPENROUTER_API_KEY, EnvVars::POOLSIDE_API_KEY, From 900396e7fca13e4b2888f304606027d7b6cd52fe Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 12:22:08 -0400 Subject: [PATCH 14/57] 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 15/57] 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 16/57] 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 e8c4020432c24361098e363de2d51f244b57bb6d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 12:46:13 -0400 Subject: [PATCH 17/57] Collapse the steering bar by default and expand it on bar click The steering dock on run pages now starts collapsed, staying out of the way until the operator opens it. A run that is interrupted and waiting for steering still forces the dock open. While collapsed, the whole dock header is a click target that expands it. Clicks on buttons in the bar (Interrupt, the chevron) keep their own behavior, and the chevron remains the keyboard/assistive-tech toggle. The interview dock shares the shell, so it gets the same click-to-open behavior. Co-Authored-By: Claude Fable 5 --- .../app/components/run-dock.test.tsx | 56 ++++++++++++++++++- apps/fabro-web/app/components/run-dock.tsx | 18 +++++- .../app/components/steer-bar.test.tsx | 6 +- apps/fabro-web/app/components/steer-bar.tsx | 4 +- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/apps/fabro-web/app/components/run-dock.test.tsx b/apps/fabro-web/app/components/run-dock.test.tsx index 56c5a1599..a6d238e49 100644 --- a/apps/fabro-web/app/components/run-dock.test.tsx +++ b/apps/fabro-web/app/components/run-dock.test.tsx @@ -9,7 +9,7 @@ import { import TestRenderer, { act } from "react-test-renderer"; import { setupReactTestEnv } from "../lib/test-utils"; -import { DockComposer } from "./run-dock"; +import { DockComposer, RunDockShell } from "./run-dock"; const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; let teardownReactEnv: (() => void) | undefined; @@ -26,6 +26,60 @@ afterEach(() => { teardownReactEnv = undefined; }); +describe("RunDockShell", () => { + function mountShell( + collapsed: boolean, + onCollapsedChange: (collapsed: boolean) => void, + ) { + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create( + } + />, + ); + }); + mountedRenderers.push(renderer); + return renderer; + } + + function clickableDivs(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root.findAll( + (node) => node.type === "div" && node.props.onClick !== undefined, + ); + } + + test("the whole collapsed bar is a click target that expands it", () => { + const onCollapsedChange = mock((_collapsed: boolean) => undefined); + const renderer = mountShell(true, onCollapsedChange); + + const [header] = clickableDivs(renderer); + expect(header).toBeDefined(); + act(() => header.props.onClick({ target: { closest: () => null } })); + expect(onCollapsedChange).toHaveBeenCalledWith(false); + }); + + test("clicks on header buttons do not also expand the bar", () => { + const onCollapsedChange = mock((_collapsed: boolean) => undefined); + const renderer = mountShell(true, onCollapsedChange); + + const [header] = clickableDivs(renderer); + act(() => header.props.onClick({ target: { closest: () => ({}) } })); + expect(onCollapsedChange).not.toHaveBeenCalled(); + }); + + test("the expanded header is not a click target", () => { + const onCollapsedChange = mock((_collapsed: boolean) => undefined); + const renderer = mountShell(false, onCollapsedChange); + expect(clickableDivs(renderer)).toHaveLength(0); + }); +}); + describe("DockComposer", () => { test("describes its keyboard behavior to assistive technology", () => { let renderer!: TestRenderer.ReactTestRenderer; diff --git a/apps/fabro-web/app/components/run-dock.tsx b/apps/fabro-web/app/components/run-dock.tsx index 4e26daa3a..ce3ce0774 100644 --- a/apps/fabro-web/app/components/run-dock.tsx +++ b/apps/fabro-web/app/components/run-dock.tsx @@ -99,7 +99,23 @@ export function RunDockShell({ className, )} > -
+ {/* While collapsed, the whole bar expands on click. Clicks that land on + a button (Interrupt, the chevron) keep their own behavior. The + chevron button stays the keyboard/assistive-tech toggle. */} +
{ + if ((event.target as HTMLElement).closest("button")) return; + onCollapsedChange(false); + } + : undefined + } + > {/* A live region: the dock changing to a state that needs the operator has to reach assistive tech, not only the eye. */} diff --git a/apps/fabro-web/app/components/steer-bar.test.tsx b/apps/fabro-web/app/components/steer-bar.test.tsx index b13498af6..583a68750 100644 --- a/apps/fabro-web/app/components/steer-bar.test.tsx +++ b/apps/fabro-web/app/components/steer-bar.test.tsx @@ -98,11 +98,7 @@ describe("SteerBar", () => { }); mountedRenderers.push(renderer); - act(() => { - renderer.root - .findByProps({ "aria-label": "Collapse Steer running agent" }) - .props.onClick(); - }); + // The dock starts collapsed by default. expect( renderer.root.findByProps({ "aria-label": "Expand Steer running agent", diff --git a/apps/fabro-web/app/components/steer-bar.tsx b/apps/fabro-web/app/components/steer-bar.tsx index 1b6e61f7c..4ec85bbcb 100644 --- a/apps/fabro-web/app/components/steer-bar.tsx +++ b/apps/fabro-web/app/components/steer-bar.tsx @@ -57,7 +57,9 @@ export function SteerBar({ ref, }: SteerBarProps) { const [errorMessage, setErrorMessage] = useState(null); - const [collapsePreferred, setCollapsePreferred] = useState(false); + // Steering is an occasional control, so the dock starts collapsed and + // stays out of the way until the operator opens it. + const [collapsePreferred, setCollapsePreferred] = useState(true); const textareaRef = useRef(null); const steer = useSteerRun(runId); const interrupt = useInterruptRun(runId); From 8bae35398fd216ad26a9764a9e27b87d60ab8467 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Fri, 31 Jul 2026 12:59:00 -0400 Subject: [PATCH 18/57] 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 19/57] 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 20/57] 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 21/57] 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 22/57] 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 23/57] 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 24/57] 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 25/57] 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 26/57] 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 27/57] 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 980aabc543d04357f861caa9b173d3444088a534 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 31 Jul 2026 13:23:35 -0400 Subject: [PATCH 28/57] feat: download latest artifacts as zip --- Cargo.lock | 15 ++ Cargo.toml | 1 + apps/fabro-web/app/lib/api-client.test.ts | 9 + apps/fabro-web/app/lib/api-client.ts | 6 + apps/fabro-web/app/routes/run-artifacts.tsx | 28 ++- docs/public/api-reference/fabro-api.yaml | 43 ++++ lib/apps/fabro-server/Cargo.toml | 1 + lib/apps/fabro-server/src/server.rs | 15 +- .../src/server/handler/artifacts.rs | 213 ++++++++++++++++++ .../fabro-server/src/server/handler/mod.rs | 1 + lib/apps/fabro-server/src/server/tests.rs | 169 ++++++++++++++ .../fabro-store/src/artifact_store.rs | 39 ++++ .../src/api/run-internals-api.ts | 74 ++++++ 13 files changed, 601 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a485c5cc4..784dc1357 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,6 +402,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async_zip" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" +dependencies = [ + "crc32fast", + "futures-lite", + "pin-project", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + [[package]] name = "atoi" version = "2.0.0" @@ -2992,6 +3006,7 @@ version = "0.311.0-nightly.0" dependencies = [ "anyhow", "async-trait", + "async_zip", "axum", "axum-extra", "base64", diff --git a/Cargo.toml b/Cargo.toml index 78ab08a99..702b4e77e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ dotenvy = "0.15" futures = "0.3" tokio-stream = "0.1" async-trait = "0.1" +async_zip = { version = "0.0.18", features = ["tokio"] } fs2 = "0.4" base64 = "0.22" bytes = "1" diff --git a/apps/fabro-web/app/lib/api-client.test.ts b/apps/fabro-web/app/lib/api-client.test.ts index 8d38323a1..5f0f44b19 100644 --- a/apps/fabro-web/app/lib/api-client.test.ts +++ b/apps/fabro-web/app/lib/api-client.test.ts @@ -6,6 +6,7 @@ import { extractRequestId, fetchAllPages, generatedAxios, + runArtifactsDownloadUrl, stageArtifactDownloadUrl, } from "./api-client"; @@ -146,6 +147,14 @@ describe("stageArtifactDownloadUrl", () => { }); }); +describe("runArtifactsDownloadUrl", () => { + test("builds the escaped ZIP download href", () => { + expect(runArtifactsDownloadUrl("run 1")).toBe( + "/api/v1/runs/run%201/artifacts/download", + ); + }); +}); + describe("extractRequestId", () => { test("supports top-level, error-level, and detail-embedded request ids", () => { expect(extractRequestId({ request_id: "top" })).toBe("top"); diff --git a/apps/fabro-web/app/lib/api-client.ts b/apps/fabro-web/app/lib/api-client.ts index e77eb6d8a..4c298fd0c 100644 --- a/apps/fabro-web/app/lib/api-client.ts +++ b/apps/fabro-web/app/lib/api-client.ts @@ -422,3 +422,9 @@ export function stageArtifactDownloadUrl( encodeURIComponent(id) }/stages/${encodeURIComponent(stageId)}/artifacts/download?${searchParams}`; } + +export function runArtifactsDownloadUrl(id: string): string { + return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${ + encodeURIComponent(id) + }/artifacts/download`; +} diff --git a/apps/fabro-web/app/routes/run-artifacts.tsx b/apps/fabro-web/app/routes/run-artifacts.tsx index a314973c9..c03d68875 100644 --- a/apps/fabro-web/app/routes/run-artifacts.tsx +++ b/apps/fabro-web/app/routes/run-artifacts.tsx @@ -6,7 +6,10 @@ import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; import { EmptyState, ErrorState, LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; -import { stageArtifactDownloadUrl } from "../lib/api-client"; +import { + runArtifactsDownloadUrl, + stageArtifactDownloadUrl, +} from "../lib/api-client"; import { formatBytes } from "../lib/format"; import { plural } from "../lib/plural"; import { useRunArtifacts, useRunStages } from "../lib/queries"; @@ -117,7 +120,7 @@ function ArtifactList({ runId, files }: { runId: string; files: readonly Artifac return (
-
+

{files.length} {plural(files.length, "file", "files")} {versioned && ( @@ -127,11 +130,22 @@ function ArtifactList({ runId, files }: { runId: string; files: readonly Artifac )}

- - {versioned - ? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored` - : `${formatBytes(latestBytes)} total`} - +
+ + {versioned + ? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored` + : `${formatBytes(latestBytes)} total`} + + + +
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 80681f93a..c4540adcc 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -3282,6 +3282,49 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/artifacts/download: + get: + operationId: downloadRunArtifacts + tags: [Run Internals] + summary: Download Run Artifacts + description: | + Streams a ZIP archive with the latest captured version of each artifact path. + Stage order, retry number, and stage ID determine the latest version, matching the artifacts page. + Captures from the `start` and `exit` control nodes are excluded. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: ZIP archive containing the latest artifact files + headers: + Content-Disposition: + description: Attachment filename for the ZIP archive + schema: + type: string + content: + application/zip: + schema: + type: string + format: binary + "404": + description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Artifact archive could not be prepared + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/files: get: operationId: listRunFiles diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index e24dd114b..2be93d38f 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -75,6 +75,7 @@ serde_json.workspace = true serde_yaml = "0.9" anyhow.workspace = true async-trait.workspace = true +async_zip.workspace = true clap.workspace = true toml.workspace = true toml_edit.workspace = true diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 69220d2b9..bc5aeeeb3 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -137,6 +137,7 @@ use tokio_stream::StreamExt; use tokio_stream::wrappers::{BroadcastStream, UnboundedReceiverStream}; use tokio_util::sync::CancellationToken; use tower::{ServiceExt, service_fn}; +use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate}; use tower_http::compression::{CompressionLayer, CompressionLevel}; use tracing::{Instrument, debug, error, info, warn}; use ulid::Ulid; @@ -1922,12 +1923,14 @@ pub fn build_router_with_options( /// Response-compression layer shared by the main and install-mode routers. /// /// The default predicate skips streaming SSE (`text/event-stream`), gRPC, -/// images, and tiny bodies. The quality is pinned because tower-http's -/// default defers to each codec's own default, and brotli's is quality 11 — -/// seconds of CPU on a multi-megabyte asset. Level 4 keeps both codecs fast -/// at a near-optimal ratio. -pub(crate) fn compression_layer() -> CompressionLayer { - CompressionLayer::new().quality(CompressionLevel::Precise(4)) +/// images, ZIP archives, and tiny bodies. The quality is pinned because +/// tower-http's default defers to each codec's own default, and brotli's is +/// quality 11 — seconds of CPU on a multi-megabyte asset. Level 4 keeps both +/// codecs fast at a near-optimal ratio. +pub(crate) fn compression_layer() -> CompressionLayer { + CompressionLayer::new() + .quality(CompressionLevel::Precise(4)) + .compress_when(DefaultPredicate::new().and(NotForContentType::const_new("application/zip"))) } async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Response { diff --git a/lib/apps/fabro-server/src/server/handler/artifacts.rs b/lib/apps/fabro-server/src/server/handler/artifacts.rs index 9d7b049fb..cd1d0eb1b 100644 --- a/lib/apps/fabro-server/src/server/handler/artifacts.rs +++ b/lib/apps/fabro-server/src/server/handler/artifacts.rs @@ -1,5 +1,20 @@ +use std::io; use std::sync::Arc; +use async_zip::base::write::ZipFileWriter; +use async_zip::error::ZipError; +use async_zip::{Compression, ZipEntryBuilder}; +use axum::http::HeaderValue; +use fabro_store::{ArtifactStore, Error as StoreError, RunProjection}; +use futures_util::SinkExt as _; +use futures_util::io::AsyncWriteExt as _; +use tokio::io::AsyncWrite; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::io::{CopyToBytes, SinkWriter}; +use tokio_util::sync::PollSender; +use tracing::warn; + use super::super::{ ApiError, AppState, ArtifactEntry, ArtifactKey, ArtifactListResponse, AsyncWriteExt, Body, Bytes, DefaultBodyLimit, Digest, HashMap, HashSet, HeaderMap, IntoResponse, Json, NodeArtifact, @@ -17,6 +32,7 @@ pub(super) fn routes() -> Router> { .route("/runs/{id}/blobs", post(write_run_blob)) .route("/runs/{id}/blobs/{blobId}", get(read_run_blob)) .route("/runs/{id}/artifacts", get(list_run_artifacts)) + .route("/runs/{id}/artifacts/download", get(download_run_artifacts)) .route( "/runs/{id}/stages/{stageId}/artifacts", get(list_stage_artifacts) @@ -147,6 +163,180 @@ async fn list_run_artifacts( } } +const ARCHIVE_STREAM_CHANNEL_CAPACITY: usize = 8; + +#[derive(Debug, thiserror::Error)] +enum ArtifactArchiveError { + #[error("archive output failed: {0}")] + Io(#[from] io::Error), + #[error("artifact read failed: {0}")] + Store(#[from] StoreError), + #[error("artifact disappeared while the archive was being created")] + MissingArtifact, + #[error("ZIP write failed: {0}")] + Zip(#[from] ZipError), +} + +fn validate_artifact_archive_path(path: &str) -> Result<(), StoreError> { + ArtifactStore::validate_relative_path(path)?; + let bytes = path.as_bytes(); + let has_windows_drive_prefix = + bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'); + if path.contains('\0') || has_windows_drive_prefix { + return Err(StoreError::Other( + "artifact path is not safe for a ZIP archive".to_string(), + )); + } + Ok(()) +} + +fn latest_run_artifacts( + entries: Vec, + projection: &RunProjection, +) -> Result, StoreError> { + let stage_order = projection + .iter_stages() + .enumerate() + .map(|(order, (stage_id, _))| (stage_id.clone(), order)) + .collect::>(); + let mut latest_by_path: HashMap, NodeArtifact)> = HashMap::new(); + + for artifact in entries { + if matches!(artifact.node.node_id(), "start" | "exit") { + continue; + } + validate_artifact_archive_path(&artifact.filename)?; + + let order = stage_order.get(&artifact.node).copied(); + let replace = + latest_by_path + .get(&artifact.filename) + .is_none_or(|(existing_order, existing)| { + (order, artifact.retry, &artifact.node) + > (*existing_order, existing.retry, &existing.node) + }); + if replace { + latest_by_path.insert(artifact.filename.clone(), (order, artifact)); + } + } + + let mut latest = latest_by_path + .into_values() + .map(|(_, artifact)| artifact) + .collect::>(); + latest.sort_by(|left, right| left.filename.cmp(&right.filename)); + Ok(latest) +} + +async fn write_artifact_archive( + writer: W, + artifact_store: ArtifactStore, + run_id: RunId, + artifacts: Vec, +) -> Result<(), ArtifactArchiveError> +where + W: AsyncWrite + Unpin, +{ + let mut archive = ZipFileWriter::with_tokio(writer); + for artifact in artifacts { + let key = ArtifactKey::new(artifact.node, artifact.retry, artifact.filename.clone()); + let Some(mut source) = artifact_store.get_stream(&run_id, &key).await? else { + return Err(ArtifactArchiveError::MissingArtifact); + }; + let entry = ZipEntryBuilder::new(artifact.filename.into(), Compression::Stored); + let mut destination = archive.write_entry_stream(entry).await?; + while let Some(chunk) = source.next().await { + destination.write_all(&chunk?).await?; + } + destination.close().await?; + } + let mut writer = archive.close().await?.into_inner(); + writer.shutdown().await?; + Ok(()) +} + +fn artifact_archive_body( + artifact_store: ArtifactStore, + run_id: RunId, + artifacts: Vec, +) -> Body { + let (sender, receiver) = + mpsc::channel::>(ARCHIVE_STREAM_CHANNEL_CAPACITY); + let error_sender = sender.clone(); + let sink = PollSender::new(sender) + .sink_map_err(|_| io::Error::from(io::ErrorKind::BrokenPipe)) + .with(|chunk: Bytes| { + std::future::ready(Ok::, io::Error>(Ok(chunk))) + }); + let writer = SinkWriter::new(CopyToBytes::new(sink)); + + tokio::spawn(async move { + if let Err(error) = write_artifact_archive(writer, artifact_store, run_id, artifacts).await + { + let body_error = io::Error::other("artifact archive stream failed"); + if error_sender.send(Err(body_error)).await.is_ok() { + warn!(%run_id, %error, "artifact archive stream failed"); + } + } + }); + + Body::from_stream(ReceiverStream::new(receiver)) +} + +async fn download_run_artifacts( + _auth: RequiredUser, + State(state): State>, + Path(id): Path, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + let cached = match state.cached_run(&id).await { + Ok(cached) => cached, + Err(error) => return error.into_response(), + }; + let entries = match state.artifact_store.list_for_run(&id).await { + Ok(entries) => entries, + Err(error) => { + warn!(run_id = %id, %error, "failed to list artifacts for ZIP download"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Artifact archive could not be prepared.", + ) + .into_response(); + } + }; + let artifacts = match latest_run_artifacts(entries, &cached.projection) { + Ok(artifacts) => artifacts, + Err(error) => { + warn!(run_id = %id, %error, "failed to select artifacts for ZIP download"); + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Artifact archive could not be prepared.", + ) + .into_response(); + } + }; + + let filename = format!("attachment; filename=\"fabro-artifacts-{id}.zip\""); + let body = artifact_archive_body(state.artifact_store.clone(), id, artifacts); + let mut response = body.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/zip"), + ); + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&filename).expect("run IDs produce valid attachment filenames"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + response +} + fn run_artifact_entry_from(entry: NodeArtifact) -> RunArtifactEntry { RunArtifactEntry { stage_id: entry.node.to_string(), @@ -647,3 +837,26 @@ async fn get_stage_artifact( } } } + +#[cfg(test)] +mod tests { + use super::validate_artifact_archive_path; + + #[test] + fn archive_paths_reject_cross_platform_absolute_forms() { + for path in [ + "/escape.txt", + "../escape.txt", + r"..\escape.txt", + "C:/escape.txt", + "c:escape.txt", + "bad\0name", + ] { + assert!( + validate_artifact_archive_path(path).is_err(), + "accepted unsafe archive path: {path:?}" + ); + } + assert!(validate_artifact_archive_path("nested/result.txt").is_ok()); + } +} diff --git a/lib/apps/fabro-server/src/server/handler/mod.rs b/lib/apps/fabro-server/src/server/handler/mod.rs index 75dd6507d..1f8edcdca 100644 --- a/lib/apps/fabro-server/src/server/handler/mod.rs +++ b/lib/apps/fabro-server/src/server/handler/mod.rs @@ -116,6 +116,7 @@ pub(super) fn demo_routes() -> Router> { .route("/runs/{id}/graph/source", get(demo::get_run_graph_source)) .route("/runs/{id}/stages", get(demo::get_run_stages)) .route("/runs/{id}/artifacts", get(demo::list_run_artifacts_stub)) + .route("/runs/{id}/artifacts/download", get(not_implemented)) .route("/runs/{id}/files", get(demo::list_run_files_stub)) .route("/runs/{id}/commits", get(demo::list_run_commits_stub)) .route( diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index b3da5be63..51b380c23 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -7,6 +7,7 @@ use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc as StdArc, Mutex as StdMutex}; +use async_zip::base::read::mem::ZipFileReader; use axum::body::Body; use axum::http::{Method, Request, header}; use chrono::{Duration as ChronoDuration, Utc}; @@ -10589,6 +10590,173 @@ async fn stage_artifacts_keep_same_filename_per_retry() { assert_eq!(&bytes[..], b"second"); } +#[tokio::test] +async fn run_artifacts_download_streams_latest_files_as_zip() { + let state = test_app_state(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + let run_id = create_run(&app, MINIMAL_DOT) + .await + .parse::() + .unwrap(); + + let run_store = state.stores.runs.open_run(&run_id).await.unwrap(); + for event in [ + workflow_event::Event::RunRunnable { + source: fabro_types::RunRunnableSource::StartRequested, + actor: None, + }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, + ] { + workflow_event::append_event(&run_store, &run_id, &event) + .await + .unwrap(); + } + append_scoped_stage_event( + &state, + run_id, + "build", + 1, + &stage_started_event("build", "command"), + ) + .await; + append_scoped_stage_event( + &state, + run_id, + "verify", + 1, + &stage_started_event("verify", "command"), + ) + .await; + + for (stage_id, retry, path, contents) in [ + ( + StageId::new("unknown", 1), + 99, + "reports/result.txt", + &b"unknown stage"[..], + ), + ( + StageId::new("build", 1), + 1, + "reports/result.txt", + &b"build result"[..], + ), + ( + StageId::new("verify", 1), + 1, + "reports/result.txt", + &b"first verify"[..], + ), + ( + StageId::new("verify", 1), + 2, + "reports/result.txt", + &b"latest verify"[..], + ), + ( + StageId::new("build", 1), + 1, + "logs/run.txt", + &b"build log"[..], + ), + ( + StageId::new("start", 1), + 1, + "control-start.txt", + &b"excluded"[..], + ), + ( + StageId::new("exit", 1), + 1, + "control-exit.txt", + &b"excluded"[..], + ), + ] { + state + .artifact_store + .put(&run_id, &ArtifactKey::new(stage_id, retry, path), contents) + .await + .unwrap(); + } + + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/artifacts/download"))) + .header(header::ACCEPT_ENCODING, "gzip") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/zip") + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some(format!("attachment; filename=\"fabro-artifacts-{run_id}.zip\"").as_str()) + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, no-store") + ); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + + let bytes = response_bytes!(response, StatusCode::OK).await; + let archive = ZipFileReader::new(bytes.clone()) + .await + .unwrap_or_else(|error| { + panic!( + "ZIP parse failed for {} bytes (tail {:?}): {error}", + bytes.len(), + &bytes[bytes.len().saturating_sub(48)..] + ) + }); + let names = archive + .file() + .entries() + .iter() + .map(|entry| entry.filename().as_str().unwrap().to_string()) + .collect::>(); + assert_eq!(names, vec!["logs/run.txt", "reports/result.txt"]); + + let mut contents_by_name = HashMap::new(); + for (index, name) in names.into_iter().enumerate() { + let mut entry = archive.reader_with_entry(index).await.unwrap(); + let mut contents = Vec::new(); + entry.read_to_end_checked(&mut contents).await.unwrap(); + contents_by_name.insert(name, contents); + } + assert_eq!(contents_by_name["logs/run.txt"], b"build log"); + assert_eq!(contents_by_name["reports/result.txt"], b"latest verify"); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(api(&format!("/runs/{}/artifacts/download", RunId::new()))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_status!(response, StatusCode::NOT_FOUND).await; +} + #[tokio::test] async fn create_run_persists_run_spec() { let state = test_app_state(); @@ -11316,6 +11484,7 @@ async fn worker_token_is_rejected_on_user_only_routes() { (Method::GET, format!("/runs/{run_id}/graph/source")), (Method::GET, format!("/runs/{run_id}/stages")), (Method::GET, format!("/runs/{run_id}/artifacts")), + (Method::GET, format!("/runs/{run_id}/artifacts/download")), (Method::GET, format!("/runs/{run_id}/files")), ( Method::GET, diff --git a/lib/components/fabro-store/src/artifact_store.rs b/lib/components/fabro-store/src/artifact_store.rs index 5be00d792..ef51387ba 100644 --- a/lib/components/fabro-store/src/artifact_store.rs +++ b/lib/components/fabro-store/src/artifact_store.rs @@ -4,6 +4,7 @@ use bytes::Bytes; use chrono::Utc; use fabro_types::RunId; use futures::StreamExt; +use futures::stream::BoxStream; use object_store::ObjectStore; use object_store::buffered::BufWriter; use object_store::path::Path as ObjectPath; @@ -122,6 +123,28 @@ impl ArtifactStore { } } + pub async fn get_stream( + &self, + run_id: &RunId, + key: &ArtifactKey, + ) -> Result>>> { + let path = self.artifact_path(run_id, key)?; + match self.object_store.get(&path).await { + Ok(result) => Ok(Some( + result + .into_stream() + .map(|chunk| chunk.map_err(Error::from)) + .boxed(), + )), + Err(object_store::Error::NotFound { .. }) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn validate_relative_path(relative_path: &str) -> Result<()> { + validate_filename_segments(relative_path).map(drop) + } + pub async fn list_for_run(&self, run_id: &RunId) -> Result> { let prefix = self.run_prefix(run_id)?; let mut stream = self.object_store.list(Some(&prefix)); @@ -460,6 +483,22 @@ mod tests { ); } + #[tokio::test] + async fn get_stream_round_trips_chunked_reads() { + let store = test_store(); + let run_id = fixtures::RUN_1; + let key = ArtifactKey::new(StageId::new("build", 2), 1, "logs/output.txt"); + store.put(&run_id, &key, b"hello world").await.unwrap(); + + let mut stream = store.get_stream(&run_id, &key).await.unwrap().unwrap(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk.unwrap()); + } + + assert_eq!(bytes, b"hello world"); + } + #[tokio::test] async fn rejects_invalid_relative_filenames() { let store = test_store(); diff --git a/lib/packages/fabro-api-client/src/api/run-internals-api.ts b/lib/packages/fabro-api-client/src/api/run-internals-api.ts index 85dfe6632..2f0e779b1 100644 --- a/lib/packages/fabro-api-client/src/api/run-internals-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-internals-api.ts @@ -146,6 +146,46 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config options: localVarRequestOptions, }; }, + /** + * Streams a ZIP archive with the latest captured version of each artifact path. Stage order, retry number, and stage ID determine the latest version, matching the artifacts page. Captures from the `start` and `exit` control nodes are excluded. + * @summary Download Run Artifacts + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadRunArtifacts: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('downloadRunArtifacts', 'id', id) + const localVarPath = `/api/v1/runs/{id}/artifacts/download` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/zip,application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Returns one stored run event by source event sequence with content fields separated and truncated. * @summary Get Run Event Detail @@ -946,6 +986,19 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.attachRunEvents']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Streams a ZIP archive with the latest captured version of each artifact path. Stage order, retry number, and stage ID determine the latest version, matching the artifacts page. Captures from the `start` and `exit` control nodes are excluded. + * @summary Download Run Artifacts + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadRunArtifacts(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadRunArtifacts(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.downloadRunArtifacts']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Returns one stored run event by source event sequence with content fields separated and truncated. * @summary Get Run Event Detail @@ -1210,6 +1263,16 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b attachRunEvents(id: string, sinceSeq?: number, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.attachRunEvents(id, sinceSeq, options).then((request) => request(axios, basePath)); }, + /** + * Streams a ZIP archive with the latest captured version of each artifact path. Stage order, retry number, and stage ID determine the latest version, matching the artifacts page. Captures from the `start` and `exit` control nodes are excluded. + * @summary Download Run Artifacts + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadRunArtifacts(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.downloadRunArtifacts(id, options).then((request) => request(axios, basePath)); + }, /** * Returns one stored run event by source event sequence with content fields separated and truncated. * @summary Get Run Event Detail @@ -1426,6 +1489,17 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).attachRunEvents(id, sinceSeq, options).then((request) => request(this.axios, this.basePath)); } + /** + * Streams a ZIP archive with the latest captured version of each artifact path. Stage order, retry number, and stage ID determine the latest version, matching the artifacts page. Captures from the `start` and `exit` control nodes are excluded. + * @summary Download Run Artifacts + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public downloadRunArtifacts(id: string, options?: RawAxiosRequestConfig) { + return RunInternalsApiFp(this.configuration).downloadRunArtifacts(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * Returns one stored run event by source event sequence with content fields separated and truncated. * @summary Get Run Event Detail From bf347b3e0dbb08569f234235d467192a1e00f107 Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 29 Jul 2026 19:22:19 +0000 Subject: [PATCH 29/57] 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 30/57] 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 31/57] 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 32/57] 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 33/57] 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 34/57] 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 35/57] 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 36/57] 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 37/57] 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 38/57] 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 39/57] 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 40/57] 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 41/57] 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 42/57] 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 43/57] 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 44/57] 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 45/57] 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 dc46d183b0ccfdd8b4cc7ad138739e9125362602 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 1 Aug 2026 09:04:36 -0400 Subject: [PATCH 46/57] fix(llm): classify provider error codes through one shared table Three places translated a provider error code into a ProviderErrorKind: error_from_status_code for HTTP error bodies, and a private table in each of the openai_responses and anthropic_messages stream decoders. The tables disagreed, so the same failure classified differently depending on which path saw it. Most visibly, OpenAI returns HTTP 429 with error.type "insufficient_quota" when an account is out of credit. The streaming decoder mapped that to QuotaExceeded, but the non-streaming path fell through to the plain 429 => RateLimit arm, so a spent quota was retried with backoff and never triggered failover. Move the code table into error.rs as kind_from_error_code, returning None when the code says nothing so each caller keeps its own default. All three call sites now share it. In error_from_status_code, unambiguous statuses (401, 403, 404, 408, 413, 5xx) still win outright. A 429 defers to the code only when it reports a spent quota. Ambiguous statuses (400, 422, ...) prefer the structured code over the existing message-substring guessing, which now runs only when there is no code. Two classifications improve as a side effect of merging the tables: not_found_error now maps to NotFound rather than Server for openai, and request_too_large maps to ContextLength rather than InvalidRequest. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/codec/anthropic_messages/stream.rs | 17 +- .../src/codec/openai_responses/stream.rs | 35 +--- lib/components/fabro-llm/src/error.rs | 169 ++++++++++++++++-- 3 files changed, 168 insertions(+), 53 deletions(-) diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs index 1f6a55fb8..15e1e71cd 100644 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs +++ b/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs @@ -8,7 +8,7 @@ use super::SYNTHETIC_TOOL_NAME; use super::decode::{convert_synthetic_tool_to_text, map_finish_reason, refusal_error}; use crate::codec::{RawEvent, StreamDecoder, parse_tool_arguments_or_empty}; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; +use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, kind_from_error_code}; use crate::types::{ ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall, @@ -355,16 +355,11 @@ fn stream_error_event_to_provider_error(data: &serde_json::Value, provider_name: .and_then(serde_json::Value::as_str) .map(String::from); - let kind = match error_code.as_deref() { - Some("rate_limit_error") => ProviderErrorKind::RateLimit, - Some("authentication_error") => ProviderErrorKind::Authentication, - Some("permission_error") => ProviderErrorKind::AccessDenied, - Some("not_found_error") => ProviderErrorKind::NotFound, - Some("invalid_request_error") => ProviderErrorKind::InvalidRequest, - Some("request_too_large") => ProviderErrorKind::ContextLength, - // overloaded_error, api_error, and unknown stream errors are transient. - _ => ProviderErrorKind::Server, - }; + // overloaded_error, api_error, and unknown stream errors are transient. + let kind = error_code + .as_deref() + .and_then(kind_from_error_code) + .unwrap_or(ProviderErrorKind::Server); Error::Provider { kind, diff --git a/lib/components/fabro-llm/src/codec/openai_responses/stream.rs b/lib/components/fabro-llm/src/codec/openai_responses/stream.rs index 9d0a06d4d..643369688 100644 --- a/lib/components/fabro-llm/src/codec/openai_responses/stream.rs +++ b/lib/components/fabro-llm/src/codec/openai_responses/stream.rs @@ -11,7 +11,7 @@ use serde::Deserialize; use super::decode::{map_finish_reason, token_counts_from_api_usage, tool_call_from_item}; use super::wire::ApiUsage; use crate::codec::{CodecCtx, RawEvent, StreamDecoder}; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; +use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, kind_from_error_code}; use crate::types::{ ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, TokenCounts, ToolCall, @@ -36,35 +36,10 @@ fn provider_error_from_openai_error_json(error: &serde_json::Value, provider: &s .filter(|message| !message.is_empty()) .map_or_else(|| "OpenAI stream error".to_string(), str::to_string); - let kind = match classifier { - Some("insufficient_quota" | "billing_hard_limit_reached") => { - ProviderErrorKind::QuotaExceeded - } - Some("rate_limit_error" | "rate_limit_exceeded" | "too_many_requests") => { - ProviderErrorKind::RateLimit - } - Some("authentication_error" | "invalid_api_key" | "invalid_authentication") => { - ProviderErrorKind::Authentication - } - Some( - "access_denied" | "account_deactivated" | "permission_denied" | "permission_error", - ) => ProviderErrorKind::AccessDenied, - Some("content_filter" | "content_policy_violation") => ProviderErrorKind::ContentFilter, - Some("context_length_exceeded") => ProviderErrorKind::ContextLength, - Some("server_error" | "internal_error" | "service_unavailable" | "engine_overloaded") => { - ProviderErrorKind::Server - } - Some(code) if code.ends_with("_not_found") => ProviderErrorKind::NotFound, - Some(code) - if code.starts_with("invalid_") - || code.starts_with("unsupported_") - || code.ends_with("_too_large") - || code.ends_with("_too_long") => - { - ProviderErrorKind::InvalidRequest - } - Some(_) | None => ProviderErrorKind::Server, - }; + // Unrecognized and absent codes are treated as transient. + let kind = classifier + .and_then(kind_from_error_code) + .unwrap_or(ProviderErrorKind::Server); Error::Provider { kind, diff --git a/lib/components/fabro-llm/src/error.rs b/lib/components/fabro-llm/src/error.rs index b6fc67661..7897eebe7 100644 --- a/lib/components/fabro-llm/src/error.rs +++ b/lib/components/fabro-llm/src/error.rs @@ -284,6 +284,52 @@ impl Error { } } +/// Provider error code to error kind mapping, for the codes that say more +/// than the transport-level status or stream event type does. +/// +/// Returns `None` when the code adds nothing, so each caller keeps its own +/// default: the stream decoders treat an unrecognized code as transient, +/// while [`error_from_status_code`] falls back to the HTTP status. +/// +/// Every dialect classifies through this one table so a code such as +/// `insufficient_quota` means the same thing whether it arrives in an HTTP +/// error body or in a mid-stream error event. +#[must_use] +pub fn kind_from_error_code(code: &str) -> Option { + Some(match code { + // Out of credit, or over a billing cap. Distinct from RateLimit: + // backoff never clears it, but another provider has its own quota. + "insufficient_quota" | "billing_hard_limit_reached" | "exceeded_current_quota_error" => { + ProviderErrorKind::QuotaExceeded + } + "rate_limit_error" | "rate_limit_exceeded" | "too_many_requests" => { + ProviderErrorKind::RateLimit + } + "authentication_error" | "invalid_api_key" | "invalid_authentication" => { + ProviderErrorKind::Authentication + } + "access_denied" | "account_deactivated" | "permission_denied" | "permission_error" => { + ProviderErrorKind::AccessDenied + } + "content_filter" | "content_policy_violation" => ProviderErrorKind::ContentFilter, + // `request_too_large` is anthropic's oversized-input code, so it has + // to precede the `_too_large` suffix rule below. + "context_length_exceeded" | "request_too_large" => ProviderErrorKind::ContextLength, + "server_error" | "internal_error" | "service_unavailable" | "engine_overloaded" => { + ProviderErrorKind::Server + } + c if c == "not_found_error" || c.ends_with("_not_found") => ProviderErrorKind::NotFound, + c if c.starts_with("invalid_") + || c.starts_with("unsupported_") + || c.ends_with("_too_large") + || c.ends_with("_too_long") => + { + ProviderErrorKind::InvalidRequest + } + _ => return None, + }) +} + /// HTTP status code to error type mapping (Section 6.4). #[must_use] pub fn error_from_status_code( @@ -303,6 +349,8 @@ pub fn error_from_status_code( raw, }; + let code_kind = detail.error_code.as_deref().and_then(kind_from_error_code); + // Check specific status codes first -- these always map to their designated // error types let kind = match status_code { @@ -316,13 +364,16 @@ pub fn error_from_status_code( }; } 413 => ProviderErrorKind::ContextLength, - 429 if detail.error_code.as_deref() == Some("exceeded_current_quota_error") => { + // A 429 means rate limited unless the body reports a spent quota, + // which retrying will never clear. + 429 if code_kind == Some(ProviderErrorKind::QuotaExceeded) => { ProviderErrorKind::QuotaExceeded } 429 => ProviderErrorKind::RateLimit, 500..=599 => ProviderErrorKind::Server, - // For ambiguous status codes (400, 422, etc.), use message-based classification - _ => { + // For ambiguous status codes (400, 422, etc.), the provider's error + // code is the better signal; fall back to the message only without one + _ => code_kind.unwrap_or_else(|| { let lower_msg = detail.message.to_lowercase(); if lower_msg.contains("not found") || lower_msg.contains("does not exist") { ProviderErrorKind::NotFound @@ -336,7 +387,7 @@ pub fn error_from_status_code( } else { ProviderErrorKind::InvalidRequest } - } + }), }; Error::Provider { @@ -606,20 +657,114 @@ mod tests { assert!(err.retryable()); } + /// Every vendor spelling of "you are out of credit" arrives as a 429 and + /// has to classify as a spent quota, not as a rate limit. #[test] - fn exceeded_current_quota_error_is_non_retryable_quota_failure() { + fn quota_codes_on_429_are_non_retryable_quota_failures() { + for (provider, code) in [ + ("kimi", "exceeded_current_quota_error"), + ("openai", "insufficient_quota"), + ("openai", "billing_hard_limit_reached"), + ] { + let err = error_from_status_code( + 429, + "Your account has insufficient balance".into(), + provider.into(), + Some(code.into()), + None, + None, + ); + + assert_eq!( + err.provider_kind(), + Some(ProviderErrorKind::QuotaExceeded), + "{code}" + ); + assert!(!err.retryable(), "{code}"); + assert!(err.failover_eligible(), "{code}"); + } + } + + /// A 429 that is a genuine rate limit stays retryable, whether the body + /// names it, names something unrecognized, or carries no code at all. + #[test] + fn non_quota_429_stays_a_retryable_rate_limit() { + for code in [ + Some("rate_limit_error"), + Some("rate_limit_reached_error"), + Some("invalid_request_error"), + None, + ] { + let err = error_from_status_code( + 429, + "slow down".into(), + "openai".into(), + code.map(String::from), + None, + None, + ); + + assert_eq!( + err.provider_kind(), + Some(ProviderErrorKind::RateLimit), + "{code:?}" + ); + assert!(err.retryable(), "{code:?}"); + } + } + + /// For a status with no fixed meaning, the structured code beats guessing + /// from the message text. + #[test] + fn ambiguous_status_prefers_error_code_over_message() { let err = error_from_status_code( - 429, - "Your account is suspended due to insufficient balance".into(), - "kimi".into(), - Some("exceeded_current_quota_error".into()), + 402, + "Payment required".into(), + "openai".into(), + Some("insufficient_quota".into()), None, None, ); - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded)); - assert!(!err.retryable()); - assert!(err.failover_eligible()); + + // No code, so the message fallback still runs. + let err = error_from_status_code( + 400, + "This model's maximum context length is 4096 tokens".into(), + "openai".into(), + None, + None, + None, + ); + assert_eq!(err.provider_kind(), Some(ProviderErrorKind::ContextLength)); + } + + #[test] + fn kind_from_error_code_covers_every_dialect() { + for (code, expected) in [ + ("insufficient_quota", ProviderErrorKind::QuotaExceeded), + ("rate_limit_error", ProviderErrorKind::RateLimit), + ("authentication_error", ProviderErrorKind::Authentication), + ("permission_error", ProviderErrorKind::AccessDenied), + ("content_policy_violation", ProviderErrorKind::ContentFilter), + ("context_length_exceeded", ProviderErrorKind::ContextLength), + ("engine_overloaded", ProviderErrorKind::Server), + // anthropic's oversized-input code beats the `_too_large` rule + ("request_too_large", ProviderErrorKind::ContextLength), + ("prompt_too_long", ProviderErrorKind::InvalidRequest), + ("invalid_request_error", ProviderErrorKind::InvalidRequest), + ("unsupported_parameter", ProviderErrorKind::InvalidRequest), + // both the anthropic and openai not-found spellings + ("not_found_error", ProviderErrorKind::NotFound), + ("model_not_found", ProviderErrorKind::NotFound), + ] { + assert_eq!(kind_from_error_code(code), Some(expected), "{code}"); + } + + // No opinion, so the caller keeps its own default. + assert_eq!(kind_from_error_code("overloaded_error"), None); + assert_eq!(kind_from_error_code("api_error"), None); + assert_eq!(kind_from_error_code(""), None); } #[test] From daaca3f479fc1cec2a2c07f3bdc65b2e243e04ae Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 1 Aug 2026 09:09:54 -0400 Subject: [PATCH 47/57] refactor: simplify artifact ZIP download Path safety now lives in one place. The NUL-byte and drive-letter rules move from a server-only helper into the store's own filename validation, so uploads reject those paths at write time instead of only the ZIP read path catching them. The download still re-checks, because artifacts stored before the rule existed can still carry an unsafe path, but it now skips a bad path rather than failing the whole archive. Promote is_boundary_stage to RunProjection and drop the three identical private copies. The ZIP download used a node-name match instead, which would have dropped artifacts from a working node that happened to be named "start". Compress the archive. Entries were Stored while the response was also excluded from transfer compression, so text artifacts moved at full size. async_zip gains the deflate feature; async-compression and flate2 were already in the lock file. Log archive failures unconditionally. The send-succeeded guard meant a client that had already disconnected left no record at all, which is the case where the log is the only evidence. Also: collapse the duplicate 500 arms, drop the dead stage-ID tiebreaker and the cached order in the selection map, name the accessible label after the visible one, share the run URL prefix between the two download href builders, and document the mid-stream truncation behavior in the OpenAPI description. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 2 + Cargo.toml | 2 +- apps/fabro-web/app/lib/api-client.ts | 18 ++- apps/fabro-web/app/routes/run-artifacts.tsx | 3 +- docs/public/api-reference/fabro-api.yaml | 6 +- .../src/server/handler/artifacts.rs | 142 ++++++++---------- .../src/server/handler/billing.rs | 11 +- lib/apps/fabro-server/src/server/tests.rs | 17 +-- .../fabro-store/src/artifact_store.rs | 32 +++- lib/components/fabro-store/src/run_state.rs | 11 +- .../fabro-workflow/src/billing_rollup.rs | 11 +- .../fabro-types/src/run_projection.rs | 14 ++ .../src/api/run-internals-api.ts | 8 +- 13 files changed, 145 insertions(+), 132 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 784dc1357..aa1d75762 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,7 @@ checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", + "futures-io", "pin-project-lite", "tokio", ] @@ -408,6 +409,7 @@ version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" dependencies = [ + "async-compression", "crc32fast", "futures-lite", "pin-project", diff --git a/Cargo.toml b/Cargo.toml index 702b4e77e..53ff423f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ dotenvy = "0.15" futures = "0.3" tokio-stream = "0.1" async-trait = "0.1" -async_zip = { version = "0.0.18", features = ["tokio"] } +async_zip = { version = "0.0.18", features = ["tokio", "deflate"] } fs2 = "0.4" base64 = "0.22" bytes = "1" diff --git a/apps/fabro-web/app/lib/api-client.ts b/apps/fabro-web/app/lib/api-client.ts index 4c298fd0c..bb59e3607 100644 --- a/apps/fabro-web/app/lib/api-client.ts +++ b/apps/fabro-web/app/lib/api-client.ts @@ -408,6 +408,13 @@ export function requestSignalOptions(request?: Request): RawAxiosRequestConfig { return request?.signal ? { signal: request.signal } : {}; } +/** Absolute href for a path under a run, for links the browser follows itself. */ +function runApiPath(id: string, suffix: string): string { + return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${ + encodeURIComponent(id) + }${suffix}`; +} + export function stageArtifactDownloadUrl( id: string, stageId: string, @@ -418,13 +425,12 @@ export function stageArtifactDownloadUrl( filename, retry: String(retry), }); - return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${ - encodeURIComponent(id) - }/stages/${encodeURIComponent(stageId)}/artifacts/download?${searchParams}`; + return runApiPath( + id, + `/stages/${encodeURIComponent(stageId)}/artifacts/download?${searchParams}`, + ); } export function runArtifactsDownloadUrl(id: string): string { - return `${generatedApiConfiguration.basePath ?? ""}/api/v1/runs/${ - encodeURIComponent(id) - }/artifacts/download`; + return runApiPath(id, "/artifacts/download"); } diff --git a/apps/fabro-web/app/routes/run-artifacts.tsx b/apps/fabro-web/app/routes/run-artifacts.tsx index c03d68875..0f75d6a2e 100644 --- a/apps/fabro-web/app/routes/run-artifacts.tsx +++ b/apps/fabro-web/app/routes/run-artifacts.tsx @@ -138,8 +138,7 @@ function ArtifactList({ runId, files }: { runId: string; files: readonly Artifac