From f4488c4d35f74e830cb59c8ef53878597dd83549 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 7 Apr 2026 12:59:25 -0400 Subject: [PATCH] feat(run): record run creation provenance Persist server, client, and subject provenance on run creation so run state and inspect output can show which Fabro version created a run, which first-party client submitted it, and how the request was authenticated. --- lib/crates/fabro-checkpoint/src/metadata.rs | 1 + .../fabro-cli/src/commands/store/dump.rs | 2 + lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 32 ++++- lib/crates/fabro-cli/tests/it/cmd/support.rs | 16 +++ lib/crates/fabro-server/src/jwt_auth.rs | 133 ++++++++++++++++-- lib/crates/fabro-server/src/run_manifest.rs | 1 + lib/crates/fabro-server/src/serve.rs | 5 +- lib/crates/fabro-server/src/server.rs | 97 ++++++++++++- lib/crates/fabro-store/src/run_state.rs | 1 + lib/crates/fabro-store/src/slate/mod.rs | 1 + lib/crates/fabro-types/src/lib.rs | 5 +- lib/crates/fabro-types/src/run.rs | 43 ++++++ lib/crates/fabro-types/src/run_event/run.rs | 5 +- lib/crates/fabro-workflow/src/event.rs | 7 +- .../fabro-workflow/src/operations/create.rs | 77 +++++++++- .../src/operations/rebuild_meta.rs | 2 + .../fabro-workflow/src/operations/start.rs | 1 + .../src/pipeline/execute/tests.rs | 1 + .../fabro-workflow/src/pipeline/initialize.rs | 1 + .../fabro-workflow/src/pipeline/persist.rs | 2 + .../src/pipeline/pull_request.rs | 6 + .../fabro-workflow/src/pipeline/retro.rs | 2 + lib/crates/fabro-workflow/src/run_lookup.rs | 2 + lib/crates/fabro-workflow/src/test_support.rs | 1 + 24 files changed, 418 insertions(+), 26 deletions(-) diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs index 857392214..cdbe86711 100644 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -214,6 +214,7 @@ mod tests { repo_origin_url: None, base_branch: None, labels: HashMap::new(), + provenance: None, } } diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 446a82cde..4c7e6f97c 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -193,6 +193,7 @@ mod tests { repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()), base_branch: Some("main".to_string()), labels: HashMap::from([("team".to_string(), "infra".to_string())]), + provenance: None, } } @@ -325,6 +326,7 @@ mod tests { base_branch: run_record.base_branch.clone(), workflow_slug: run_record.workflow_slug.clone(), db_prefix: None, + provenance: run_record.provenance.clone(), }, ) .await diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 534c775c7..1cd80169a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -51,7 +51,13 @@ fn inspect_created_run_shows_run_record_without_start_or_conclusion() { "workflow_name": "Simple", "workflow_slug": "simple", "sandbox_provider": "local", - "dry_run": true + "dry_run": true, + "provenance": { + "server_version": "[VERSION]", + "client_name": "fabro-cli", + "client_version": "[VERSION]", + "subject_auth_method": "disabled" + } }, "start_record": null, "conclusion": null, @@ -78,7 +84,13 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() { "workflow_name": "Simple", "workflow_slug": "simple", "sandbox_provider": "local", - "dry_run": true + "dry_run": true, + "provenance": { + "server_version": "[VERSION]", + "client_name": "fabro-cli", + "client_version": "[VERSION]", + "subject_auth_method": "disabled" + } }, "start_record": { "has_start_time": true @@ -147,7 +159,13 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() { "workflow_name": "Simple", "workflow_slug": "simple", "sandbox_provider": "local", - "dry_run": true + "dry_run": true, + "provenance": { + "server_version": "[VERSION]", + "client_name": "fabro-cli", + "client_version": "[VERSION]", + "subject_auth_method": "disabled" + } }, "start_record": { "has_start_time": true @@ -192,7 +210,13 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() { "workflow_name": "Flow", "workflow_slug": "flow", "llm_provider": "openai", - "sandbox_provider": "local" + "sandbox_provider": "local", + "provenance": { + "server_version": "[VERSION]", + "client_name": "fabro-cli", + "client_version": "[VERSION]", + "subject_auth_method": "disabled" + } }, "start_record": { "has_start_time": true, diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 57b18ca7d..feba3a519 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -796,6 +796,14 @@ pub(crate) fn compact_inspect(output: &Output) -> Value { "workflow_slug": run_record.pointer("/workflow_slug"), "sandbox_provider": run_record.pointer("/settings/sandbox/provider"), "dry_run": run_record.pointer("/settings/dry_run"), + "provenance": run_record.pointer("/provenance").as_ref().map(|_| { + serde_json::json!({ + "server_version": "[VERSION]", + "client_name": run_record.pointer("/provenance/client/name"), + "client_version": "[VERSION]", + "subject_auth_method": run_record.pointer("/provenance/subject/auth_method"), + }) + }), }, "start_record": item["start_record"].as_object().map(|record| { serde_json::json!({ @@ -847,6 +855,14 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value { "workflow_slug": run_record.pointer("/workflow_slug"), "llm_provider": run_record.pointer("/settings/llm/provider"), "sandbox_provider": run_record.pointer("/settings/sandbox/provider"), + "provenance": run_record.pointer("/provenance").as_ref().map(|_| { + serde_json::json!({ + "server_version": "[VERSION]", + "client_name": run_record.pointer("/provenance/client/name"), + "client_version": "[VERSION]", + "subject_auth_method": run_record.pointer("/provenance/subject/auth_method"), + }) + }), }, "start_record": start_record.as_object().map(|_| { serde_json::json!({ diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 5b314f1d1..3a7101609 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -11,6 +11,7 @@ use tracing::warn; use crate::error::ApiError; use crate::web_auth::SessionCookie; use fabro_config::server::ApiSettings; +use fabro_types::RunAuthMethod; /// JWT claims for service-to-service authentication. #[derive(Debug, Deserialize)] @@ -292,16 +293,13 @@ impl FromRequestParts for AuthenticatedService { } } -/// Axum extractor that authenticates and extracts the user's login. -/// -/// - Demo mode → `login: "demo"` -/// - JWT → login from the `sub` claim (last path segment of URL) -/// - mTLS → CN from the peer certificate -pub struct AuthenticatedUser { - pub login: String, +/// Axum extractor that authenticates and extracts the request subject. +pub struct AuthenticatedSubject { + pub login: Option, + pub auth_method: RunAuthMethod, } -impl FromRequestParts for AuthenticatedUser { +impl FromRequestParts for AuthenticatedSubject { type Rejection = ApiError; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { @@ -313,7 +311,8 @@ impl FromRequestParts for AuthenticatedUser { let strategies = match auth_mode { AuthMode::Disabled => { return Ok(Self { - login: "demo".to_string(), + login: None, + auth_method: RunAuthMethod::Disabled, }); } AuthMode::Strategies(strategies) => strategies, @@ -330,7 +329,8 @@ impl FromRequestParts for AuthenticatedUser { AuthStrategy::Cookie => { if let Some(session) = parts.extensions.get::() { return Ok(Self { - login: session.login.clone(), + login: Some(session.login.clone()), + auth_method: RunAuthMethod::Cookie, }); } last_err = ApiError::unauthorized(); @@ -342,7 +342,10 @@ impl FromRequestParts for AuthenticatedUser { } => { if try_jwt(parts, key, validation, allowed_usernames).is_ok() { if let Some(login) = extract_jwt_login(parts, key, validation) { - return Ok(Self { login }); + return Ok(Self { + login: Some(login), + auth_method: RunAuthMethod::Jwt, + }); } } last_err = ApiError::unauthorized(); @@ -350,7 +353,10 @@ impl FromRequestParts for AuthenticatedUser { AuthStrategy::Mtls => { if try_mtls(parts).is_ok() { if let Some(login) = extract_mtls_cn(parts) { - return Ok(Self { login }); + return Ok(Self { + login: Some(login), + auth_method: RunAuthMethod::Mtls, + }); } } last_err = ApiError::unauthorized(); @@ -365,23 +371,44 @@ impl FromRequestParts for AuthenticatedUser { #[cfg(test)] mod tests { use super::*; + use axum::Json; use axum::Router; - use axum::body::Body; + use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use axum::response::IntoResponse; use axum::routing::get; use tower::ServiceExt; + use crate::web_auth::SessionCookie; + async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse { "ok" } + async fn subject_handler(subject: AuthenticatedSubject) -> impl IntoResponse { + Json(serde_json::json!({ + "login": subject.login, + "auth_method": subject.auth_method, + })) + } + fn test_router(mode: AuthMode) -> Router { Router::new() .route("/test", get(protected_handler)) .layer(axum::Extension(mode)) } + fn subject_router(mode: AuthMode) -> Router { + Router::new() + .route("/subject", get(subject_handler)) + .layer(axum::Extension(mode)) + } + + async fn response_json(response: axum::response::Response) -> serde_json::Value { + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + fn generate_test_keypair() -> (jsonwebtoken::EncodingKey, DecodingKey) { let output = std::process::Command::new("openssl") .args(["genpkey", "-algorithm", "Ed25519"]) @@ -714,6 +741,72 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test] + async fn disabled_mode_extracts_disabled_subject() { + let app = subject_router(AuthMode::Disabled); + + let req = Request::builder() + .uri("/subject") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["login"], serde_json::Value::Null); + assert_eq!(body["auth_method"], "disabled"); + } + + #[tokio::test] + async fn jwt_subject_extracts_login_and_auth_method() { + let (encoding, decoding) = generate_test_keypair(); + let app = subject_router(jwt_mode(decoding, vec!["brynary"])); + + let token = sign_token( + &encoding, + "fabro-web", + 60, + Some("https://github.com/brynary"), + ); + + let req = Request::builder() + .uri("/subject") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["login"], "brynary"); + assert_eq!(body["auth_method"], "jwt"); + } + + #[tokio::test] + async fn cookie_subject_extracts_login_and_auth_method() { + let app = subject_router(AuthMode::Strategies(vec![AuthStrategy::Cookie])); + + let mut req = Request::builder() + .uri("/subject") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(SessionCookie { + login: "brynary".to_string(), + name: "Brynary".to_string(), + email: "b@example.com".to_string(), + avatar_url: "https://example.com/avatar.png".to_string(), + user_url: "https://github.com/brynary".to_string(), + github_id: 1, + exp: 9999999999, + }); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["login"], "brynary"); + assert_eq!(body["auth_method"], "cookie"); + } + #[tokio::test] async fn empty_strategies_rejects() { let app = test_router(AuthMode::Strategies(vec![])); @@ -768,6 +861,20 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn mtls_subject_extracts_login_and_auth_method() { + let app = subject_router(AuthMode::Strategies(vec![AuthStrategy::Mtls])); + + let cert = generate_test_client_cert("brynary"); + let req = request_with_peer_certs("/subject", Some(vec![cert])); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["login"], "brynary"); + assert_eq!(body["auth_method"], "mtls"); + } + // --- Multi-strategy tests --- #[tokio::test] diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index b58f21202..a471d9db3 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -131,6 +131,7 @@ pub(crate) fn create_run_input(prepared: PreparedManifest) -> CreateRunInput { .as_ref() .map(|git| fabro_github::normalize_repo_origin_url(&git.origin_url)), base_branch: prepared.git.as_ref().map(|git| git.branch.clone()), + provenance: None, } } diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 1adc6bb95..55cfbc2f7 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -549,7 +549,10 @@ mod tests { let disk_store = build_object_store_with_preference(&store_path, false) .expect("disk-backed store should build"); - assert!(store_path.exists(), "disk-backed store should create store dir"); + assert!( + store_path.exists(), + "disk-backed store should create store dir" + ); drop(disk_store); let mem_path = temp.path().join("memory-store"); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 826d82f36..92c83e04f 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -10,7 +10,7 @@ use crate::bind::Bind; #[cfg(test)] use axum::body::to_bytes; use axum::extract::{self as axum_extract, Path, Query, State}; -use axum::http::{HeaderValue, Method, StatusCode}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; @@ -29,7 +29,10 @@ use fabro_llm::types::{ Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage, }; use fabro_store::{ArtifactStore, Database, EventEnvelope, EventPayload, StageId}; -use fabro_types::{RunBlobId, RunControlAction, RunEvent, RunId, Settings}; +use fabro_types::{ + RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, + RunServerProvenance, RunSubjectProvenance, Settings, +}; use fabro_util::redact::redact_jsonl_line; use fabro_util::version::FABRO_VERSION; use fabro_workflow::artifacts as workflow_artifacts; @@ -58,7 +61,7 @@ use tracing::{error, info}; use crate::demo; use crate::diagnostics; use crate::error::ApiError; -use crate::jwt_auth::{AuthMode, AuthenticatedService}; +use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedSubject}; use crate::run_manifest; use crate::secret_store::{SecretStore, SecretStoreError}; use crate::static_files; @@ -2223,8 +2226,9 @@ async fn write_file_answer(run_dir: &std::path::Path, answer: &Answer) -> anyhow } async fn create_run( - _auth: AuthenticatedService, + subject: AuthenticatedSubject, State(state): State>, + headers: HeaderMap, Json(req): Json, ) -> Response { let prepared = match run_manifest::prepare_manifest_with_mode( @@ -2240,6 +2244,7 @@ async fn create_run( let mut create_input = run_manifest::create_run_input(prepared.clone()); create_input.run_id = Some(run_id); + create_input.provenance = Some(run_provenance(&headers, &subject)); let created = match Box::pin(operations::create(state.store.as_ref(), create_input)).await { Ok(created) => created, @@ -2285,6 +2290,47 @@ async fn create_run( .into_response() } +fn run_provenance(headers: &HeaderMap, subject: &AuthenticatedSubject) -> RunProvenance { + RunProvenance { + server: Some(RunServerProvenance { + version: FABRO_VERSION.to_string(), + }), + client: run_client_provenance(headers), + subject: Some(RunSubjectProvenance { + login: subject.login.clone(), + auth_method: subject.auth_method, + }), + } +} + +fn run_client_provenance(headers: &HeaderMap) -> Option { + let user_agent = headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(str::to_string)?; + let (name, version) = parse_known_fabro_user_agent(&user_agent) + .map_or((None, None), |(name, version)| { + (Some(name.to_string()), Some(version.to_string())) + }); + Some(RunClientProvenance { + user_agent: Some(user_agent), + name, + version, + }) +} + +fn parse_known_fabro_user_agent(user_agent: &str) -> Option<(&str, &str)> { + let token = user_agent.split_whitespace().next()?; + let (name, version) = token.split_once('/')?; + if version.is_empty() { + return None; + } + match name { + "fabro-cli" | "fabro-web" => Some((name, version)), + _ => None, + } +} + async fn run_preflight( _auth: AuthenticatedService, State(state): State>, @@ -4998,6 +5044,49 @@ mod tests { assert!(body["nodes"].is_object()); } + #[tokio::test] + async fn get_run_state_includes_provenance_from_user_agent() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + + let req = Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .header("user-agent", "fabro-cli/1.2.3") + .body(manifest_body(MINIMAL_DOT)) + .unwrap(); + + let response = app.clone().oneshot(req).await.unwrap(); + let body = body_json(response.into_body()).await; + let run_id = body["id"].as_str().unwrap(); + + let req = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/state"))) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + assert_eq!( + body["run"]["provenance"]["server"]["version"], + FABRO_VERSION + ); + assert_eq!( + body["run"]["provenance"]["client"]["user_agent"], + "fabro-cli/1.2.3" + ); + assert_eq!(body["run"]["provenance"]["client"]["name"], "fabro-cli"); + assert_eq!(body["run"]["provenance"]["client"]["version"], "1.2.3"); + assert_eq!( + body["run"]["provenance"]["subject"]["auth_method"], + "disabled" + ); + assert!(body["run"]["provenance"]["subject"]["login"].is_null()); + } + #[tokio::test] async fn list_run_events_returns_paginated_json() { let state = create_app_state(); diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 50b46d868..060818635 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -99,6 +99,7 @@ impl RunProjection { repo_origin_url: props.repo_origin_url.clone(), base_branch: props.base_branch.clone(), labels, + provenance: props.provenance.clone(), }); self.graph_source.clone_from(&props.workflow_source); } diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index f4dc41602..3ae03d978 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -288,6 +288,7 @@ mod tests { repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()), base_branch: Some("main".to_string()), labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]), + provenance: None, } } diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 2f64bffa4..8e3098b5d 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -31,7 +31,10 @@ pub use retro::{ AggregateStats, FrictionKind, FrictionPoint, Learning, LearningCategory, OpenItem, OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro, }; -pub use run::RunRecord; +pub use run::{ + RunAuthMethod, RunClientProvenance, RunProvenance, RunRecord, RunServerProvenance, + RunSubjectProvenance, +}; pub use run_blob_id::RunBlobId; pub use run_event::{EventBody, RunEvent, RunNoticeLevel, TokenUsage}; pub use run_id::RunId; diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index bfb201817..cafef8d28 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -7,6 +7,47 @@ use crate::graph::Graph; use crate::run_id::RunId; use crate::settings::Settings; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunAuthMethod { + Disabled, + Cookie, + Jwt, + Mtls, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunServerProvenance { + pub version: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunClientProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunSubjectProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login: Option, + pub auth_method: RunAuthMethod, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunProvenance { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunRecord { pub run_id: RunId, @@ -23,4 +64,6 @@ pub struct RunRecord { pub base_branch: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub labels: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, } diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index c4f618201..0a6b215e2 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::{Graph, RunControlAction, Settings, StatusReason}; +use crate::{Graph, RunControlAction, RunProvenance, Settings, StatusReason}; use super::{RunNoticeLevel, TokenUsage}; @@ -28,6 +28,8 @@ pub struct RunCreatedProps { pub workflow_slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub db_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -56,6 +58,7 @@ pub struct RunControlRequestedProps { pub action: RunControlAction, } +#[allow(clippy::empty_structs_with_brackets)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct RunControlEffectProps {} diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 508714fad..ea603803f 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -49,6 +49,8 @@ pub enum Event { workflow_slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] db_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provenance: Option<::fabro_types::RunProvenance>, }, WorkflowRunStarted { name: String, @@ -1355,6 +1357,7 @@ fn event_body_from_event(event: &Event) -> EventBody { base_branch, workflow_slug, db_prefix, + provenance, .. } => EventBody::RunCreated(fabro_types::RunCreatedProps { settings: serde_json::from_value(settings.clone()).expect("run.created settings"), @@ -1369,6 +1372,7 @@ fn event_body_from_event(event: &Event) -> EventBody { base_branch: base_branch.clone(), workflow_slug: workflow_slug.clone(), db_prefix: db_prefix.clone(), + provenance: provenance.clone(), }), Event::WorkflowRunStarted { name, @@ -2370,7 +2374,7 @@ pub enum RunEventSink { Store(RunDatabase), JsonLines(Arc>>>), Callback(Arc), - Composite(Vec), + Composite(Vec), } type RunEventSinkFuture = Pin> + Send + 'static>>; @@ -2440,6 +2444,7 @@ impl RunEventSink { } } +#[allow(clippy::large_enum_variant)] enum RunEventCommand { Event(RunEvent), Flush(oneshot::Sender<()>), diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index f1d0fc955..6d8f0241b 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -3,7 +3,7 @@ use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; use fabro_sandbox::SandboxProvider; use fabro_store::Database; -use fabro_types::{RunId, Settings}; +use fabro_types::{RunId, RunProvenance, Settings}; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -35,6 +35,7 @@ pub struct CreateRunInput { pub host_repo_path: Option, pub repo_origin_url: Option, pub base_branch: Option, + pub provenance: Option, } #[derive(Debug)] @@ -55,6 +56,7 @@ struct PersistCreateOptions { working_directory: PathBuf, host_repo_path: Option, repo_origin_url: Option, + provenance: Option, } /// Resolve workflow inputs, normalize settings, and persist a run directory. @@ -81,6 +83,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result Result