diff --git a/TESTING.md b/TESTING.md index d939265c414..7819c326fb7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -367,3 +367,14 @@ CLI-side, only two matter for testing: | ACP logs `discovered 0 channel(s)` / `no channel subscriptions resolved` | Agent identity isn't a member of any channel | `buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member` from another identity | | `GOOSE_MODE` warning, agent hangs | Not set | `export GOOSE_MODE=auto` | | Tests pass locally but CI fails | Forgot to run `just ci` | `just ci` runs the gate (fmt, clippy, unit tests, desktop/web builds) | + +### Workflow fixture repository/cache isolation + +`AppState::new` initializes `GitPackCache`, whose startup sweeps stale +`session-*` directories under `config.git_pack_cache_path`. Workflow unit-test fixture states +therefore own a temporary repository/cache directory, outside the worktree. +Integration binaries must likewise set `BUZZ_GIT_REPO_PATH` and +`BUZZ_GIT_PACK_CACHE_PATH` to disposable external paths **before** reading +`Config::from_env`; the default is `./repos/.pack-cache`. Never infer cache +preservation from a clean Git status: Git does not inventory empty directories. +Capture an inventory before tests when pre-existing untracked files matter. diff --git a/crates/buzz-cli/src/commands/workflows.rs b/crates/buzz-cli/src/commands/workflows.rs index 0028dfc7663..a0c20710da0 100644 --- a/crates/buzz-cli/src/commands/workflows.rs +++ b/crates/buzz-cli/src/commands/workflows.rs @@ -162,44 +162,56 @@ pub async fn cmd_delete_workflow(client: &BuzzClient, workflow_id: &str) -> Resu Ok(()) } +async fn current_workflow_revision( + client: &BuzzClient, + workflow_id: &str, +) -> Result { + let response = client + .get_authed(&format!("/workflows/{workflow_id}/revision")) + .await?; + let event: serde_json::Value = serde_json::from_str(&response) + .map_err(|e| CliError::Other(format!("invalid workflow revision response: {e}")))?; + event + .get("id") + .and_then(|id| id.as_str()) + .map(str::to_owned) + .ok_or_else(|| CliError::NotFound(format!("workflow {workflow_id} not found"))) +} + /// Trigger a workflow — sign and submit a kind:46020 event. /// /// When `inputs` is provided, it is parsed as a JSON object and used as the -/// event content (MCP parity). When omitted, the event content is `{}`. +/// event content (MCP parity). When omitted, the event content is empty. pub async fn cmd_trigger_workflow( client: &BuzzClient, workflow_id: &str, inputs: Option<&str>, ) -> Result<(), CliError> { let wf_uuid = parse_uuid(workflow_id)?; + let revision = current_workflow_revision(client, workflow_id).await?; + + let builder = workflow_trigger_builder(wf_uuid, &revision, inputs)?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} +fn workflow_trigger_builder( + workflow_id: uuid::Uuid, + revision: &str, + inputs: Option<&str>, +) -> Result { if let Some(raw) = inputs { - // Parse and validate it is a JSON object, then build the event manually - // so we can embed the inputs as the event content. let parsed: serde_json::Value = serde_json::from_str(raw) .map_err(|e| CliError::Usage(format!("--inputs is not valid JSON: {e}")))?; - if !parsed.is_object() { - return Err(CliError::Usage("--inputs must be a JSON object".into())); - } - let content = serde_json::to_string(&parsed).unwrap_or_default(); - use nostr::{EventBuilder, Kind, Tag}; - let tags = vec![Tag::parse(["d", &wf_uuid.to_string()]) - .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; - let builder = EventBuilder::new( - Kind::Custom(buzz_sdk::kind::KIND_WORKFLOW_TRIGGER as u16), - &content, - ) - .tags(tags); - let event = client.sign_event(builder)?; - let resp = client.submit_event(event).await?; - println!("{}", normalize_write_response(&resp)); + let object = parsed + .as_object() + .ok_or_else(|| CliError::Usage("--inputs must be a JSON object".into()))?; + buzz_sdk::build_workflow_trigger_with_inputs(workflow_id, revision, object).map_err(sdk_err) } else { - let builder = buzz_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?; - let event = client.sign_event(builder)?; - let resp = client.submit_event(event).await?; - println!("{}", normalize_write_response(&resp)); + buzz_sdk::build_workflow_trigger(workflow_id, revision).map_err(sdk_err) } - Ok(()) } /// Approve or deny a workflow step — sign and submit a kind:46030 (grant) or 46031 (deny) event. @@ -254,3 +266,32 @@ pub async fn dispatch(cmd: crate::WorkflowsCmd, client: &BuzzClient) -> Result<( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trigger_builders_preserve_inputs_and_distinguish_same_second_operations() { + let keys = nostr::Keys::generate(); + let workflow_id = uuid::Uuid::new_v4(); + let revision = "ab".repeat(32); + let timestamp = nostr::Timestamp::from(1_700_000_000); + for inputs in [None, Some(r#"{"message":"hello"}"#)] { + let build = || { + workflow_trigger_builder(workflow_id, &revision, inputs) + .unwrap() + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .unwrap() + }; + let first = build(); + let second = build(); + assert_ne!(first.id, second.id); + assert_eq!(first.content, inputs.unwrap_or("")); + } + for invalid in ["not-json", "[]", "null", "1"] { + assert!(workflow_trigger_builder(workflow_id, &revision, Some(invalid)).is_err()); + } + } +} diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 5e89b1101b6..18800fb80a3 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -21,10 +21,11 @@ pub use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; // Keep the established channel module paths compatible while membership SQL // and invariants live in their dedicated store module. pub use crate::channel_members::{ - add_member, get_accessible_channel_ids, get_accessible_channels, get_bot_members, - get_member_count, get_member_counts_bulk, get_member_role, get_members, get_members_bulk, - get_users_bulk, is_member, list_large_channel_rosters_needing_reconciliation, - lock_member_snapshot, membership_pairs, remove_member, verify_channel_roster_fence_behavior, + acquire_channel_membership_lock, add_member, get_accessible_channel_ids, + get_accessible_channels, get_bot_members, get_member_count, get_member_counts_bulk, + get_member_role, get_member_role_in_transaction, get_members, get_members_bulk, get_users_bulk, + is_member, list_large_channel_rosters_needing_reconciliation, lock_member_snapshot, + membership_pairs, remove_member, verify_channel_roster_fence_behavior, verify_channel_roster_fence_catalog, AccessibleChannel, BotChannelEntry, BotMemberRecord, LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, }; diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 8280ca01f82..92bde638573 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -178,7 +178,7 @@ pub async fn verify_channel_roster_fence_behavior(pool: &sqlx::PgPool) -> Result /// Take the per-channel membership lock. MUST be the first statement in the /// transaction that then reads roles/owner counts and writes membership, so the /// whole check-then-write sequence is atomic against a concurrent one. -async fn acquire_channel_membership_lock( +pub async fn acquire_channel_membership_lock( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, channel_id: Uuid, @@ -1519,6 +1519,29 @@ impl Db { } } +/// Get an active member role using the caller's transaction. +/// +/// Authorization callers must acquire [`acquire_channel_membership_lock`] first +/// and hold the transaction through the dependent mutation. +pub async fn get_member_role_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT cm.role::text AS role FROM channel_members cm \ + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.pubkey = $3 AND cm.removed_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + Ok(row.map(|r| r.try_get("role")).transpose()?) +} + #[cfg(test)] mod postgres_tests { use super::*; diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index cb45809eadb..e88582d7486 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1164,6 +1164,26 @@ pub(crate) async fn get_event_by_id_with_operation( } } +/// Fetch a single live event by ID on the caller's transaction. +pub async fn get_event_by_id_in_transaction( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + id_bytes: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ + FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(id_bytes) + .fetch_optional(&mut **tx) + .await?; + match row { + Some(r) => row_to_stored_event(r), + None => Ok(None), + } +} + /// Fetches the latest global (non-channel, `channel_id IS NULL`) replaceable event /// for a (kind, pubkey) pair. /// @@ -1528,6 +1548,17 @@ pub async fn insert_event_with_thread_metadata( } impl Db { + /// Fetch a live event by ID using the caller's transaction. + #[datastore_span(name = "get_event_by_id_in_transaction", system = "postgresql")] + pub async fn get_event_by_id_in_transaction( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id_in_transaction(tx, community_id, id_bytes).await + } + /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. #[datastore_span(name = "insert_event", system = "postgresql")] pub async fn insert_event( diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index 623d979e102..8b2b4fe0727 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -409,6 +409,33 @@ pub async fn get_workflow( row_to_workflow_record(row) } +/// Fetch and share-lock one workflow on an existing transaction. +/// +/// Definition replacement updates this row, so holding this lock through run +/// creation keeps revision validation and the dependent run atomic. +pub async fn get_workflow_for_share_in_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + id: Uuid, +) -> Result { + let row = sqlx::query( + r#" + SELECT id, community_id, name, owner_pubkey, channel_id, definition, definition_hash, definition_event_id, + status::text AS status, enabled, created_at, updated_at + FROM workflows + WHERE community_id = $1 AND id = $2 + FOR SHARE + "#, + ) + .bind(community_id.as_uuid()) + .bind(id) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| DbError::NotFound(format!("workflow {id}")))?; + + row_to_workflow_record(row) +} + /// List workflows for a channel, ordered newest first. /// /// `limit` is capped at [`LIST_MAX_LIMIT`]. Pass `None` to use [`LIST_DEFAULT_LIMIT`]. @@ -817,6 +844,34 @@ pub async fn delete_workflow_for_owner( // -- Workflow Run CRUD -------------------------------------------------------- +/// Insert a new exact-revision workflow run on the caller's transaction. +pub async fn create_workflow_run_in_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + workflow_id: Uuid, + definition_event_id: &[u8], + trigger_event_id: Option<&[u8]>, + trigger_context: Option<&serde_json::Value>, +) -> Result { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO workflow_runs + (community_id, id, workflow_id, definition_event_id, status, trigger_event_id, current_step, execution_trace, trigger_context) + VALUES ($1, $2, $3, $4, 'pending', $5, 0, '[]', $6) + "#, + ) + .bind(community_id.as_uuid()) + .bind(id) + .bind(workflow_id) + .bind(definition_event_id) + .bind(trigger_event_id) + .bind(trigger_context) + .execute(&mut **tx) + .await?; + Ok(id) +} + /// Insert a new workflow run. Returns the new run's UUID. /// /// `trigger_context` is the serialized `TriggerContext` for this run. It is stored @@ -851,6 +906,24 @@ pub async fn create_workflow_run( Ok(id) } +/// Find the committed result of a manual trigger within its tenant and workflow. +/// The command event and run commit atomically, so a retry can recover this ID. +pub async fn get_workflow_run_id_by_trigger( + pool: &PgPool, + community_id: CommunityId, + workflow_id: Uuid, + trigger_event_id: &[u8], +) -> Result> { + Ok(sqlx::query_scalar( + "SELECT id FROM workflow_runs WHERE community_id = $1 AND workflow_id = $2 AND trigger_event_id = $3", + ) + .bind(community_id.as_uuid()) + .bind(workflow_id) + .bind(trigger_event_id) + .fetch_optional(pool) + .await?) +} + /// Fetch a single workflow run by ID, scoped to its community. pub async fn get_workflow_run( pool: &PgPool, @@ -1297,6 +1370,56 @@ pub async fn find_by_owner_and_name( // -- Run and approval Db API -------------------------------------------------- impl Db { + /// Recover the committed run ID for an exact manual-trigger retry. + #[datastore_span(name = "get_workflow_run_id_by_trigger", system = "postgresql")] + pub async fn get_workflow_run_id_by_trigger( + &self, + community_id: CommunityId, + workflow_id: Uuid, + trigger_event_id: &[u8], + ) -> Result> { + crate::workflow::get_workflow_run_id_by_trigger( + &self.pool, + community_id, + workflow_id, + trigger_event_id, + ) + .await + } + + /// Fetch and share-lock one workflow on an existing transaction. + #[datastore_span(name = "get_workflow_for_share_in_transaction", system = "postgresql")] + pub async fn get_workflow_for_share_in_transaction( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + id: Uuid, + ) -> Result { + crate::workflow::get_workflow_for_share_in_transaction(tx, community_id, id).await + } + + /// Create an exact-revision workflow run on an existing transaction. + #[datastore_span(name = "create_workflow_run_in_transaction", system = "postgresql")] + pub async fn create_workflow_run_in_transaction( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + workflow_id: Uuid, + definition_event_id: &[u8], + trigger_event_id: Option<&[u8]>, + trigger_context: Option<&serde_json::Value>, + ) -> Result { + crate::workflow::create_workflow_run_in_transaction( + tx, + community_id, + workflow_id, + definition_event_id, + trigger_event_id, + trigger_context, + ) + .await + } + /// Create a new workflow run. #[datastore_span(name = "create_workflow_run", system = "postgresql")] pub async fn create_workflow_run( diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index c7fa09bebd0..10ebdfd93a9 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -46,6 +46,7 @@ async fn authorize_workflow_read( path: &str, raw_query: Option<&str>, workflow_id: Uuid, + allow_immutable_owner: bool, ) -> Result)> { let raw_host = headers .get(axum::http::header::HOST) @@ -99,15 +100,57 @@ async fn authorize_workflow_read( .await .map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?; if !accessible.contains(&channel_id) { - return Err(api_error( - StatusCode::FORBIDDEN, - "workflow is not accessible", - )); + let controls = allow_immutable_owner + && (workflow.owner_pubkey == pubkey_bytes + || state + .db + .is_agent_owner(tenant.community(), &workflow.owner_pubkey, &pubkey_bytes) + .await + .map_err(|error| internal_error(&format!("workflow owner lookup: {error}")))?); + if !controls { + return Err(api_error( + StatusCode::FORBIDDEN, + "workflow is not accessible", + )); + } } Ok(tenant) } +/// `GET /workflows/{workflow_id}/revision` — current signed revision for an +/// authorized channel reader or the managed agent's immutable human owner. +/// +/// This narrow endpoint does not grant channel visibility. It returns only the +/// revision event ID needed to construct a revision-bound manual trigger; the +/// signed definition remains subject to normal channel-read authorization. +pub async fn workflow_revision( + State(state): State>, + Path(workflow_id): Path, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let path = format!("/workflows/{workflow_id}/revision"); + let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id, true).await?; + let workflow = state + .db + .get_workflow(tenant.community(), workflow_id) + .await + .map_err(|_| api_error(StatusCode::NOT_FOUND, "workflow not found"))?; + let revision = workflow.definition_event_id.as_deref().ok_or_else(|| { + api_error( + StatusCode::CONFLICT, + "owner-signed workflow revision is unavailable", + ) + })?; + let event = state + .db + .get_event_by_id(tenant.community(), revision) + .await + .map_err(|error| internal_error(&format!("get workflow revision: {error}")))? + .ok_or_else(|| api_error(StatusCode::CONFLICT, "workflow revision is unavailable"))?; + Ok(Json(serde_json::json!({ "id": event.event.id.to_hex() }))) +} + /// `GET /workflows/{workflow_id}/runs` — one authorized, keyset-paginated page. pub async fn workflow_runs( State(state): State>, @@ -131,8 +174,15 @@ pub async fn workflow_runs( } let path = format!("/workflows/{workflow_id}/runs"); - let tenant = - authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?; + let tenant = authorize_workflow_read( + &state, + &headers, + &path, + raw_query.as_deref(), + workflow_id, + false, + ) + .await?; let mut rows = state .db .list_workflow_runs_page( @@ -171,7 +221,7 @@ pub async fn run_approvals( headers: HeaderMap, ) -> Result, (StatusCode, Json)> { let path = format!("/workflows/{workflow_id}/runs/{run_id}/approvals"); - let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id).await?; + let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id, false).await?; let run = state .db @@ -229,9 +279,75 @@ fn approval_json(approval: &buzz_db::workflow::ApprovalRecord) -> Value { } #[cfg(test)] -mod tests { +mod postgres_tests { + use std::sync::Arc; + + use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, + }; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use sha2::Digest; + use tower::ServiceExt; + use super::*; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn workflow_test_state(host: &str) -> Arc { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let mut config = crate::config::Config::from_env().expect("config from env"); + let test_git_directory = AppState::workflow_test_git_directory(&mut config); + config.database_url = database_url.clone(); + config.redis_url = redis_url.clone(); + config.relay_url = format!("wss://{host}"); + config.require_auth_token = false; + config.require_relay_membership = false; + + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("connect workflow API test database"); + // The harness prepares the schema (CI uses pgschema, not SQLx history). + let db = buzz_db::Db::from_pool(pool.clone()); + db.ensure_configured_community(host) + .await + .expect("create workflow API test community"); + let redis_pool = deadpool_redis::Config::from_url(&redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool config"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.test_git_directory = Some(test_git_directory); + Arc::new(state) + } + #[test] fn request_path_preserves_signed_query_verbatim() { assert_eq!( @@ -263,4 +379,138 @@ mod tests { assert!(wire.get("token").is_none()); assert_eq!(wire["approval_ref"], hex::encode([0xab; 32])); } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn revoked_immutable_owner_receives_only_revision_id() { + use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; + + let host = format!("workflow-revision-{}.example", Uuid::new_v4().simple()); + let state = workflow_test_state(&host).await; + let tenant = state + .db + .ensure_configured_community(&host) + .await + .expect("load workflow API test community"); + let community = tenant.id; + let owner = Keys::generate(); + let agent = Keys::generate(); + let owner_bytes = owner.public_key().to_bytes(); + let agent_bytes = agent.public_key().to_bytes(); + state + .db + .ensure_user(community, &owner_bytes) + .await + .expect("ensure immutable owner"); + state + .db + .ensure_user(community, &agent_bytes) + .await + .expect("ensure managed agent"); + assert!(state + .db + .set_agent_owner(community, &agent_bytes, &owner_bytes) + .await + .expect("set immutable owner")); + let channel = state + .db + .create_channel( + community, + "revision-secret-boundary", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &agent_bytes, + None, + ) + .await + .expect("create workflow channel"); + state + .db + .add_member( + community, + channel.id, + &owner_bytes, + MemberRole::Member, + Some(&agent_bytes), + ) + .await + .expect("add immutable owner to workflow channel"); + + let workflow_id = Uuid::new_v4(); + let secret = format!("secret-{}", Uuid::new_v4().simple()); + let yaml = format!( + "name: guarded\ntrigger:\n on: webhook\nsteps:\n - id: call\n action: call_webhook\n url: https://example.invalid\n headers:\n Authorization: Bearer {secret}\n body: '{secret}'\n" + ); + let revision = EventBuilder::new(Kind::Custom(30620), &yaml) + .tags(vec![ + Tag::parse(["d", workflow_id.to_string().as_str()]).expect("d tag"), + Tag::parse(["h", channel.id.to_string().as_str()]).expect("h tag"), + ]) + .sign_with_keys(&agent) + .expect("sign secret-bearing workflow revision"); + let (_, definition_json) = + buzz_workflow::WorkflowEngine::parse_yaml(&yaml).expect("parse workflow YAML"); + let definition_hash = sha2::Sha256::digest(definition_json.as_bytes()); + let mut tx = state + .db + .begin_event_write_transaction() + .await + .expect("begin workflow seed"); + buzz_db::event::insert_event_in_transaction( + &mut tx, + community, + &revision, + Some(channel.id), + ) + .await + .expect("persist signed workflow revision"); + state + .db + .upsert_workflow( + &mut tx, + community, + workflow_id, + Some(channel.id), + &agent_bytes, + "guarded", + &definition_json, + definition_hash.as_slice(), + revision.id.as_bytes(), + ) + .await + .expect("materialize workflow revision"); + tx.commit().await.expect("commit workflow seed"); + state + .db + .remove_member(community, channel.id, &owner_bytes, &agent_bytes) + .await + .expect("revoke immutable owner's channel access"); + let tenant_context = TenantContext::resolved(community, host.clone()); + state.invalidate_membership(&tenant_context, channel.id, &owner_bytes); + + let path = format!("/workflows/{workflow_id}/revision"); + let response = crate::router::build_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .method("GET") + .uri(&path) + .header(header::HOST, &host) + .header("x-pubkey", owner.public_key().to_hex()) + .body(Body::empty()) + .expect("workflow revision request"), + ) + .await + .expect("workflow revision response"); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("read workflow revision response"); + let body: Value = serde_json::from_slice(&bytes).expect("revision response JSON"); + assert_eq!(body, serde_json::json!({ "id": revision.id.to_hex() })); + let serialized = String::from_utf8(bytes.to_vec()).expect("UTF-8 response"); + assert!(!serialized.contains(&secret)); + assert!(body.get("content").is_none()); + assert!(body.get("tags").is_none()); + } } diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 48675e9019a..dc25cda3f9d 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -5,7 +5,7 @@ //! //! SECURITY: This module is only reachable AFTER the ingest pipeline has verified: //! 1. Event signature (verify_event) -//! 2. Timestamp freshness (±15 min) +//! 2. Timestamp freshness (±15 min), except read-only manual recovery //! 3. Pubkey/auth identity match //! 4. Per-kind scope authorization @@ -92,11 +92,11 @@ enum PersistResult { /// If the event is a duplicate (ON CONFLICT DO NOTHING), the transaction is /// rolled back and `PersistResult::Duplicate` is returned — no mutations needed. /// -/// Most domain mutations still execute on the connection pool rather than this -/// transaction, so their handlers rely on idempotency if event commit fails. -/// Workflow definition ingest is the exception: it materializes the workflow -/// revision on this same transaction so the signed event and revision pointer -/// commit or roll back together. +/// NOTE: Most domain mutations still execute on the connection pool rather +/// than in this transaction. Workflow-definition ingestion is the exception: +/// its materialized workflow row and exact signed revision are written through +/// this transaction so the event and revision binding commit atomically. +/// Other operations remain idempotent but not strictly atomic. #[datastore_span(name = "persist_command_event", system = "postgresql")] async fn persist_command_event( db: &buzz_db::Db, @@ -736,7 +736,8 @@ async fn handle_workflow_def( .map_err(|e| IngestError::Internal(format!("error: json serialize: {e}")))?; let hash = compute_definition_hash(&definition_json_final); - // Persist the signed definition and materialized revision atomically. + // Persist the command event — returns the transaction that will also own + // the materialized workflow revision update. let mut tx = match persist_command_event(&state.db, tenant, event, Some(channel_id)).await? { PersistResult::Duplicate => { return Ok(IngestResult { @@ -787,11 +788,14 @@ async fn handle_workflow_def( other => IngestError::Internal(format!("error: db upsert_workflow: {other}")), })?; - // Commit before cache invalidation so a concurrent refill can observe the new revision. + // Commit the event transaction after the idempotent workflow upsert succeeds. tx.commit() .await .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; + // Invalidate only after commit. Invalidating while the new row is still + // invisible lets a concurrent trigger refill the cache with the old + // definition and retain it until TTL expiry. state .workflow_engine .invalidate_channel_workflows(community_id, channel_id); @@ -811,21 +815,159 @@ async fn handle_workflow_def( }) } +async fn caller_controls_workflow( + state: &Arc, + community_id: CommunityId, + workflow_owner: &[u8], + caller: &[u8], +) -> Result { + if workflow_owner == caller { + return Ok(true); + } + + state + .db + .is_agent_owner(community_id, workflow_owner, caller) + .await + .map_err(|e| IngestError::Internal(format!("error: workflow owner check: {e}"))) +} + +fn exact_tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { + let mut values = event.tags.iter().filter_map(|tag| { + (tag.kind().to_string() == name) + .then(|| tag.content()) + .flatten() + }); + let value = values.next()?; + values.next().is_none().then_some(value) +} + +async fn verify_workflow_revision( + state: &Arc, + mut tx: Option<&mut sqlx::Transaction<'_, sqlx::Postgres>>, + community_id: CommunityId, + workflow: &buzz_db::workflow::WorkflowRecord, + requested_revision: &[u8], +) -> Result<(), IngestError> { + let Some(persisted_revision) = workflow.definition_event_id.as_deref() else { + return Err(IngestError::Rejected( + "invalid: owner-signed workflow revision is unavailable".into(), + )); + }; + if persisted_revision != requested_revision { + return Err(IngestError::Rejected( + "conflict: workflow revision does not match current definition".into(), + )); + } + + let stored = match tx.as_mut() { + Some(tx) => { + state + .db + .get_event_by_id_in_transaction(tx, community_id, persisted_revision) + .await + } + None => { + state + .db + .get_event_by_id(community_id, persisted_revision) + .await + } + } + .map_err(|e| IngestError::Internal(format!("error: workflow revision lookup: {e}")))? + .ok_or_else(|| IngestError::Rejected("invalid: signed workflow revision not found".into()))?; + let definition_event = &stored.event; + let workflow_id = workflow.id.to_string(); + let workflow_channel_id = workflow.channel_id.map(|id| id.to_string()); + if definition_event.id.as_bytes() != persisted_revision + || !definition_event.verify_id() + || !definition_event.verify_signature() + || definition_event.kind.as_u16() as u32 != KIND_WORKFLOW_DEF + || definition_event.pubkey.to_bytes().as_slice() != workflow.owner_pubkey + || exact_tag_value(definition_event, "d") != Some(workflow_id.as_str()) + || workflow_channel_id.is_none() + || exact_tag_value(definition_event, "h") != workflow_channel_id.as_deref() + || stored.channel_id != workflow.channel_id + { + return Err(IngestError::Rejected( + "invalid: signed workflow revision binding mismatch".into(), + )); + } + + let (_, signed_json) = buzz_workflow::WorkflowEngine::parse_yaml(&definition_event.content) + .map_err(|_| { + IngestError::Rejected("invalid: signed workflow revision is malformed".into()) + })?; + let signed_definition: serde_json::Value = + serde_json::from_str(&signed_json).map_err(|_| { + IngestError::Rejected("invalid: signed workflow revision is malformed".into()) + })?; + if signed_definition != webhook_secret::strip_secret(&workflow.definition) { + return Err(IngestError::Rejected( + "invalid: signed workflow revision differs from materialized definition".into(), + )); + } + Ok(()) +} + async fn handle_workflow_trigger( tenant: &TenantContext, state: &Arc, event: &Event, auth: &IngestAuth, +) -> Result { + workflow_trigger(tenant, state, event, auth, TriggerAdmission::Fresh).await +} + +/// Recover only an already committed manual operation after ingest authentication. +/// This path cannot insert an event/run or spawn execution, even on a lookup miss. +pub(super) async fn recover_workflow_trigger( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + tracer: &Arc, +) -> Result { + workflow_trigger( + tenant, + state, + event, + auth, + TriggerAdmission::CommittedOnly(tracer), + ) + .await +} + +#[derive(Clone, Copy)] +enum TriggerAdmission<'a> { + Fresh, + CommittedOnly(&'a Arc), +} + +async fn workflow_trigger( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + admission: TriggerAdmission<'_>, ) -> Result { let self_bytes = auth.pubkey().to_bytes().to_vec(); - // 1. Extract workflow reference from `d` tag or `e` tag - let workflow_id_str = extract_d_tag(event) - .or_else(|| extract_e_tag(event)) - .ok_or_else(|| { - IngestError::Rejected("invalid: missing workflow reference (d or e tag)".into()) - })?; - let workflow_id = Uuid::parse_str(&workflow_id_str) + // 1. Bind the command to both the workflow UUID and one exact signed revision. + let workflow_id_str = exact_tag_value(event, "d").ok_or_else(|| { + IngestError::Rejected("invalid: expected exactly one workflow d tag".into()) + })?; + let revision_hex = exact_tag_value(event, "e").ok_or_else(|| { + IngestError::Rejected("invalid: expected exactly one workflow revision e tag".into()) + })?; + let requested_revision = hex::decode(revision_hex) + .map_err(|_| IngestError::Rejected("invalid: bad workflow revision event id".into()))?; + if requested_revision.len() != 32 { + return Err(IngestError::Rejected( + "invalid: bad workflow revision event id".into(), + )); + } + let workflow_id = Uuid::parse_str(workflow_id_str) .map_err(|_| IngestError::Rejected("invalid: bad workflow_id format".into()))?; // 2. Validate workflow exists — scoped to the caller's community. The same @@ -839,14 +981,20 @@ async fn handle_workflow_trigger( .await .map_err(|_| IngestError::Rejected("invalid: workflow not found".into()))?; - // 3. Manual triggers execute with the workflow owner's authority, so only - // the owner may start them. Channel membership alone is insufficient: a - // member could otherwise invoke another user's webhook or message actions. - if workflow.owner_pubkey != self_bytes { + // 3. Manual triggers execute with the workflow owner's authority. Permit + // that principal and, for a managed agent, its immutable human owner. + // Channel membership alone remains insufficient. + if !caller_controls_workflow(state, community_id, &workflow.owner_pubkey, &self_bytes).await? { return Err(IngestError::Rejected( "forbidden: not authorized to trigger this workflow".into(), )); } + // Managed-agent ownership is immutable. Carry the authorized workflow + // principal across the transaction boundary so no pool-backed ownership + // lookup is attempted while the command transaction holds its connection. + let authorized_workflow_owner = workflow.owner_pubkey.clone(); + + verify_workflow_revision(state, None, community_id, &workflow, &requested_revision).await?; // SEC-006: manual triggers must honor the workflow's lifecycle state and // recheck the owner's *current* channel authority before creating a run. @@ -873,20 +1021,86 @@ async fn handle_workflow_trigger( IngestError::Rejected("forbidden: not authorized to trigger this workflow".into()) })?; + if let TriggerAdmission::CommittedOnly(tracer) = admission { + let response = + committed_workflow_trigger_response(state, community_id, &workflow, event).await?; + // This early ingest return acknowledges existing work; it is neither + // fresh admission nor an untraced exit from the conformance seam. + use crate::conformance as conf; + conf::emit( + tracer, + conf::TraceAction::WriteDuplicate { + msg_id: conf::msg_id_label(event.id.as_bytes()), + channel: conf::channel_label(wf_channel_id), + claimed_community: conf::claimed_community_from_event(event), + }, + conf::state_for_request(tenant, auth.pubkey()), + ); + return Ok(response); + } + // Persist the command event under the workflow channel even though the // trigger event itself only carries the workflow UUID. Storing channel // triggers as global events leaks workflow IDs to unrelated relay members. - let tx = match persist_command_event(&state.db, tenant, event, workflow.channel_id).await? { + let mut tx = match persist_command_event(&state.db, tenant, event, workflow.channel_id).await? { PersistResult::Duplicate => { - return Ok(IngestResult { - event_id: event.id.to_hex(), - accepted: true, - message: "duplicate: already processed".into(), - }); + return committed_workflow_trigger_response(state, community_id, &workflow, event) + .await; } PersistResult::Inserted(tx) => tx, }; + // Serialize the final authority check and run commit with channel + // membership writers. If revocation commits first we observe no role; if + // this lock wins, revocation cannot commit until this run is durable. + buzz_db::channel::acquire_channel_membership_lock(&mut tx, community_id, wf_channel_id) + .await + .map_err(|e| IngestError::Internal(format!("error: membership lock: {e}")))?; + + // Re-read the workflow under a row lock on the same transaction that will + // commit the trigger event and run. Definition replacement updates this row, + // so it cannot commit between this exact-revision check and our commit. A + // replacement that won first is observed here and rejected as stale. + let workflow = state + .db + .get_workflow_for_share_in_transaction(&mut tx, community_id, workflow_id) + .await + .map_err(|_| IngestError::Rejected("invalid: workflow not found".into()))?; + if workflow.owner_pubkey != authorized_workflow_owner { + return Err(IngestError::Rejected( + "conflict: workflow owner changed while trigger was being processed".into(), + )); + } + verify_workflow_revision( + state, + Some(&mut tx), + community_id, + &workflow, + &requested_revision, + ) + .await?; + if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active { + return Err(IngestError::Rejected( + "forbidden: workflow is disabled or inactive".into(), + )); + } + let role = buzz_db::channel::get_member_role_in_transaction( + &mut tx, + community_id, + wf_channel_id, + &workflow.owner_pubkey, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: owner authority lookup: {e}")))?; + if !matches!( + (role.as_deref(), def.requires_elevated_authority()), + (Some(_), false) | (Some("owner" | "admin"), true) + ) { + return Err(IngestError::Rejected( + "forbidden: not authorized to trigger this workflow".into(), + )); + } + // 4. Execute: create workflow run let mut trigger_ctx = TriggerContext { channel_id: workflow @@ -912,10 +1126,11 @@ async fn handle_workflow_trigger( let event_id_bytes = event.id.as_bytes().to_vec(); let run_id = state .db - .create_workflow_run( + .create_workflow_run_in_transaction( + &mut tx, community_id, workflow_id, - workflow.definition_event_id.as_deref(), + &requested_revision, Some(&event_id_bytes), trigger_ctx_json.as_ref(), ) @@ -929,51 +1144,72 @@ async fn handle_workflow_trigger( // 5. Spawn workflow execution let engine = Arc::clone(&state.workflow_engine); - let db = state.db.clone(); - let def_value = workflow.definition.clone(); let trigger_ctx_clone = trigger_ctx.clone(); tokio::spawn(async move { - let def: buzz_workflow::WorkflowDef = match serde_json::from_value(def_value) { - Ok(d) => d, - Err(e) => { - tracing::error!("workflow_trigger: failed to parse definition: {e}"); - if let Err(db_err) = db - .update_workflow_run( - community_id, - run_id, - RunStatus::Failed, - 0, - &serde_json::json!([]), - Some(buzz_db::workflow::WorkflowRunFailure { - code: "invalid_definition", - message: &format!("definition parse error: {e}"), - }), - ) - .await - { - tracing::error!("workflow_trigger: failed to mark run as failed: {db_err}"); - } - return; + let result = match engine.load_run_definition(community_id, run_id).await { + Ok((_, definition)) => { + buzz_workflow::executor::execute_from_step( + &engine, + community_id, + run_id, + &definition, + &trigger_ctx_clone, + 0, + None, + ) + .await } + Err(error) => Err((error, buzz_workflow::error::PartialProgress::default())), }; - - let result = buzz_workflow::executor::execute_from_step( - &engine, - community_id, - run_id, - &def, - &trigger_ctx_clone, - 0, - None, - ) - .await; engine .finalize_run(community_id, run_id, result, None) .await; }); - // 6. Return response - Ok(IngestResult { + // 6. Return the same result shape for initial delivery and exact replay. + Ok(workflow_trigger_response(event, run_id)) +} + +// One recovery authority for fresh duplicates and expired retries. Reads use +// the primary, never content equivalence or a client-provided run ID. A retained +// event without its run (legacy/retention gap) is not permission to execute it. +async fn committed_workflow_trigger_response( + state: &AppState, + community_id: CommunityId, + workflow: &buzz_db::workflow::WorkflowRecord, + event: &Event, +) -> Result { + let stored = state + .db + .get_event_by_id(community_id, event.id.as_bytes()) + .await + .map_err(|e| IngestError::Internal(format!("error: recover workflow trigger: {e}")))? + .ok_or_else(|| { + IngestError::Rejected( + "invalid: no retained committed workflow trigger; stale events cannot start work" + .into(), + ) + })?; + if stored.event.id != event.id + || !stored.event.verify_id() + || !stored.event.verify_signature() + || stored.channel_id != workflow.channel_id + { + return Err(IngestError::Rejected( + "invalid: stored workflow trigger binding mismatch".into(), + )); + } + let run_id = state + .db + .get_workflow_run_id_by_trigger(community_id, workflow.id, event.id.as_bytes()) + .await + .map_err(|e| IngestError::Internal(format!("error: recover workflow run: {e}")))? + .ok_or_else(|| IngestError::Internal("error: stored workflow trigger has no run".into()))?; + Ok(workflow_trigger_response(event, run_id)) +} + +fn workflow_trigger_response(event: &Event, run_id: Uuid) -> IngestResult { + IngestResult { event_id: event.id.to_hex(), accepted: true, message: format!( @@ -982,7 +1218,7 @@ async fn handle_workflow_trigger( "run_id": run_id.to_string(), }) ), - }) + } } /// Enforce the approver_spec field against the requesting pubkey. @@ -1295,19 +1531,12 @@ async fn resume_workflow_after_approval( return; } - let workflow = match db.get_workflow(community_id, workflow_id).await { - Ok(w) => w, - Err(e) => { - tracing::error!("resume_workflow: failed to fetch workflow {workflow_id}: {e}"); - return; - } - }; - - let def: buzz_workflow::WorkflowDef = match serde_json::from_value(workflow.definition.clone()) - { - Ok(d) => d, + let (run, def) = match engine.load_run_definition(community_id, run_id).await { + Ok(loaded) => loaded, Err(e) => { - tracing::error!("resume_workflow: failed to parse workflow definition: {e}"); + tracing::error!( + "resume_workflow: failed to load signed definition for run {run_id}: {e}" + ); if let Err(db_err) = db .update_workflow_run( community_id, @@ -1317,7 +1546,7 @@ async fn resume_workflow_after_approval( &run.execution_trace, Some(buzz_db::workflow::WorkflowRunFailure { code: "invalid_definition", - message: &format!("definition parse error: {e}"), + message: &format!("signed run definition unavailable: {e}"), }), ) .await @@ -1328,6 +1557,30 @@ async fn resume_workflow_after_approval( } }; + if run.workflow_id != workflow_id { + tracing::error!( + "resume_workflow: approval workflow {workflow_id} does not match run workflow {}", + run.workflow_id + ); + if let Err(e) = db + .update_workflow_run( + community_id, + run_id, + RunStatus::Failed, + run.current_step, + &run.execution_trace, + Some(buzz_db::workflow::WorkflowRunFailure { + code: "approval_binding_mismatch", + message: "approval does not belong to the workflow run", + }), + ) + .await + { + tracing::error!("resume_workflow: failed to mark mismatched run as failed: {e}"); + } + return; + } + // Reconstruct step_outputs from execution trace for template resolution let mut initial_outputs: std::collections::HashMap = std::collections::HashMap::new(); @@ -1371,13 +1624,46 @@ mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + #[derive(Default)] + struct RecordingActionSink { + messages: std::sync::Mutex>, + } + + impl buzz_workflow::ActionSink for RecordingActionSink { + fn send_message( + &self, + _community_id: CommunityId, + _channel_id: &str, + text: &str, + _authored_text: &str, + _author_pubkey: &str, + _reply_to: Option<&str>, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + self.messages + .lock() + .expect("recording action sink lock") + .push(text.to_string()); + Box::pin(async { Ok("recorded-event".to_string()) }) + } + } + async fn persistence_test_context() -> (buzz_db::Db, TenantContext) { let url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 -- local test-only credentials - let pool = sqlx::PgPool::connect(&url) + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(&url) .await .expect("connect workflow persistence test database"); + // The harness prepares the schema (CI uses pgschema, not SQLx history). let db = buzz_db::Db::from_pool(pool); if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { db.migrate() @@ -1393,6 +1679,202 @@ mod postgres_tests { (db, TenantContext::resolved(community, host)) } + async fn manual_trigger_test_context() -> (Arc, TenantContext, Keys, Keys, Uuid, Event) + { + manual_trigger_test_context_with_yaml(concat!( + "name: manual-trigger-pool\n", + "trigger:\n on: message_posted\n", + "steps:\n", + " - id: approval\n action: request_approval\n from: '@owner'\n message: approve\n", + " - id: send\n action: send_message\n text: done\n", + )).await + } + + async fn manual_trigger_test_context_with_yaml( + yaml: &str, + ) -> (Arc, TenantContext, Keys, Keys, Uuid, Event) { + use buzz_core::channel::{ChannelType, ChannelVisibility}; + + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let setup_pool = sqlx::PgPool::connect(&url) + .await + .expect("connect workflow trigger setup database"); + // The harness prepares the schema (CI uses pgschema, not SQLx history). + let setup_db = buzz_db::Db::from_pool(setup_pool.clone()); + + let host = format!("workflow-trigger-{}.example", Uuid::new_v4().simple()); + let community = setup_db + .ensure_configured_community(&host) + .await + .expect("create workflow trigger test community") + .id; + let tenant = TenantContext::resolved(community, host.clone()); + let human = Keys::generate(); + let agent = Keys::generate(); + let human_bytes = human.public_key().to_bytes(); + let agent_bytes = agent.public_key().to_bytes(); + setup_db + .ensure_user(community, &human_bytes) + .await + .expect("ensure human owner"); + setup_db + .ensure_user(community, &agent_bytes) + .await + .expect("ensure managed agent"); + assert!(setup_db + .set_agent_owner(community, &agent_bytes, &human_bytes) + .await + .expect("set immutable agent owner")); + let channel = setup_db + .create_channel( + community, + "manual-trigger-pool", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &agent_bytes, + None, + ) + .await + .expect("create workflow channel"); + let workflow_id = Uuid::new_v4(); + let definition = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml) + .tags(vec![ + Tag::parse(["d", workflow_id.to_string().as_str()]).expect("d tag"), + Tag::parse(["h", channel.id.to_string().as_str()]).expect("h tag"), + ]) + .sign_with_keys(&agent) + .expect("sign workflow definition"); + let (_, definition_json) = buzz_workflow::WorkflowEngine::parse_yaml(&definition.content) + .expect("parse signed workflow definition"); + let definition_hash = compute_definition_hash(&definition_json); + let mut tx = setup_db + .begin_event_write_transaction() + .await + .expect("begin workflow seed"); + buzz_db::event::insert_event_in_transaction( + &mut tx, + community, + &definition, + Some(channel.id), + ) + .await + .expect("persist signed workflow definition"); + setup_db + .upsert_workflow( + &mut tx, + community, + workflow_id, + Some(channel.id), + &agent_bytes, + "manual-trigger-pool", + &definition_json, + &definition_hash, + definition.id.as_bytes(), + ) + .await + .expect("materialize signed workflow"); + tx.commit().await.expect("commit signed workflow"); + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(&url) + .await + .expect("connect one-connection workflow trigger pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let mut config = crate::config::Config::from_env().expect("config from env"); + let test_git_directory = AppState::workflow_test_git_directory(&mut config); + config.database_url = url; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_url = format!("wss://{host}"); + config.require_relay_membership = false; + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool config"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.test_git_directory = Some(test_git_directory); + setup_pool.close().await; + ( + Arc::new(state), + tenant, + human, + agent, + workflow_id, + definition, + ) + } + + fn workflow_trigger_event_for_revision( + keys: &Keys, + workflow_id: Uuid, + revision: &str, + ) -> Event { + workflow_trigger_event_at( + keys, + workflow_id, + revision, + Timestamp::now().as_secs(), + &Uuid::new_v4().to_string(), + ) + } + + fn workflow_trigger_event_at( + keys: &Keys, + workflow_id: Uuid, + revision: &str, + created_at: u64, + request_id: &str, + ) -> Event { + EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), "") + .tags(vec![ + Tag::parse(["d", workflow_id.to_string().as_str()]).expect("d tag"), + Tag::parse(["e", revision]).expect("revision tag"), + Tag::parse(["request-id", request_id]).expect("request identity"), + ]) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .expect("sign workflow trigger") + } + + fn workflow_trigger_event(keys: &Keys, workflow_id: Uuid, revision: &Event) -> Event { + workflow_trigger_event_for_revision(keys, workflow_id, &revision.id.to_hex()) + } + + fn http_auth(keys: &Keys) -> IngestAuth { + IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![buzz_auth::Scope::MessagesWrite], + auth_method: super::super::ingest::HttpAuthMethod::Nip98, + } + } + fn workflow_event( keys: &Keys, workflow_id: Uuid, @@ -1428,6 +1910,284 @@ mod postgres_tests { } } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn human_owner_manual_trigger_completes_with_one_connection() { + let (state, tenant, human, agent, workflow_id, revision) = + manual_trigger_test_context().await; + + let trigger = workflow_trigger_event(&human, workflow_id, &revision); + let result = tokio::time::timeout( + std::time::Duration::from_secs(3), + handle_workflow_trigger(&tenant, &state, &trigger, &http_auth(&human)), + ) + .await + .expect("human-owner trigger must not wait for a second pool connection") + .expect("human-owner trigger must succeed"); + let run_id = Uuid::parse_str( + serde_json::from_str::( + result + .message + .strip_prefix("response:") + .expect("workflow trigger response prefix"), + ) + .expect("workflow trigger response JSON")["run_id"] + .as_str() + .expect("workflow trigger run id"), + ) + .expect("workflow trigger run UUID"); + let (_, loaded_definition) = state + .workflow_engine + .load_run_definition(tenant.community(), run_id) + .await + .expect("manual execution must load its exact signed revision"); + assert_eq!(loaded_definition.name, "manual-trigger-pool"); + + let agent_trigger = workflow_trigger_event(&agent, workflow_id, &revision); + let agent_result = + handle_workflow_trigger(&tenant, &state, &agent_trigger, &http_auth(&agent)) + .await + .expect("workflow principal must be able to trigger its own workflow"); + assert!(agent_result.message.contains("\"run_id\"")); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn approval_resume_rejects_replaced_run_revision() { + assert_approval_revision(Some(true)).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn approval_resume_rejects_deleted_run_revision() { + assert_approval_revision(Some(false)).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn approval_resume_accepts_unchanged_run_revision() { + assert_approval_revision(None).await; + } + + async fn assert_approval_revision(change: Option) { + let (state, tenant, _human, agent, workflow_id, revision_a) = + manual_trigger_test_context().await; + let community_id = tenant.community(); + let db = state.db.clone(); + let trigger_context = serde_json::to_value(TriggerContext { + channel_id: exact_tag_value(&revision_a, "h") + .unwrap_or_default() + .to_string(), + ..TriggerContext::default() + }) + .expect("serialize trigger context"); + let run_id = db + .create_workflow_run( + community_id, + workflow_id, + Some(revision_a.id.as_bytes()), + None, + Some(&trigger_context), + ) + .await + .expect("create revision A run"); + db.update_workflow_run( + community_id, + run_id, + RunStatus::WaitingApproval, + 0, + &serde_json::json!([{ + "step_id": "approval", + "output": {"approved": true} + }]), + None, + ) + .await + .expect("suspend revision A run for approval"); + + if change == Some(true) { + let channel_id = + Uuid::parse_str(exact_tag_value(&revision_a, "h").expect("channel tag")) + .expect("channel UUID"); + let revision_b = EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + concat!( + "name: revision-b\n", + "trigger:\n on: message_posted\n", + "steps:\n", + " - id: approval\n action: request_approval\n from: '@owner'\n message: approve\n", + " - id: after\n action: send_message\n text: revision B\n", + ), + ) + .tags(vec![ + Tag::parse(["d", workflow_id.to_string().as_str()]).expect("d tag"), + Tag::parse(["h", channel_id.to_string().as_str()]).expect("h tag"), + ]) + .custom_created_at(nostr::Timestamp::from(revision_a.created_at.as_secs() + 1)) + .sign_with_keys(&agent) + .expect("sign revision B"); + let (_, definition_b_json) = + buzz_workflow::WorkflowEngine::parse_yaml(&revision_b.content) + .expect("parse revision B"); + let definition_b_hash = compute_definition_hash(&definition_b_json); + let mut tx = db + .begin_event_write_transaction() + .await + .expect("begin revision B update"); + db.replace_parameterized_event_in_transaction( + &mut tx, + community_id, + &revision_b, + &workflow_id.to_string(), + Some(channel_id), + buzz_db::replaceable::ParameterizedReplacePrecondition::Unconditional, + ) + .await + .expect("persist revision B"); + db.upsert_workflow( + &mut tx, + community_id, + workflow_id, + Some(channel_id), + &agent.public_key().to_bytes(), + "revision-b", + &definition_b_json, + &definition_b_hash, + revision_b.id.as_bytes(), + ) + .await + .expect("materialize revision B"); + tx.commit().await.expect("commit revision B update"); + } else if change == Some(false) { + db.soft_delete_event(community_id, revision_a.id.as_bytes()) + .await + .expect("delete captured revision"); + } + + let sink = Arc::new(RecordingActionSink::default()); + state.workflow_engine.set_action_sink(sink.clone()); + resume_workflow_after_approval( + Arc::clone(&state.workflow_engine), + db.clone(), + community_id, + run_id, + workflow_id, + 1, + ) + .await; + + let resumed = db + .get_workflow_run(community_id, run_id) + .await + .expect("load resumed run"); + let messages = sink.messages.lock().expect("recorded messages lock"); + if change.is_some() { + assert_eq!(resumed.status, RunStatus::Failed); + assert!( + messages.is_empty(), + "stale approval must execute neither captured A nor current B" + ); + } else { + assert_eq!(resumed.status, RunStatus::Completed); + assert_eq!(messages.as_slice(), ["done"]); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn approval_resume_fails_closed_without_a_signed_run_revision() { + let (state, tenant, _human, _agent, workflow_id, _revision) = + manual_trigger_test_context().await; + let community_id = tenant.community(); + let db = state.db.clone(); + let run_id = db + .create_workflow_run(community_id, workflow_id, None, None, None) + .await + .expect("create legacy revisionless run"); + db.update_workflow_run( + community_id, + run_id, + RunStatus::WaitingApproval, + 0, + &serde_json::json!([]), + None, + ) + .await + .expect("suspend revisionless run"); + + resume_workflow_after_approval( + Arc::clone(&state.workflow_engine), + db.clone(), + community_id, + run_id, + workflow_id, + 1, + ) + .await; + + let failed = db + .get_workflow_run(community_id, run_id) + .await + .expect("load failed run"); + assert_eq!(failed.status, RunStatus::Failed); + assert_eq!(failed.error_code.as_deref(), Some("invalid_definition")); + assert!(failed + .error_message + .as_deref() + .is_some_and(|message| message.contains("no owner-signed definition revision"))); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn manual_trigger_rejects_non_owner_and_stale_or_missing_revision() { + let (state, tenant, human, _agent, workflow_id, revision) = + manual_trigger_test_context().await; + let stranger = Keys::generate(); + let unauthorized = workflow_trigger_event(&stranger, workflow_id, &revision); + let unauthorized_error = + match handle_workflow_trigger(&tenant, &state, &unauthorized, &http_auth(&stranger)) + .await + { + Err(error) => error, + Ok(_) => panic!("channel membership must not grant manual trigger authority"), + }; + assert!(matches!( + unauthorized_error, + IngestError::Rejected(ref message) + if message == "forbidden: not authorized to trigger this workflow" + )); + + let stale = workflow_trigger_event_for_revision(&human, workflow_id, &"42".repeat(32)); + let stale_error = + match handle_workflow_trigger(&tenant, &state, &stale, &http_auth(&human)).await { + Err(error) => error, + Ok(_) => panic!("a stale signed revision must be rejected"), + }; + assert!(matches!( + stale_error, + IngestError::Rejected(ref message) + if message == "conflict: workflow revision does not match current definition" + )); + + let missing = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), "") + .tag(Tag::parse(["d", workflow_id.to_string().as_str()]).expect("d tag")) + .sign_with_keys(&human) + .expect("sign revision-less trigger"); + let missing_error = + match handle_workflow_trigger(&tenant, &state, &missing, &http_auth(&human)).await { + Err(error) => error, + Ok(_) => panic!("a revision-less trigger must fail closed"), + }; + assert!(matches!( + missing_error, + IngestError::Rejected(ref message) + if message == "invalid: expected exactly one workflow revision e tag" + )); + } + + mod trigger_delivery; + mod trigger_ingest; + #[test] fn workflow_revision_parser_accepts_create_and_valid_update() { let revision = [0x42; 32]; @@ -1476,6 +2236,10 @@ mod postgres_tests { let workflow_id = Uuid::new_v4(); let created_at = Timestamp::now().as_secs(); let create = workflow_event(&keys, workflow_id, created_at, None, "create"); + let channel_id = Uuid::parse_str( + exact_tag_value(&create, "h").expect("workflow definition channel tag"), + ) + .expect("workflow definition channel UUID"); let missing_revision = hex::encode([0x24; 32]); let missing_revision_update = workflow_event( @@ -1496,15 +2260,22 @@ mod postgres_tests { if message == "conflict: workflow revision does not exist" )); - let PersistResult::Inserted(tx) = persist_command_event(&db, &tenant, &create, None) - .await - .expect("persist create") + let PersistResult::Inserted(tx) = + persist_command_event(&db, &tenant, &create, Some(channel_id)) + .await + .expect("persist create") else { panic!("first create must insert"); }; tx.commit().await.expect("commit create"); + let stored_create = db + .get_event_by_id(tenant.community(), create.id.as_bytes()) + .await + .expect("load persisted workflow definition") + .expect("persisted workflow definition"); + assert_eq!(stored_create.channel_id, Some(channel_id)); assert!(matches!( - persist_command_event(&db, &tenant, &create, None) + persist_command_event(&db, &tenant, &create, Some(channel_id)) .await .expect("replay create"), PersistResult::Duplicate diff --git a/crates/buzz-relay/src/handlers/command_executor/postgres_tests/trigger_delivery.rs b/crates/buzz-relay/src/handlers/command_executor/postgres_tests/trigger_delivery.rs new file mode 100644 index 00000000000..92e0d55b00d --- /dev/null +++ b/crates/buzz-relay/src/handlers/command_executor/postgres_tests/trigger_delivery.rs @@ -0,0 +1,210 @@ +// Keep out-of-line PostgreSQL cases structurally discoverable by the CI guard. +mod postgres_tests { + use super::super::*; + use std::collections::{HashMap, HashSet}; + + fn response_run_id(result: &IngestResult) -> Uuid { + assert!(result.accepted); + let response: serde_json::Value = serde_json::from_str( + result + .message + .strip_prefix("response:") + .expect("new run response"), + ) + .expect("response JSON"); + Uuid::parse_str(response["run_id"].as_str().expect("run ID")).expect("run UUID") + } + + async fn assert_persisted_trigger_runs( + state: &AppState, + tenant: &TenantContext, + workflow_id: Uuid, + revision: &Event, + triggers: &[Event], + run_ids: &HashSet, + ) { + let runs = state + .db + .list_workflow_runs(tenant.community(), workflow_id, 100) + .await + .expect("persisted runs"); + assert_eq!( + runs.len(), + triggers.len(), + "exactly one run per signed operation" + ); + assert_eq!( + run_ids.len(), + triggers.len(), + "distinct acknowledged run IDs" + ); + assert_eq!( + runs.iter().map(|run| run.id).collect::>(), + *run_ids + ); + let trigger_ids = triggers + .iter() + .map(|trigger| trigger.id.as_bytes().to_vec()) + .collect::>(); + assert_eq!( + trigger_ids.len(), + triggers.len(), + "fixture requests must be distinct" + ); + assert_eq!( + runs.iter() + .map(|run| run.trigger_event_id.clone().expect("trigger association")) + .collect::>(), + trigger_ids + ); + for run in runs { + assert_eq!( + run.definition_event_id.as_deref(), + Some(revision.id.as_bytes().as_slice()) + ); + } + for trigger in triggers { + assert_eq!( + state + .db + .get_workflow_run_id_by_trigger( + tenant.community(), + Uuid::new_v4(), + trigger.id.as_bytes(), + ) + .await + .expect("workflow-scoped lookup"), + None + ); + let stored = state + .db + .get_event_by_id(tenant.community(), trigger.id.as_bytes()) + .await + .expect("trigger remains stored"); + assert!( + stored.is_some(), + "later triggers must not replace earlier operations" + ); + } + } + + async fn deliver_and_replay(same_second: bool) { + let (state, tenant, human, _agent, workflow_id, revision) = + manual_trigger_test_context().await; + let now = Timestamp::now().as_secs(); + let mut triggers = vec![ + workflow_trigger_event_at(&human, workflow_id, &revision.id.to_hex(), now, "first"), + workflow_trigger_event_at( + &human, + workflow_id, + &revision.id.to_hex(), + if same_second { now } else { now - 1 }, + "second", + ), + ]; + if same_second { + // A lower ID wins a NIP-33 timestamp tie. Deliver it first so accidental + // coordinate replacement would incorrectly suppress the second request. + triggers.sort_by_key(|event| event.id); + } + assert_ne!(triggers[0].id, triggers[1].id); + assert_eq!( + triggers[0].created_at == triggers[1].created_at, + same_second + ); + let mut run_ids = HashSet::new(); + let mut responses = HashMap::new(); + for trigger in &triggers { + let result = handle_workflow_trigger(&tenant, &state, trigger, &http_auth(&human)) + .await + .expect("distinct trigger accepted"); + assert!(run_ids.insert(response_run_id(&result))); + responses.insert(trigger.id, result.message); + } + // Replay both, including the earlier operation after another has committed. + for trigger in &triggers { + let replay = handle_workflow_trigger(&tenant, &state, trigger, &http_auth(&human)) + .await + .expect("exact replay accepted"); + assert!(replay.accepted); + assert_eq!( + replay.message, responses[&trigger.id], + "retry recovers the original result" + ); + } + assert_persisted_trigger_runs(&state, &tenant, workflow_id, &revision, &triggers, &run_ids) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn manual_triggers_delivered_newer_then_older_create_distinct_runs() { + deliver_and_replay(false).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn manual_triggers_in_one_second_create_distinct_runs() { + deliver_and_replay(true).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_human_owner_manual_triggers_do_not_starve_one_connection_pool() { + let (state, tenant, human, _agent, workflow_id, revision) = + manual_trigger_test_context().await; + let now = Timestamp::now().as_secs(); + let triggers = (0..8) + .map(|i| { + workflow_trigger_event_at( + &human, + workflow_id, + &revision.id.to_hex(), + now, + &i.to_string(), + ) + }) + .collect::>(); + let mut deliveries = triggers.clone(); + // Concurrent exact retries must not create additional runs either. + deliveries.extend_from_slice(&triggers[..2]); + let results = tokio::time::timeout(std::time::Duration::from_secs(8), async { + let mut tasks = tokio::task::JoinSet::new(); + for trigger in deliveries { + let state = Arc::clone(&state); + let tenant = tenant.clone(); + let auth = http_auth(&human); + tasks.spawn(async move { + handle_workflow_trigger(&tenant, &state, &trigger, &auth).await + }); + } + let mut results = Vec::new(); + while let Some(result) = tasks.join_next().await { + results.push( + result + .expect("trigger task must not panic") + .expect("trigger succeeds"), + ); + } + results + }) + .await + .expect("concurrent triggers must drain rather than pool-starve"); + assert_eq!(results.len(), 10); + assert!(results.iter().all(|result| result.accepted)); + let mut acknowledged = HashMap::new(); + for result in &results { + let run_id = response_run_id(result); + if let Some(previous) = acknowledged.insert(result.event_id.clone(), run_id) { + assert_eq!( + previous, run_id, + "concurrent retry returns the original run" + ); + } + } + assert_eq!(acknowledged.len(), 8); + let run_ids = acknowledged.into_values().collect::>(); + assert_persisted_trigger_runs(&state, &tenant, workflow_id, &revision, &triggers, &run_ids) + .await; + } +} diff --git a/crates/buzz-relay/src/handlers/command_executor/postgres_tests/trigger_ingest.rs b/crates/buzz-relay/src/handlers/command_executor/postgres_tests/trigger_ingest.rs new file mode 100644 index 00000000000..22693db9389 --- /dev/null +++ b/crates/buzz-relay/src/handlers/command_executor/postgres_tests/trigger_ingest.rs @@ -0,0 +1,520 @@ +// Full WS/HTTP shared ingest, real PostgreSQL event/run transaction and executor. +// Only wall-clock time and the external action sink are controlled by the test. +mod postgres_tests { + use super::super::*; + use crate::handlers::ingest::{ingest_event, ingest_event_at}; + use nostr::JsonUtil; + use std::time::Duration; + + const YAML: &str = "name: manual-trigger-pool\ntrigger:\n on: message_posted\nsteps:\n - id: send\n action: send_message\n text: done\n"; + + fn run_id(response: &IngestResult) -> Uuid { + let value: serde_json::Value = + serde_json::from_str(response.message.strip_prefix("response:").unwrap()).unwrap(); + Uuid::parse_str(value["run_id"].as_str().unwrap()).unwrap() + } + + async fn settled(state: &AppState, tenant: &TenantContext, id: Uuid) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let run = state + .db + .get_workflow_run(tenant.community(), id) + .await + .unwrap(); + if run.status == RunStatus::Completed { + break; + } + assert_ne!(run.status, RunStatus::Failed, "{:?}", run.error_message); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("executor completed"); + } + + async fn absent(state: &AppState, tenant: &TenantContext, workflow: Uuid, event: &Event) { + assert!(state + .db + .get_event_by_id_including_deleted(tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .is_none()); + assert!(state + .db + .get_workflow_run_id_by_trigger(tenant.community(), workflow, event.id.as_bytes()) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_manual_ingest_recovers_committed_exact_bytes_without_dispatch() { + let (state, tenant, human, _, workflow, revision) = + manual_trigger_test_context_with_yaml(YAML).await; + let sink = Arc::new(RecordingActionSink::default()); + state.workflow_engine.set_action_sink(sink.clone()); + let event = workflow_trigger_event(&human, workflow, &revision); + let bytes = event.as_json(); + let trace_file = tempfile::NamedTempFile::new().unwrap(); + let mut state = Arc::try_unwrap(state).ok().unwrap(); + state.tracer = + Arc::new(crate::conformance::JsonlTracer::create(trace_file.path()).unwrap()); + let state = Arc::new(state); + // The caller discards this response (lost delivery); DB/dispatch are real. + let first = ingest_event(&state, &tenant, event.clone(), http_auth(&human)) + .await + .unwrap(); + let id = run_id(&first); + settled(&state, &tenant, id).await; + assert_eq!(sink.messages.lock().unwrap().as_slice(), ["done"]); + let later = event.created_at.as_secs() as i64 + 901; + // Assert recovery's new early return, not the pre-existing + // fresh-command trace gap. + let previous_steps = std::fs::read_to_string(trace_file.path()) + .unwrap() + .lines() + .count(); + for _ in 0..2 { + let retry = ingest_event_at( + &state, + &tenant, + Event::from_json(&bytes).unwrap(), + http_auth(&human), + later, + ) + .await + .expect("expired committed retry recovers"); + assert_eq!(retry.event_id, first.event_id); + assert_eq!(retry.message, first.message); + } + let trace = std::fs::read_to_string(trace_file.path()).unwrap(); + let steps: Vec = trace + .lines() + .skip(previous_steps) + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(steps.len(), 2); + assert!( + steps.iter().all(|step| matches!( + step.action, + buzz_conformance::TraceAction::WriteDuplicate { .. } + )), + "recovery must emit duplicate acknowledgment, not a coverage breach: {trace}" + ); + // A deliberate fresh intent is a different authentic ID, not equal content. + let distinct = workflow_trigger_event_at( + &human, + workflow, + &revision.id.to_hex(), + later as u64, + "distinct", + ); + let second = ingest_event_at(&state, &tenant, distinct, http_auth(&human), later) + .await + .unwrap(); + assert_ne!(id, run_id(&second)); + settled(&state, &tenant, run_id(&second)).await; + let runs = state + .db + .list_workflow_runs(tenant.community(), workflow, 100) + .await + .unwrap(); + assert_eq!(runs.len(), 2); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(sink.messages.lock().unwrap().as_slice(), ["done", "done"]); + assert_eq!( + state + .db + .get_event_by_id(tenant.community(), event.id.as_bytes()) + .await + .unwrap() + .unwrap() + .event + .as_json(), + bytes + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_manual_ingest_rejects_unseen_and_invalid_envelopes() { + let (state, tenant, human, _, workflow, revision) = + manual_trigger_test_context_with_yaml(YAML).await; + let sink = Arc::new(RecordingActionSink::default()); + state.workflow_engine.set_action_sink(sink.clone()); + let event = workflow_trigger_event(&human, workflow, &revision); + let later = event.created_at.as_secs() as i64 + 901; + assert!( + ingest_event_at(&state, &tenant, event.clone(), http_auth(&human), later) + .await + .is_err() + ); + absent(&state, &tenant, workflow, &event).await; + // Far-future unseen events and other kinds retain freshness rejection too. + assert!(ingest_event_at( + &state, + &tenant, + event.clone(), + http_auth(&human), + later - 1802 + ) + .await + .is_err()); + assert!( + ingest_event_at(&state, &tenant, revision.clone(), http_auth(&human), later) + .await + .is_err() + ); + let first = ingest_event(&state, &tenant, event.clone(), http_auth(&human)) + .await + .unwrap(); + settled(&state, &tenant, run_id(&first)).await; + let mut tamper = event.clone(); + tamper.content = "tampered".into(); + assert!( + ingest_event_at(&state, &tenant, tamper, http_auth(&human), later) + .await + .is_err() + ); + let mut bad_sig = event.clone(); + bad_sig.sig = workflow_trigger_event(&Keys::generate(), workflow, &revision).sig; + assert!( + ingest_event_at(&state, &tenant, bad_sig, http_auth(&human), later) + .await + .is_err() + ); + let stranger = Keys::generate(); + assert!( + ingest_event_at(&state, &tenant, event.clone(), http_auth(&stranger), later) + .await + .is_err() + ); + let unauthorized = workflow_trigger_event(&stranger, workflow, &revision); + assert!(ingest_event_at( + &state, + &tenant, + unauthorized.clone(), + http_auth(&stranger), + later + ) + .await + .is_err()); + absent(&state, &tenant, workflow, &unauthorized).await; + let mut auth = http_auth(&human); + if let IngestAuth::Http { scopes, .. } = &mut auth { + scopes.clear(); + } + assert!(ingest_event_at(&state, &tenant, event.clone(), auth, later) + .await + .is_err()); + let wrong_workflow = workflow_trigger_event(&human, Uuid::new_v4(), &revision); + assert!(ingest_event_at( + &state, + &tenant, + wrong_workflow.clone(), + http_auth(&human), + later + ) + .await + .is_err()); + absent(&state, &tenant, workflow, &wrong_workflow).await; + let other_host = format!("other-{}.example", Uuid::new_v4()); + let other = state + .db + .ensure_configured_community(&other_host) + .await + .unwrap() + .id; + let other_tenant = TenantContext::resolved(other, other_host); + assert!(ingest_event_at( + &state, + &other_tenant, + event.clone(), + http_auth(&human), + later + ) + .await + .is_err()); + absent(&state, &other_tenant, workflow, &event).await; + assert_eq!( + state + .db + .list_workflow_runs(tenant.community(), workflow, 100) + .await + .unwrap() + .len(), + 1 + ); + assert_eq!(sink.messages.lock().unwrap().as_slice(), ["done"]); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_manual_ingest_requires_current_authority() { + for change in ["deleted", "replaced", "nonmember", "disabled"] { + let (state, tenant, human, agent, workflow, revision) = + manual_trigger_test_context_with_yaml(YAML).await; + let sink = Arc::new(RecordingActionSink::default()); + state.workflow_engine.set_action_sink(sink.clone()); + let event = workflow_trigger_event(&human, workflow, &revision); + let first = ingest_event(&state, &tenant, event.clone(), http_auth(&human)) + .await + .unwrap(); + settled(&state, &tenant, run_id(&first)).await; + let community = tenant.community(); + let channel = Uuid::parse_str(exact_tag_value(&revision, "h").unwrap()).unwrap(); + match change { + "deleted" => { + state + .db + .soft_delete_event(community, revision.id.as_bytes()) + .await + .unwrap(); + } + "replaced" => { + let replacement = EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + YAML.replace("done", "changed"), + ) + .tags(revision.tags.to_vec()) + .custom_created_at(Timestamp::from(revision.created_at.as_secs() + 1)) + .sign_with_keys(&agent) + .unwrap(); + let (_, json) = + buzz_workflow::WorkflowEngine::parse_yaml(&replacement.content).unwrap(); + let mut tx = state.db.begin_event_write_transaction().await.unwrap(); + state + .db + .replace_parameterized_event_in_transaction( + &mut tx, + community, + &replacement, + &workflow.to_string(), + Some(channel), + buzz_db::replaceable::ParameterizedReplacePrecondition::Unconditional, + ) + .await + .unwrap(); + state + .db + .upsert_workflow( + &mut tx, + community, + workflow, + Some(channel), + agent.public_key().as_bytes(), + "manual-trigger-pool", + &json, + &compute_definition_hash(&json), + replacement.id.as_bytes(), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + } + "nonmember" => { + // Preserve a channel owner so removing the workflow principal + // exercises revocation rather than the last-owner safety gate. + state + .db + .add_member( + community, + channel, + human.public_key().as_bytes(), + buzz_core::channel::MemberRole::Owner, + Some(agent.public_key().as_bytes()), + ) + .await + .unwrap(); + state + .db + .remove_member( + community, + channel, + agent.public_key().as_bytes(), + agent.public_key().as_bytes(), + ) + .await + .unwrap(); + } + "disabled" => { + state + .db + .set_workflow_enabled(community, workflow, false) + .await + .unwrap(); + } + _ => unreachable!(), + } + assert!( + ingest_event_at( + &state, + &tenant, + event.clone(), + http_auth(&human), + event.created_at.as_secs() as i64 + 901 + ) + .await + .is_err(), + "{change}" + ); + assert_eq!( + state + .db + .list_workflow_runs(community, workflow, 100) + .await + .unwrap() + .len(), + 1 + ); + assert_eq!(sink.messages.lock().unwrap().as_slice(), ["done"]); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_manual_ingest_never_repairs_event_only_or_retention_gaps_by_execution() { + let (state, tenant, human, _, workflow, revision) = + manual_trigger_test_context_with_yaml(YAML).await; + let sink = Arc::new(RecordingActionSink::default()); + state.workflow_engine.set_action_sink(sink.clone()); + let event = workflow_trigger_event(&human, workflow, &revision); + let channel = Uuid::parse_str(exact_tag_value(&revision, "h").unwrap()).unwrap(); + // Legacy/partial data: normal manual admission commits event+run atomically. + let PersistResult::Inserted(tx) = + persist_command_event(&state.db, &tenant, &event, Some(channel)) + .await + .unwrap() + else { + panic!("new event") + }; + tx.commit().await.unwrap(); + let later = event.created_at.as_secs() as i64 + 901; + assert!( + ingest_event_at(&state, &tenant, event.clone(), http_auth(&human), later) + .await + .is_err() + ); + assert!(state + .db + .list_workflow_runs(tenant.community(), workflow, 100) + .await + .unwrap() + .is_empty()); + state + .db + .soft_delete_event(tenant.community(), event.id.as_bytes()) + .await + .unwrap(); + assert!( + ingest_event_at(&state, &tenant, event.clone(), http_auth(&human), later) + .await + .is_err() + ); + assert!(sink.messages.lock().unwrap().is_empty()); + } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expired_manual_ingest_acknowledges_pending_run_without_rescheduling() { + let (state, tenant, human, _, workflow, revision) = + manual_trigger_test_context_with_yaml(YAML).await; + let sink = Arc::new(RecordingActionSink::default()); + state.workflow_engine.set_action_sink(sink.clone()); + let event = workflow_trigger_event(&human, workflow, &revision); + let channel = Uuid::parse_str(exact_tag_value(&revision, "h").unwrap()).unwrap(); + let community = tenant.community(); + // Reproduce the durable state at the real commit -> spawn crash window. + // Run creation is in the event transaction, not deferred to the executor. + let PersistResult::Inserted(mut tx) = + persist_command_event(&state.db, &tenant, &event, Some(channel)) + .await + .unwrap() + else { + panic!("new event") + }; + let id = state + .db + .create_workflow_run_in_transaction( + &mut tx, + community, + workflow, + revision.id.as_bytes(), + Some(event.id.as_bytes()), + None, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + for status in [ + RunStatus::Pending, + RunStatus::Running, + RunStatus::WaitingApproval, + RunStatus::Failed, + ] { + state + .db + .update_workflow_run( + community, + id, + status.clone(), + 0, + &serde_json::json!([]), + None, + ) + .await + .unwrap(); + let response = ingest_event_at( + &state, + &tenant, + event.clone(), + http_auth(&human), + event.created_at.as_secs() as i64 + 901, + ) + .await + .unwrap(); + assert_eq!(run_id(&response), id); + tokio::time::sleep(Duration::from_millis(25)).await; + assert_eq!( + state + .db + .get_workflow_run(community, id) + .await + .unwrap() + .status, + status + ); + assert!( + sink.messages.lock().unwrap().is_empty(), + "acknowledgment is not a retry of execution" + ); + } + // Retention can also remove the event while the run remains. Do not + // mistake the surviving run reference for fresh execution authorization. + state + .db + .soft_delete_event(community, event.id.as_bytes()) + .await + .unwrap(); + assert!(ingest_event_at( + &state, + &tenant, + event.clone(), + http_auth(&human), + event.created_at.as_secs() as i64 + 901 + ) + .await + .is_err()); + assert_eq!( + state + .db + .list_workflow_runs(community, workflow, 100) + .await + .unwrap() + .len(), + 1 + ); + assert!(sink.messages.lock().unwrap().is_empty()); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee1d0312be9..e53bd614259 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2112,6 +2112,18 @@ pub async fn ingest_event( tenant: &TenantContext, event: Event, auth: IngestAuth, +) -> Result { + ingest_event_at(state, tenant, event, auth, chrono::Utc::now().timestamp()).await +} + +// Explicit admission time keeps elapsed-time replay tests on the complete +// production pipeline without modifying signed bytes or sleeping 15 minutes. +pub(super) async fn ingest_event_at( + state: &Arc, + tenant: &TenantContext, + event: Event, + auth: IngestAuth, + now: i64, ) -> Result { // Captured before `event` moves into the inner fn: the stored-events // counter below is emitted at this shared seam so WebSocket and HTTP @@ -2128,7 +2140,7 @@ pub async fn ingest_event( "ingest_event_exited_without_trace", ); - let result = ingest_event_inner(state, &tracer, tenant, event, auth).await; + let result = ingest_event_inner(state, &tracer, tenant, event, auth, now).await; // Fleet-wide stored counter: kind + author_type only, no community tag // (see the cardinality rationale on buzz_events_received_total — @@ -2173,6 +2185,7 @@ async fn ingest_event_inner( tenant: &TenantContext, event: Event, auth: IngestAuth, + now: i64, ) -> Result { let event_id_hex = event.id.to_hex(); let kind_u32 = event_kind_u32(&event); @@ -2232,9 +2245,12 @@ async fn ingest_event_inner( let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone()); const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes - let now = chrono::Utc::now().timestamp(); let event_ts = event.created_at.as_secs() as i64; - if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { + let outside_freshness_window = (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS; + // Only manual triggers have a committed-result recovery contract. Expired + // triggers still pass all envelope/auth checks below, then take a read-only + // path: they must never fall through to fresh command admission. + if outside_freshness_window && kind_u32 != KIND_WORKFLOW_TRIGGER { return Err(IngestError::Rejected( "invalid: event timestamp too far from server time".into(), )); @@ -2283,7 +2299,14 @@ async fn ingest_event_inner( ))); } - // Command kinds are routed AFTER signature verification, timestamp check, + if outside_freshness_window { + return super::command_executor::recover_workflow_trigger( + tenant, state, &event, &auth, tracer, + ) + .await; + } + + // Fresh command admission follows signature verification, timestamp check, // pubkey/auth match, and scope validation — never before. if buzz_core::kind::is_command_kind(kind_u32) { return super::command_executor::handle_command(tenant, state, event, auth).await; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 61aedf70be0..96cff655f3b 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -76,6 +76,10 @@ pub fn build_router(state: Arc) -> Router { // Relay-owned third-party GIF metadata proxy (NIP-98 auth). .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) .route(api::gifs::SHARE_PATH, post(api::gifs::share)) + .route( + "/workflows/{workflow_id}/revision", + get(api::workflows::workflow_revision), + ) .route( "/workflows/{workflow_id}/runs", get(api::workflows::workflow_runs), diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 95372d5bc3b..531226307f3 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -628,6 +628,9 @@ impl Default for ConnectionManager { /// Shared application state, cloned cheaply via inner `Arc` fields. #[derive(Clone)] pub struct AppState { + // Keep unit-test repository/cache paths alive until the state is dropped. + #[cfg(test)] + pub(crate) test_git_directory: Option>, /// Relay configuration. pub config: Arc, /// Database connection pool. @@ -781,6 +784,16 @@ pub struct AppState { } impl AppState { + /// Isolate workflow fixture caches before constructing state: cache startup + /// sweeps stale sessions, so inherited/default paths are not safe fixtures. + #[cfg(test)] + pub(crate) fn workflow_test_git_directory(config: &mut Config) -> Arc { + let directory = Arc::new(tempfile::tempdir().expect("isolated workflow fixture git paths")); + config.git_repo_path = directory.path().join("repos"); + config.git_pack_cache_path = directory.path().join("pack-cache"); + directory + } + /// Constructs `AppState` from its component services. /// /// Returns `(state, audit_shutdown)`. The caller should call @@ -921,6 +934,8 @@ impl AppState { )), git_store, git_pack_cache, + #[cfg(test)] + test_git_directory: None, audio_rooms: Arc::new(AudioRoomManager::new()), shutting_down: Arc::new(AtomicBool::new(false)), readiness: Arc::new(crate::readiness::ReadinessCoordinator::default()), @@ -2541,3 +2556,36 @@ pub(crate) mod tests { } } } + +#[cfg(test)] +#[test] +fn workflow_fixture_cache_does_not_sweep_inherited_sessions() { + let inherited = tempfile::tempdir().expect("external inherited cache"); + let session = inherited.path().join("session-existing"); + std::fs::create_dir(&session).expect("session"); + let sentinel = session.join("sentinel"); + std::fs::write(&sentinel, b"preserve").expect("sentinel"); + let heartbeat = std::fs::File::create(session.join(".heartbeat")).expect("heartbeat"); + heartbeat + .set_times( + std::fs::FileTimes::new() + .set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(3600)), + ) + .expect("stale heartbeat"); + let mut config = Config::from_env().expect("config"); + config.git_pack_cache_path = inherited.path().to_path_buf(); + let directory = AppState::workflow_test_git_directory(&mut config); + assert!(config.git_pack_cache_path.starts_with(directory.path())); + assert!(config.git_repo_path.starts_with(directory.path())); + let cache = + crate::api::git::pack_cache::GitPackCache::new(&config.git_pack_cache_path, 1024, 1) + .expect("cache"); + assert_eq!( + std::fs::read(&sentinel).expect("inherited session survives"), + b"preserve" + ); + let disposable = directory.path().to_path_buf(); + drop(cache); + drop(directory); + assert!(!disposable.exists()); +} diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f43887b65b1..c6845ece8d6 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1658,10 +1658,42 @@ pub fn build_workflow_delete( build_delete_addressable(KIND_WORKFLOW_DEF, author_pubkey, &workflow_id.to_string()) } -/// Build a workflow trigger event (kind 46020). -pub fn build_workflow_trigger(workflow_id: Uuid) -> Result { - let tags = vec![tag(&["d", &workflow_id.to_string()])?]; - Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), "").tags(tags)) +/// Build a workflow trigger event (kind 46020) bound to an exact signed revision. +/// +/// Each call creates a new operation, even within the same second. Retain and +/// resubmit the signed event when retrying that operation; do not rebuild it. +pub fn build_workflow_trigger( + workflow_id: Uuid, + definition_event_id: &str, +) -> Result { + workflow_trigger_builder(workflow_id, definition_event_id, "") +} + +/// Build a distinct workflow trigger operation with JSON object inputs. +/// Retry by resubmitting the signed event, as with [`build_workflow_trigger`]. +pub fn build_workflow_trigger_with_inputs( + workflow_id: Uuid, + definition_event_id: &str, + inputs: &serde_json::Map, +) -> Result { + let content = serde_json::Value::Object(inputs.clone()).to_string(); + check_content(&content, 64 * 1024)?; + workflow_trigger_builder(workflow_id, definition_event_id, &content) +} + +fn workflow_trigger_builder( + workflow_id: Uuid, + definition_event_id: &str, + content: &str, +) -> Result { + let revision = check_hex_exact(definition_event_id, 64, "definition_event_id")?; + let tags = vec![ + tag(&["d", &workflow_id.to_string()])?, + tag(&["e", &revision])?, + // Event IDs hash the unsigned fields, not the randomized signature. + tag(&["request-id", &Uuid::new_v4().to_string()])?, + ]; + Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), content).tags(tags)) } /// Build a workflow approval event — kind 46030 (grant) or 46031 (deny). @@ -4113,9 +4145,39 @@ mod tests { #[test] fn workflow_trigger_happy_path() { let wid = uuid(); - let ev = sign(build_workflow_trigger(wid).unwrap()); + let ev = sign(build_workflow_trigger(wid, &"ab".repeat(32)).unwrap()); assert_eq!(ev.kind.as_u16(), 46020); assert!(has_tag(&ev, "d", &wid.to_string())); + assert!(has_tag(&ev, "e", &"ab".repeat(32))); + assert!(build_workflow_trigger(wid, "not-an-event-id").is_err()); + } + + #[test] + fn workflow_trigger_invocations_in_one_second_have_distinct_ids() { + let keys = nostr::Keys::generate(); + let workflow_id = uuid(); + let revision = "ab".repeat(32); + let timestamp = nostr::Timestamp::from(1_700_000_000); + let first = build_workflow_trigger(workflow_id, &revision).unwrap(); + let replay = first + .clone() + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .unwrap(); + let first = first + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .unwrap(); + let second = build_workflow_trigger(workflow_id, &revision) + .unwrap() + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(first.id, replay.id, "retry the same built operation"); + assert_ne!( + first.id, second.id, + "new invocations must not become replays" + ); } #[test] diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 4c8c8904ac5..70740c9889a 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -1778,7 +1778,31 @@ mod workflows { .to_string() } - /// Fire a workflow by id on `http_base`'s community (kind:46020, `d`=id). + /// Fetch the current owner-signed workflow revision from `http_base`'s + /// community. Manual triggers must name this exact event id. + async fn workflow_revision(http_base: &str, keys: &Keys, workflow_id: &str) -> String { + let resp = reqwest::Client::new() + .get(format!("{http_base}/workflows/{workflow_id}/revision")) + .header("X-Pubkey", keys.public_key().to_hex()) + .send() + .await + .unwrap_or_else(|e| panic!("GET workflow revision from {http_base} failed: {e}")); + let status = resp.status(); + let body = resp.text().await.expect("read workflow revision body"); + assert!( + status.is_success(), + "workflow revision lookup against {http_base} failed with {status}: {body}" + ); + let event: serde_json::Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("parse workflow revision JSON: {e} ({body:?})")); + event["id"] + .as_str() + .unwrap_or_else(|| panic!("workflow revision response missing event id: {event}")) + .to_string() + } + + /// Fire an exact workflow revision on `http_base`'s community + /// (kind:46020, `d`=workflow id, `e`=signed revision id). /// Returns a normalized `{accepted, message}` body so the caller can assert /// on the *wire-observable* accept/reject and message. The HTTP bridge maps /// `IngestError::Rejected` to HTTP 400 + `{error}` while the WS door maps the @@ -1788,9 +1812,13 @@ mod workflows { http_base: &str, keys: &Keys, workflow_id: &str, + revision: &str, ) -> serde_json::Value { let event = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER), "") - .tags(vec![Tag::parse(["d", workflow_id]).unwrap()]) + .tags(vec![ + Tag::parse(["d", workflow_id]).unwrap(), + Tag::parse(["e", revision]).unwrap(), + ]) .sign_with_keys(keys) .unwrap(); @@ -1837,9 +1865,11 @@ mod workflows { /// This deliberately removes "not a member of U in B" as an alternate /// cause of the B rejection — K *is* a member of U in B. /// 2. Define a workflow in `U` under **A** (kind:30620). The server - /// generates `W` and returns it. `W` is an A-community row. - /// 3. Fire `W` under host **B** (kind:46020, `d`=W) as K. Must be - /// rejected — `accepted == false` and the generic `workflow not found` + /// generates `W` and returns it. Retrieve its current signed revision + /// `R` through A's authenticated revision endpoint. `W` and `R` are + /// A-community records. + /// 3. Fire `W`/`R` under host **B** (kind:46020, `d`=W, `e`=R) as K. Must + /// be rejected — `accepted == false` and the generic `workflow not found` /// message — because `get_workflow(B_community, W)` finds nothing: `W` /// exists only in A. K's membership of U-in-B is irrelevant; the /// lookup never reaches the membership check. @@ -1893,11 +1923,17 @@ mod workflows { uuid::Uuid::parse_str(&workflow_id).is_ok(), "server-generated workflow_id must be a UUID, got {workflow_id:?}" ); + let revision = workflow_revision(&http_a, &keys, &workflow_id).await; + assert_eq!( + revision.len(), + 64, + "signed workflow revision must be a 32-byte event id" + ); - // (3) Fire W under host B as K. Must fail closed: W is an A-community - // row, and get_workflow(B_community, W) finds nothing. K is a member of - // U in B, so a leak here is the community fence failing, not membership. - let b_resp = trigger_workflow(&http_b, &keys, &workflow_id).await; + // (3) Fire W/R under host B as K. Keep the trigger grammar otherwise + // valid with A's real signed revision so only tenant-scoped workflow + // resolution can explain the rejection. + let b_resp = trigger_workflow(&http_b, &keys, &workflow_id, &revision).await; assert_eq!( b_resp["accepted"].as_bool(), Some(false), @@ -1915,7 +1951,7 @@ mod workflows { // proves the B rejection is community confinement, not an // untriggerable workflow, and exercises the same-community happy path // through the fence under test. - let a_resp = trigger_workflow(&http_a, &keys, &workflow_id).await; + let a_resp = trigger_workflow(&http_a, &keys, &workflow_id, &revision).await; assert_eq!( a_resp["accepted"].as_bool(), Some(true), diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 390b9b14ac3..2197fc1a3f5 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -44,7 +44,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::OnceLock; -use buzz_core::kind::{event_kind_u32, is_workflow_execution_kind, KIND_REACTION}; +use buzz_core::kind::{ + event_kind_u32, is_workflow_execution_kind, KIND_REACTION, KIND_WORKFLOW_DEF, +}; use buzz_core::tenant::CommunityId; use buzz_db::workflow::RunStatus; use buzz_db::Db; @@ -122,6 +124,68 @@ impl WorkflowEngine { } } + /// Load and verify the exact owner-signed definition bound to a run. + /// + /// The mutable `workflows` row supplies identity/channel binding and the + /// current revision pointer. Content always comes from the run's signed event; + /// legacy runs without a revision fail closed. Replaced or explicitly deleted + /// definitions cannot authorize pending execution or continuation. + pub async fn load_run_definition( + &self, + community_id: CommunityId, + run_id: Uuid, + ) -> Result<(buzz_db::workflow::WorkflowRunRecord, WorkflowDef), WorkflowError> { + let run = self.db.get_workflow_run(community_id, run_id).await?; + let revision = run.definition_event_id.as_deref().ok_or_else(|| { + WorkflowError::InvalidDefinition( + "workflow run has no owner-signed definition revision".into(), + ) + })?; + let workflow = self.db.get_workflow(community_id, run.workflow_id).await?; + if workflow.definition_event_id.as_deref() != Some(revision) { + return Err(WorkflowError::InvalidDefinition( + "workflow run definition revision is no longer current".into(), + )); + } + let stored = self + .db + .get_event_by_id(community_id, revision) + .await? + .ok_or_else(|| { + WorkflowError::InvalidDefinition( + "workflow run definition event is unavailable".into(), + ) + })?; + let event = &stored.event; + let workflow_id = run.workflow_id.to_string(); + let channel_id = workflow.channel_id.map(|id| id.to_string()); + let exact_tag = |name: &str| { + let mut values = event.tags.iter().filter_map(|tag| { + (tag.kind().to_string() == name) + .then(|| tag.content()) + .flatten() + }); + let value = values.next(); + value.filter(|_| values.next().is_none()) + }; + if event.id.as_bytes() != revision + || !event.verify_id() + || !event.verify_signature() + || event_kind_u32(event) != KIND_WORKFLOW_DEF + || event.pubkey.to_bytes().as_slice() != workflow.owner_pubkey + || exact_tag("d") != Some(workflow_id.as_str()) + || channel_id.is_none() + || exact_tag("h") != channel_id.as_deref() + || stored.channel_id != workflow.channel_id + { + return Err(WorkflowError::InvalidDefinition( + "workflow run definition event binding mismatch".into(), + )); + } + let (definition, _) = Self::parse_yaml(&event.content)?; + Ok((run, definition)) + } + /// Drop the cached enabled-workflow list for a channel. /// /// Must be called after any write to a workflow's trigger eligibility or diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c8184a01031..4c225d50770 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -72,6 +72,7 @@ mod teams; mod updater; mod window_chrome; mod window_vibrancy; +mod workflow_trigger; mod workflows; mod workspace; @@ -130,5 +131,6 @@ pub use teams::*; pub use updater::*; pub use window_chrome::*; pub use window_vibrancy::*; +pub use workflow_trigger::*; pub use workflows::*; pub use workspace::*; diff --git a/desktop/src-tauri/src/commands/workflow_trigger.rs b/desktop/src-tauri/src/commands/workflow_trigger.rs new file mode 100644 index 00000000000..ba8a5cd939f --- /dev/null +++ b/desktop/src-tauri/src/commands/workflow_trigger.rs @@ -0,0 +1,116 @@ +//! A manual run is prepared once, then submitted/retried as the same signed event. +//! The renderer owns the operation (including ambiguous failures); native code +//! never silently rebuilds a submitted trigger. No restart recovery is promised. +use nostr::Event; +use serde::Deserialize; +use tauri::State; + +use super::workflows::{trigger_wire_from_message, WorkflowTriggerWire}; +use crate::{app_state::AppState, events, relay}; + +fn trigger_scope( + state: &AppState, + expected_relay_url: &str, + expected_signer_pubkey: &str, +) -> Result<(String, nostr::Keys), String> { + if expected_relay_url.trim().is_empty() || expected_signer_pubkey.trim().is_empty() { + return Err("workflow trigger requires a community and identity".to_string()); + } + let base = relay::relay_api_base_url_with_override(state); + let keys = state.signing_keys()?; + relay::assert_expected_relay_scope(Some(expected_relay_url), &base)?; + relay::assert_expected_signer(Some(expected_signer_pubkey), &keys.public_key().to_hex())?; + Ok((base, keys)) +} + +/// Resolve and sign without publishing. Losing this response cannot create a run. +#[tauri::command] +pub async fn prepare_workflow_trigger( + workflow_id: String, + expected_relay_url: String, + expected_signer_pubkey: String, + state: State<'_, AppState>, +) -> Result { + prepare_trigger( + &workflow_id, + &expected_relay_url, + &expected_signer_pubkey, + &state, + ) + .await +} + +async fn prepare_trigger( + workflow_id: &str, + expected_relay_url: &str, + expected_signer_pubkey: &str, + state: &AppState, +) -> Result { + #[derive(Deserialize)] + struct Revision { + id: String, + } + let id = uuid::Uuid::parse_str(workflow_id).map_err(|_| "invalid workflow id".to_string())?; + let (base, keys) = trigger_scope(state, expected_relay_url, expected_signer_pubkey)?; + // Neither the GET authentication nor the event signer re-reads active scope + // after the await. A concurrent switch cannot combine tenant A with key B. + let revision: Revision = relay::get_relay_json_at_with_keys( + state, + &format!("/workflows/{id}/revision"), + &base, + &keys, + ) + .await?; + events::build_workflow_trigger(&id.to_string(), &revision.id)? + .sign_with_keys(&keys) + .map_err(|error| format!("failed to sign trigger: {error}")) +} + +/// Submit the caller-retained event verbatim, including its original signature. +#[tauri::command] +pub async fn trigger_workflow( + workflow_id: String, + event: Event, + expected_relay_url: String, + expected_signer_pubkey: String, + state: State<'_, AppState>, +) -> Result { + submit_trigger( + workflow_id, + &event, + &expected_relay_url, + &expected_signer_pubkey, + &state, + ) + .await +} + +async fn submit_trigger( + workflow_id: String, + event: &Event, + expected_relay_url: &str, + expected_signer_pubkey: &str, + state: &AppState, +) -> Result { + let (base, keys) = trigger_scope(state, expected_relay_url, expected_signer_pubkey)?; + if event.kind != nostr::Kind::Custom(46020) + || !event + .tags + .iter() + .any(|tag| tag.as_slice() == ["d", workflow_id.as_str()]) + { + return Err("signed trigger does not match the workflow".to_string()); + } + event + .verify() + .map_err(|error| format!("invalid signed trigger: {error}"))?; + let result = relay::submit_signed_event_at_with_keys(event, state, &base, &keys).await?; + if result.event_id != event.id.to_hex() { + return Err("relay acknowledged a different trigger event".to_string()); + } + trigger_wire_from_message(workflow_id, &result.message) +} + +#[cfg(test)] +#[path = "workflow_trigger_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/workflow_trigger_tests.rs b/desktop/src-tauri/src/commands/workflow_trigger_tests.rs new file mode 100644 index 00000000000..77da9c59287 --- /dev/null +++ b/desktop/src-tauri/src/commands/workflow_trigger_tests.rs @@ -0,0 +1,246 @@ +use super::*; +use nostr::JsonUtil; +use std::{ + collections::HashMap, + io::{Read, Write}, + net::TcpListener, + sync::Arc, + time::Duration, +}; + +// This HTTP peer models the already-tested relay event-ID dedupe contract. +// It records verified signed payloads, not command invocation counts. No DB or +// Redis is needed to exercise Desktop's production GET/sign/submit boundary. +#[tokio::test] +async fn response_loss_replays_exact_signed_event_and_distinct_run_is_explicit() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some(base.clone()); + let owner = keys.public_key().to_hex(); + let id = uuid::Uuid::new_v4().to_string(); + let server = std::thread::spawn(move || { + let mut runs = HashMap::new(); + let mut posts = Vec::new(); + let mut effects = 0; + for _ in 0..6 { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut bytes = Vec::new(); + let mut one = [0; 1]; + while !bytes.ends_with(b"\r\n\r\n") { + socket.read_exact(&mut one).unwrap(); + bytes.push(one[0]); + } + let headers = String::from_utf8(bytes).unwrap(); + assert!(headers.to_lowercase().contains("authorization: nostr ")); + let body = if headers.starts_with("GET ") { + format!(r#"{{"id":"{}"}}"#, "ab".repeat(32)) + } else { + let len: usize = headers + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length: ") + .map(str::to_owned) + }) + .unwrap() + .parse() + .unwrap(); + let mut payload = vec![0; len]; + socket.read_exact(&mut payload).unwrap(); + let event = Event::from_json(&payload).unwrap(); + event.verify().unwrap(); + let next_run = format!("run-{}", runs.len() + 1); + let run = runs.entry(event.id).or_insert_with(|| { + effects += 1; + next_run + }); + posts.push(payload); + if posts.len() == 1 { + // Commit first, then truncate the successful response body. + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{", + ) + .unwrap(); + continue; + } + serde_json::json!({"accepted":true,"event_id":event.id.to_hex(),"message":serde_json::json!({"run_id":run}).to_string()}).to_string() + }; + write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); + } + (posts, runs.len(), effects) + }); + let event = prepare_trigger(&id, &base, &owner, &state).await.unwrap(); + assert!(submit_trigger(id.clone(), &event, &base, &owner, &state) + .await + .is_err()); + let retry = submit_trigger(id.clone(), &event, &base, &owner, &state) + .await + .unwrap(); + let replay = submit_trigger(id.clone(), &event, &base, &owner, &state) + .await + .unwrap(); + assert_eq!(retry, replay); + let next = prepare_trigger(&id, &base, &owner, &state).await.unwrap(); + assert_ne!(event.id, next.id); + let distinct = submit_trigger(id, &next, &base, &owner, &state) + .await + .unwrap(); + assert_ne!(retry.run_id, distinct.run_id); + let (posts, runs, effects) = server.join().unwrap(); + assert_eq!( + posts[0], posts[1], + "retry must preserve ID, payload AND signature" + ); + assert_eq!(posts[1], posts[2]); + assert_ne!(posts[2], posts[3]); + assert_eq!( + (runs, effects), + (2, 2), + "one effect per intentional logical run" + ); +} + +#[tokio::test] +async fn prepare_keeps_scope_across_get_and_submission_refuses_switched_scope() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let state = Arc::new(crate::app_state::build_app_state()); + let keys = nostr::Keys::generate(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some(base.clone()); + let owner = keys.public_key().to_hex(); + let other_keys = nostr::Keys::generate(); + let switched = Arc::clone(&state); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut bytes = Vec::new(); + let mut one = [0; 1]; + while !bytes.ends_with(b"\r\n\r\n") { + socket.read_exact(&mut one).unwrap(); + bytes.push(one[0]); + } + // Switch after the GET was sent but before signing its revision. + *switched.keys.lock().unwrap() = other_keys; + *switched.relay_url_override.lock().unwrap() = Some("http://127.0.0.1:9".into()); + let body = format!(r#"{{"id":"{}"}}"#, "ab".repeat(32)); + write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + let id = uuid::Uuid::new_v4().to_string(); + let event = prepare_trigger(&id, &base, &owner, &state).await.unwrap(); + server.join().unwrap(); + assert_eq!(event.pubkey, keys.public_key()); + assert!(submit_trigger(id.clone(), &event, &base, &owner, &state) + .await + .unwrap_err() + .contains("community changed")); + *state.relay_url_override.lock().unwrap() = Some(base.clone()); + assert!(submit_trigger(id.clone(), &event, &base, &owner, &state) + .await + .unwrap_err() + .contains("identity changed")); + assert!(prepare_trigger(&id, &base, &owner, &state) + .await + .unwrap_err() + .contains("identity changed")); + assert!(prepare_trigger(&id, "", &owner, &state).await.is_err()); +} + +#[tokio::test] +async fn revision_and_submit_rejections_propagate_without_a_false_run_ack() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some(base.clone()); + let owner = keys.public_key().to_hex(); + let server = std::thread::spawn(move || { + let mut posts = Vec::new(); + for attempt in 0..4 { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut bytes = Vec::new(); + let mut one = [0; 1]; + while !bytes.ends_with(b"\r\n\r\n") { + socket.read_exact(&mut one).unwrap(); + bytes.push(one[0]); + } + let headers = String::from_utf8(bytes).unwrap(); + let (status, body) = if attempt == 0 { + ( + "403 Forbidden", + "{\"error\":\"revision unavailable\"}".to_string(), + ) + } else if attempt == 1 { + ("200 OK", format!(r#"{{"id":"{}"}}"#, "ab".repeat(32))) + } else { + let len: usize = headers + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length: ") + .map(str::to_owned) + }) + .unwrap() + .parse() + .unwrap(); + let mut payload = vec![0; len]; + socket.read_exact(&mut payload).unwrap(); + let event = Event::from_json(&payload).unwrap(); + posts.push(payload); + ("200 OK", serde_json::json!({"event_id":event.id.to_hex(),"accepted":attempt == 3,"message": if attempt == 2 { "stale revision".to_string() } else { "{\"run_id\":\"run-1\"}".to_string() }}).to_string()) + }; + write!( + socket, + "HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + body.len(), + body + ) + .unwrap(); + } + assert_eq!(posts[0], posts[1]); + }); + let id = uuid::Uuid::new_v4().to_string(); + assert!(prepare_trigger(&id, &base, &owner, &state) + .await + .unwrap_err() + .contains("revision unavailable")); + let event = prepare_trigger(&id, &base, &owner, &state).await.unwrap(); + assert!(submit_trigger(id.clone(), &event, &base, &owner, &state) + .await + .unwrap_err() + .contains("stale revision")); + assert_eq!( + submit_trigger(id, &event, &base, &owner, &state) + .await + .unwrap() + .run_id, + "run-1" + ); + server.join().unwrap(); +} diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index c4e5d38c8ba..093840e1798 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -318,16 +318,6 @@ pub async fn delete_workflow( Ok(()) } -#[tauri::command] -pub async fn trigger_workflow( - workflow_id: String, - state: State<'_, AppState>, -) -> Result { - let builder = events::build_workflow_trigger(&workflow_id)?; - let result = submit_event(builder, &state).await?; - trigger_wire_from_message(workflow_id, &result.message) -} - // ── Approvals ──────────────────────────────────────────────────────────────── #[tauri::command] @@ -371,7 +361,7 @@ pub async fn deny_approval( // ── Helpers (pure, unit-tested in workflows_tests.rs) ───────────────────────── -fn trigger_wire_from_message( +pub(super) fn trigger_wire_from_message( workflow_id: String, message: &str, ) -> Result { diff --git a/desktop/src-tauri/src/events/workflows.rs b/desktop/src-tauri/src/events/workflows.rs index 8615f73851f..8d29639c315 100644 --- a/desktop/src-tauri/src/events/workflows.rs +++ b/desktop/src-tauri/src/events/workflows.rs @@ -31,10 +31,15 @@ pub fn build_workflow_delete( Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags)) } -/// Kind 46020 — trigger a workflow run by id. -pub fn build_workflow_trigger(workflow_id: &str) -> Result { - let tags = vec![tag(vec!["d", workflow_id])?]; - Ok(EventBuilder::new(Kind::Custom(46020), "").tags(tags)) +/// Kind 46020 — trigger a workflow run by id, bound to one exact definition revision. +pub fn build_workflow_trigger( + workflow_id: &str, + definition_event_id: &str, +) -> Result { + let workflow_id = + uuid::Uuid::parse_str(workflow_id).map_err(|_| "invalid workflow id".to_string())?; + buzz_sdk_pkg::build_workflow_trigger(workflow_id, definition_event_id) + .map_err(|error| error.to_string()) } /// Kind 46030 — grant an approval token (with optional note). @@ -48,3 +53,24 @@ pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result( state: &AppState, path_with_query: &str, +) -> Result { + let base = relay_api_base_url_with_override(state); + let keys = state.signing_keys()?; + get_relay_json_at_with_keys(state, path_with_query, &base, &keys).await +} + +/// Authenticated GET using one caller-captured relay and signer snapshot. +pub async fn get_relay_json_at_with_keys( + state: &AppState, + path_with_query: &str, + api_base_url: &str, + keys: &nostr::Keys, ) -> Result { if !path_with_query.starts_with('/') { return Err("relay GET path must begin with '/'".to_string()); } crate::relay_admission::wait_for_rate_limit().await; - let url = format!( - "{}{}", - relay_api_base_url_with_override(state), - path_with_query - ); - let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?; + let url = format!("{}{}", api_base_url.trim_end_matches('/'), path_with_query); + let auth = build_nip98_auth_header_for_keys(keys, &Method::GET, &url, &[])?; let response = state .http_client .get(&url) diff --git a/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx b/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx index 8d53d20badb..ce904b2f755 100644 --- a/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx +++ b/desktop/src/app/AppWorkflowEditorOverlayProvider.tsx @@ -9,7 +9,7 @@ import { type WorkflowEditorTarget, } from "@/features/workflows/ui/WorkflowEditorHost"; import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane"; -import { deleteWorkflow, triggerWorkflow } from "@/shared/api/tauriWorkflows"; +import { deleteWorkflow } from "@/shared/api/tauriWorkflows"; import type { Workflow } from "@/shared/api/types"; import { WorkflowEditorOverlayProvider } from "@/shared/context/WorkflowEditorOverlayContext"; @@ -95,15 +95,6 @@ export function AppWorkflowEditorOverlayProvider({ setEditor({ mode: "duplicate", pane: INITIAL_PANE, workflowId }); }, []); - const triggerMutation = useMutation({ - mutationFn: (workflowId: string) => triggerWorkflow(workflowId), - onSuccess: () => { - void queryClient.invalidateQueries({ - predicate: (query) => query.queryKey[0] === "workflow-runs", - }); - }, - }); - const deleteMutation = useMutation({ mutationFn: (workflowId: string) => deleteWorkflow(workflowId), onSuccess: () => { @@ -115,12 +106,6 @@ export function AppWorkflowEditorOverlayProvider({ }, }); - const triggerOne = triggerMutation.mutate; - const handleTriggerWorkflow = React.useCallback( - (workflowId: string) => triggerOne(workflowId), - [triggerOne], - ); - const deleteOne = deleteMutation.mutateAsync; const handleConfirmDelete = React.useCallback( async (workflow: Workflow) => { @@ -149,7 +134,6 @@ export function AppWorkflowEditorOverlayProvider({ onDuplicateWorkflow={handleDuplicateWorkflow} onEditWorkflow={handleEditWorkflow} onEditorPaneChange={handleEditorPaneChange} - onTriggerWorkflow={handleTriggerWorkflow} workflowHint={workflowHint} /> triggerWorkflow(workflowId), - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: workflowRunsQueryKey(workflowId), - }); - }, - }); -} - export function useApprovalMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/workflows/triggerOperations.test.mjs b/desktop/src/features/workflows/triggerOperations.test.mjs new file mode 100644 index 00000000000..fb2f3e33525 --- /dev/null +++ b/desktop/src/features/workflows/triggerOperations.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools"; +import { WorkflowTriggerOperations } from "./triggerOperations.ts"; + +const scope = { + expectedRelayUrl: "wss://a.example", + expectedSignerPubkey: "a".repeat(64), +}; +const signed = () => + finalizeEvent( + { + kind: 46020, + created_at: 42, + content: "", + tags: [ + ["d", "workflow"], + ["request-id", crypto.randomUUID()], + ], + }, + generateSecretKey(), + ); + +test("production owner retains exact signed payload after committed response loss; concurrent callers join; distinct run is explicit", async () => { + const posts = []; + const runs = new Map(); + let prepared = 0; + let loseResponse = true; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const owner = new WorkflowTriggerOperations({ + prepareWorkflowTrigger: async () => { + prepared++; + await gate; + return signed(); + }, + triggerWorkflow: async (_id, event) => { + assert.ok(verifyEvent(event)); + posts.push(JSON.stringify(event)); + if (!runs.has(event.id)) runs.set(event.id, `run-${runs.size + 1}`); + if (loseResponse) { + loseResponse = false; + throw new Error("response lost after commit"); + } + return { + runId: runs.get(event.id), + workflowId: "workflow", + status: "pending", + }; + }, + }); + const key = owner.key("workflow", scope); + const first = owner.run("workflow", scope); + assert.equal(owner.state(key).status, "pending"); + assert.equal(owner.run("workflow", scope), first); + release(); + await assert.rejects(first, /response lost/); + assert.equal(owner.state(key).status, "error"); + assert.equal(owner.state(key).failurePhase, "submit"); + const result = await owner.run("workflow", scope); + assert.equal(result.runId, "run-1"); + assert.equal(prepared, 1); + assert.equal(posts[0], posts[1]); + assert.equal(runs.size, 1); + assert.equal(owner.state(key).status, "success"); + const distinct = await owner.run("workflow", scope); + assert.equal(distinct.runId, "run-2"); + assert.equal(runs.size, 2); +}); + +test("scope-keyed retry survives A to B to A; captured caller scope cannot be mutated during await", async () => { + const seen = []; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const owner = new WorkflowTriggerOperations({ + prepareWorkflowTrigger: async () => { + await gate; + return signed(); + }, + triggerWorkflow: async (_id, event, captured) => { + seen.push([event.id, { ...captured }]); + throw new Error("unknown"); + }, + }); + const mutable = { ...scope }; + const first = owner.run("workflow", mutable); + mutable.expectedRelayUrl = "wss://b.example"; + assert.equal(owner.state(owner.key("workflow", mutable)).status, "idle"); + assert.equal( + owner.state( + owner.key("workflow", { ...scope, expectedSignerPubkey: "b".repeat(64) }), + ).status, + "idle", + ); + release(); + await assert.rejects(first); + await assert.rejects(owner.run("workflow", scope)); + assert.deepEqual(seen[0], seen[1]); + assert.deepEqual(seen[0][1], scope); + await assert.rejects(owner.run("workflow", scope, true)); + assert.notEqual( + seen[2][0], + seen[0][0], + "explicit abandon creates a distinct signed trigger", + ); +}); + +test("preflight rejection retries preparation without inventing a submitted event", async () => { + let tries = 0; + const owner = new WorkflowTriggerOperations({ + prepareWorkflowTrigger: async () => { + tries++; + throw new Error("stale revision"); + }, + triggerWorkflow: async () => + assert.fail("preflight failure must not publish"), + }); + await assert.rejects(owner.run("workflow", scope), /stale revision/); + assert.equal( + owner.state(owner.key("workflow", scope)).failurePhase, + "prepare", + ); + await assert.rejects(owner.run("workflow", scope), /stale revision/); + assert.equal(tries, 2); +}); diff --git a/desktop/src/features/workflows/triggerOperations.ts b/desktop/src/features/workflows/triggerOperations.ts new file mode 100644 index 00000000000..1d2fd1676b3 --- /dev/null +++ b/desktop/src/features/workflows/triggerOperations.ts @@ -0,0 +1,119 @@ +import { + prepareWorkflowTrigger, + triggerWorkflow, +} from "@/shared/api/tauriWorkflows"; +import type { WorkflowTriggerScope } from "@/shared/api/tauriWorkflows"; +import type { RelayEvent, TriggerWorkflowResponse } from "@/shared/api/types"; + +export type TriggerState = { + status: "idle" | "pending" | "error" | "success"; + error?: string; + failurePhase?: "prepare" | "submit"; + result?: TriggerWorkflowResponse; +}; +const idle: TriggerState = { status: "idle" }; +type Operation = { + scope: Readonly; + state: TriggerState; + event?: RelayEvent; + inFlight?: Promise; +}; + +/** One session-lifetime owner across every view. Never evict ambiguous work. + * No disk journal: retry continuity ends when the app is closed/reloaded. + */ +export class WorkflowTriggerOperations { + private operations = new Map(); + private listeners = new Set<() => void>(); + private readonly api; + constructor(api = { prepareWorkflowTrigger, triggerWorkflow }) { + this.api = api; + } + key(workflowId: string, scope: WorkflowTriggerScope) { + return JSON.stringify([ + scope.expectedRelayUrl + .trim() + .replace(/^ws:/, "http:") + .replace(/^wss:/, "https:") + .replace(/\/+$/, ""), + scope.expectedSignerPubkey.trim().toLowerCase(), + workflowId, + ]); + } + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + state(key: string): TriggerState { + return this.operations.get(key)?.state ?? idle; + } + private notify() { + for (const listener of this.listeners) listener(); + } + + run( + workflowId: string, + scope: WorkflowTriggerScope, + newRun = false, + ): Promise { + const key = this.key(workflowId, scope); + let op = this.operations.get(key); + if (op?.inFlight) return op.inFlight; + // A settled success means the next explicit Trigger is a distinct run. + // Submission errors retry the same event unless the user explicitly opts out. + // Preparation errors have not published anything and may prepare again. + if (!op || newRun || op.state.status === "success") { + if (!op && this.operations.size >= 256) { + for (const [oldKey, old] of this.operations) { + if (old.state.status === "success") this.operations.delete(oldKey); + } + if (this.operations.size >= 256) + return Promise.reject( + new Error( + "Too many unresolved workflow triggers. Retry existing operations before starting more.", + ), + ); + } + op = { state: idle, scope: Object.freeze({ ...scope }) }; + this.operations.set(key, op); + } + const operation = op; + const capturedScope = operation.scope; + operation.state = { status: "pending" }; + // Schedule after inFlight is assigned: reentrant listeners and overlapping + // card/editor clicks join the same operation, including during preparation. + operation.inFlight = Promise.resolve().then(async () => { + try { + operation.event ??= await this.api.prepareWorkflowTrigger( + workflowId, + capturedScope, + ); + const result = await this.api.triggerWorkflow( + workflowId, + operation.event, + capturedScope, + ); + operation.state = { status: "success", result }; + return result; + } catch (error) { + operation.state = { + status: "error", + error: error instanceof Error ? error.message : String(error), + failurePhase: operation.event ? "submit" : "prepare", + }; + throw error; + } finally { + operation.inFlight = undefined; + this.notify(); + } + }); + this.notify(); + return operation.inFlight; + } +} + +// Deliberately retained across A→B→A. Scope is part of every key and is +// asserted by both native commands; never show A's state in B. +export const workflowTriggerOperations = new WorkflowTriggerOperations(); diff --git a/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx b/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx index 1114e2b1afa..934f037c872 100644 --- a/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx +++ b/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx @@ -20,6 +20,8 @@ import { type WorkflowActionsMenuProps = { isEnabled: boolean; + isTriggering?: boolean; + triggerLabel?: string; isTogglingEnabled?: boolean; onDelete: () => void; onDuplicate: () => void; @@ -31,6 +33,8 @@ type WorkflowActionsMenuProps = { export function WorkflowActionsMenu({ isEnabled, + isTriggering = false, + triggerLabel = "Trigger", isTogglingEnabled = false, onDelete, onDuplicate, @@ -53,9 +57,9 @@ export function WorkflowActionsMenu({ - + - Trigger + {isTriggering ? "Triggering…" : triggerLabel} diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx index 8556424f878..ff1cf32185e 100644 --- a/desktop/src/features/workflows/ui/WorkflowCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx @@ -1,3 +1,5 @@ +import { useWorkflowTriggerOperation } from "../useWorkflowTriggerOperation"; +import { WorkflowTriggerFeedback } from "./WorkflowTriggerFeedback"; import { ArrowRight, CalendarClock, @@ -39,7 +41,6 @@ type WorkflowCardProps = { isTogglingEnabled?: boolean; messagePresentation?: WorkflowMessagePresentation; onView: (workflow: Workflow) => void; - onTrigger: (workflowId: string) => void; onToggleEnabled: (workflow: Workflow) => void; onEdit: (workflow: Workflow) => void; onDuplicate: (workflow: Workflow) => void; @@ -200,7 +201,6 @@ export function WorkflowCard({ isTogglingEnabled = false, messagePresentation, onView, - onTrigger, onToggleEnabled, onEdit, onDuplicate, @@ -208,6 +208,17 @@ export function WorkflowCard({ }: WorkflowCardProps) { const [triggerAnimationSequence, setTriggerAnimationSequence] = React.useState(0); + const trigger = useWorkflowTriggerOperation(workflow.id); + const fireTrigger = (newRun = false) => { + void trigger + .run(newRun) + .then(() => { + setTriggerAnimationSequence((sequence) => sequence + 1); + }) + .catch(() => { + /* The shared operation retains the error and retry event. */ + }); + }; const isEnabled = getWorkflowEnabled(workflow.definition); const configuredTrigger = getWorkflowTriggerConfig(workflow.definition); const cardLabel = getWorkflowCardLabel(workflow.definition, { @@ -282,15 +293,21 @@ export function WorkflowCard({ onDuplicate={() => onDuplicate(workflow)} onEdit={() => onEdit(workflow)} onToggleEnabled={() => onToggleEnabled(workflow)} - onTrigger={() => { - setTriggerAnimationSequence((sequence) => sequence + 1); - onTrigger(workflow.id); - }} + onTrigger={() => fireTrigger()} + isTriggering={trigger.status === "pending" || !trigger.ready} + triggerLabel={ + trigger.status === "error" ? "Retry trigger" : "Trigger" + } showEnabledToggle={false} /> + fireTrigger()} + onNewRun={() => fireTrigger(true)} + />

(null); const workflow = workflowQuery.data; @@ -48,10 +49,6 @@ export function WorkflowDetailPanel({ ? getWorkflowTriggerSummary(workflow.definition) : null; const workflowStatus = workflow ? getWorkflowDisplayStatus(workflow) : null; - const triggerError = errorMessage( - triggerMutation.error, - "The relay did not create a workflow run.", - ); const runsError = errorMessage( runsQuery.error, "Run history could not be loaded.", @@ -59,12 +56,12 @@ export function WorkflowDetailPanel({ const selectedRunIsPendingHistory = selectedRunId !== null && !runs.some((run) => run.id === selectedRunId); - async function handleTrigger() { + async function handleTrigger(newRun = false) { try { - const response = await triggerMutation.mutateAsync(); + const response = await triggerMutation.run(newRun); setSelectedRunId(response.runId); } catch { - // React Query stores the error; keep the current selection unchanged. + // The shared operation retains the error and the exact retry event. } } @@ -119,13 +116,21 @@ export function WorkflowDetailPanel({ ) : null} {onClose ? ( + + ); + return ( +
+

Could not confirm workflow run. {state.error}

+

+ The run may already exist. Retry reuses the same signed request. + Recovery is available only while this app stays open. +

+ + + + + + + + Start another run? + + The previous request may already have created a run. Starting a + distinct run can repeat its side effects and discards the previous + retry request. Prefer Retry trigger if you want only one run. + + + + Cancel + + Start distinct run + + + + +
+ ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowsView.tsx b/desktop/src/features/workflows/ui/WorkflowsView.tsx index ef408267e60..65e2b5e8764 100644 --- a/desktop/src/features/workflows/ui/WorkflowsView.tsx +++ b/desktop/src/features/workflows/ui/WorkflowsView.tsx @@ -25,7 +25,6 @@ import type { Channel, Workflow } from "@/shared/api/types"; import { deleteWorkflow, getChannelsWorkflows, - triggerWorkflow, updateWorkflow, } from "@/shared/api/tauriWorkflows"; import { @@ -152,15 +151,6 @@ export function WorkflowsView({ const authorPresentations = useWorkflowListAuthorPresentations(workflows); const messagePresentations = useWorkflowListMessagePresentations(workflows); - const triggerMutation = useMutation({ - mutationFn: (workflowId: string) => triggerWorkflow(workflowId), - onSuccess: () => { - void queryClient.invalidateQueries({ - predicate: (query) => query.queryKey[0] === "workflow-runs", - }); - }, - }); - const deleteMutation = useMutation({ mutationFn: (workflowId: string) => deleteWorkflow(workflowId), onSuccess: () => { @@ -205,12 +195,6 @@ export function WorkflowsView({ }, }); - const triggerOne = triggerMutation.mutate; - const handleTrigger = React.useCallback( - (workflowId: string) => triggerOne(workflowId), - [triggerOne], - ); - const handleDelete = React.useCallback( (workflow: Workflow) => setDeleteTarget(workflow), [], @@ -327,7 +311,6 @@ export function WorkflowsView({ onDuplicate={handleDuplicate} onEdit={handleEdit} onToggleEnabled={handleToggleEnabled} - onTrigger={handleTrigger} onView={handleView} workflow={workflow} /> @@ -345,7 +328,6 @@ export function WorkflowsView({ onDuplicateWorkflow={onDuplicateWorkflow} onEditWorkflow={onEditWorkflow} onEditorPaneChange={onEditorPaneChange} - onTriggerWorkflow={handleTrigger} workflowHint={editorWorkflowHint} /> diff --git a/desktop/src/features/workflows/ui/workflowTriggerFeedback.test.mjs b/desktop/src/features/workflows/ui/workflowTriggerFeedback.test.mjs new file mode 100644 index 00000000000..8e45b0bed1d --- /dev/null +++ b/desktop/src/features/workflows/ui/workflowTriggerFeedback.test.mjs @@ -0,0 +1,288 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import { JSDOM } from "jsdom"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + verifyEvent, +} from "nostr-tools"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + Node: dom.window.Node, + DocumentFragment: dom.window.DocumentFragment, + CustomEvent: dom.window.CustomEvent, + MutationObserver: dom.window.MutationObserver, + getComputedStyle: dom.window.getComputedStyle, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, +}); +window.matchMedia = () => ({ + matches: false, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, +}); +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; +window.HTMLElement.prototype.scrollIntoView = () => {}; +window.HTMLElement.prototype.hasPointerCapture = () => false; +window.HTMLElement.prototype.setPointerCapture = () => {}; +window.HTMLElement.prototype.releasePointerCapture = () => {}; +after(() => dom.window.close()); + +// Actual card + detail hook + IPC adapters. Unlike the former invocation-count +// assertion, this peer signs and commits an event, loses the response, then +// verifies the exact retry bytes and returns the same persisted run identity. +test("card/detail distinguish preparation from ambiguous submission and animate only confirmed success", async () => { + const keys = generateSecretKey(); + const pubkey = getPublicKey(keys); + localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "a", + name: "A", + relayUrl: "wss://a.example", + pubkey, + addedAt: "2026-01-01", + }, + ]), + ); + localStorage.setItem("buzz-active-community-id", "a"); + const workflowId = crypto.randomUUID(); + const workflow = { + id: workflowId, + revision: "ab".repeat(32), + owner_pubkey: pubkey, + channel_id: null, + name: "Test run", + status: "active", + created_at: 1, + updated_at: 1, + definition: { + name: "Test run", + enabled: true, + trigger: { on: "message_posted" }, + steps: [{ id: "send", action: "send_message", content: "hello" }], + }, + }; + let rejectPost; + let resolvePost; + let prepares = 0; + const posts = []; + const runs = new Map(); + window.__TAURI_INTERNALS__ = { + transformCallback: () => 1, + invoke: async (command, args) => { + if (command === "get_identity") return { pubkey, display_name: "Me" }; + if (command === "get_workflow") return workflow; + if (command === "get_workflow_runs") return { runs: [], next: null }; + if (command === "get_run_approvals") return { approvals: [] }; + if (command === "prepare_workflow_trigger") { + prepares++; + if (prepares === 1) throw new Error("revision lookup failed"); + assert.equal(args.expectedRelayUrl, "wss://a.example"); + assert.equal(args.expectedSignerPubkey, pubkey); + return finalizeEvent( + { + kind: 46020, + content: "", + created_at: 42, + tags: [ + ["d", workflowId], + ["e", workflow.revision], + ["request-id", crypto.randomUUID()], + ], + }, + keys, + ); + } + if (command === "trigger_workflow") { + assert.ok(verifyEvent(args.event)); + posts.push(JSON.stringify(args.event)); + if (!runs.has(args.event.id)) + runs.set(args.event.id, "persisted-run-1"); + return new Promise((resolve, reject) => { + rejectPost = () => reject(new Error("response lost after commit")); + resolvePost = () => + resolve({ + run_id: runs.get(args.event.id), + workflow_id: workflowId, + status: "pending", + }); + }); + } + throw new Error(`unexpected command ${command}`); + }, + }; + globalThis.__TAURI_INTERNALS__ = window.__TAURI_INTERNALS__; + const React = await import("react"); + const { render, act, fireEvent, waitFor, within, cleanup } = await import( + "@testing-library/react" + ); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + ); + const { WorkflowCard } = await import("./WorkflowCard.tsx"); + const { WorkflowDetailPanel } = await import("./WorkflowDetailPanel.tsx"); + const { workflowTriggerOperations } = await import("../triggerOperations.ts"); + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false, gcTime: 0 }, + }, + }); + const noop = () => {}; + const uiWorkflow = { + ...workflow, + ownerPubkey: pubkey, + channelId: null, + createdAt: 1, + updatedAt: 1, + }; + const rendered = render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(WorkflowCard, { + workflow: uiWorkflow, + onView: noop, + onEdit: noop, + onDelete: noop, + onDuplicate: noop, + onToggleEnabled: noop, + }), + React.createElement(WorkflowDetailPanel, { workflowId }), + ), + ), + ); + try { + const card = within(rendered.getByTestId(`workflow-card-${workflowId}`)); + const detail = within(rendered.getByTestId("workflow-detail-panel")); + await waitFor(() => + assert.equal( + detail.getByRole("button", { name: "Trigger", exact: true }).disabled, + false, + ), + ); + const stack = card.getByTestId("workflow-card-action-stack"); + const originalTile = stack.firstChild; + // Use the card menu's keyboard path (Radix opens on ArrowDown). + fireEvent.keyDown(card.getByRole("button", { name: "Workflow actions" }), { + key: "ArrowDown", + }); + await waitFor(() => + assert.ok( + rendered.getByRole("menuitem", { name: "Trigger", exact: true }), + ), + ); + fireEvent.click( + rendered.getByRole("menuitem", { name: "Trigger", exact: true }), + ); + await waitFor(() => + assert.match( + card.getByRole("alert").textContent, + /revision lookup failed/, + ), + ); + for (const surface of [card, detail]) { + const text = surface.getByRole("alert").textContent; + assert.match(text, /Workflow run was not started/); + assert.doesNotMatch(text, /may already exist|same signed request/); + assert.equal( + surface.queryByRole("button", { name: "Start a distinct run…" }), + null, + ); + } + assert.equal(posts.length, 0, "preparation failure cannot publish"); + assert.equal(stack.firstChild, originalTile); + fireEvent.click( + card.getByRole("button", { name: "Retry trigger", exact: true }), + ); + await waitFor(() => assert.equal(posts.length, 1)); + assert.match(card.getByRole("status").textContent, /Triggering/); + assert.equal( + detail.getByRole("button", { name: "Triggering..." }).disabled, + true, + ); + assert.equal( + stack.firstChild, + originalTile, + "no success animation before settlement", + ); + // A second caller joins the same operation, never creates another event. + let joined; + await act(async () => { + joined = workflowTriggerOperations + .run(workflowId, { + expectedRelayUrl: "wss://a.example", + expectedSignerPubkey: pubkey, + }) + .catch(() => {}); + rejectPost(); + await joined; + }); + await waitFor(() => + assert.match( + card.getByRole("alert").textContent, + /response lost after commit/, + ), + ); + assert.match(detail.getByRole("alert").textContent, /same signed request/); + for (const surface of [card, detail]) { + assert.doesNotMatch( + surface.getByRole("alert").textContent, + /was not started/, + ); + assert.ok(surface.getByRole("button", { name: "Start a distinct run…" })); + } + assert.equal( + stack.firstChild, + originalTile, + "failure must not animate success", + ); + fireEvent.click( + card.getByRole("button", { name: "Retry trigger", exact: true }), + ); + await waitFor(() => assert.equal(posts.length, 2)); + assert.equal(posts[0], posts[1]); + assert.equal(prepares, 2); + assert.equal(runs.size, 1); + await act(async () => { + resolvePost(); + }); + await waitFor(() => + assert.match( + card.getByRole("status").textContent, + /Run created: persisted-run-1/, + ), + ); + assert.notEqual( + stack.firstChild, + originalTile, + "confirmed success starts the action animation", + ); + assert.equal(card.queryByRole("alert"), null); + } finally { + cleanup(); + client.clear(); + } +}); diff --git a/desktop/src/features/workflows/useWorkflowTriggerOperation.ts b/desktop/src/features/workflows/useWorkflowTriggerOperation.ts new file mode 100644 index 00000000000..6596cc68232 --- /dev/null +++ b/desktop/src/features/workflows/useWorkflowTriggerOperation.ts @@ -0,0 +1,41 @@ +import type { TriggerWorkflowResponse } from "@/shared/api/types"; +import { toast } from "sonner"; +import { useSyncExternalStore } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { workflowTriggerOperations as operations } from "./triggerOperations"; + +export function useWorkflowTriggerOperation(workflowId: string) { + const { activeCommunity } = useCommunities(); + const identity = useIdentityQuery().data; + const queryClient = useQueryClient(); + const scope = { + expectedRelayUrl: activeCommunity?.relayUrl ?? "", + expectedSignerPubkey: identity?.pubkey ?? "", + }; + const key = operations.key(workflowId, scope); + const state = useSyncExternalStore(operations.subscribe, () => + operations.state(key), + ); + const run = async (newRun = false) => { + let result: TriggerWorkflowResponse; + try { + result = await operations.run(workflowId, scope, newRun); + } catch (error) { + // Capacity refusal creates no operation; it still needs an accessible + // error (normal failures live in the shared inline state). + if (operations.state(key).status === "idle") toast.error(String(error)); + throw error; + } + void queryClient.invalidateQueries({ + queryKey: ["workflow-runs", workflowId], + }); + return result; + }; + return { + ...state, + run, + ready: Boolean(scope.expectedRelayUrl && scope.expectedSignerPubkey), + }; +} diff --git a/desktop/src/shared/api/tauriWorkflows.ts b/desktop/src/shared/api/tauriWorkflows.ts index ff9d886cd36..b5ec098c7f8 100644 --- a/desktop/src/shared/api/tauriWorkflows.ts +++ b/desktop/src/shared/api/tauriWorkflows.ts @@ -1,6 +1,7 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { ApprovalActionResponse, + RelayEvent, TriggerWorkflowResponse, Workflow, WorkflowApproval, @@ -261,12 +262,29 @@ export async function getRunApprovals( return raw.approvals.map(fromRawApproval); } +export type WorkflowTriggerScope = { + expectedRelayUrl: string; + expectedSignerPubkey: string; +}; + +export async function prepareWorkflowTrigger( + workflowId: string, + scope: WorkflowTriggerScope, +): Promise { + return invokeTauri("prepare_workflow_trigger", { + workflowId, + ...scope, + }); +} + export async function triggerWorkflow( workflowId: string, + event: RelayEvent, + scope: WorkflowTriggerScope, ): Promise { const raw = await invokeTauri( "trigger_workflow", - { workflowId }, + { workflowId, event, ...scope }, ); return fromRawTriggerResponse(raw); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index d54c32e4c48..7a5ba9c635f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -14667,6 +14667,22 @@ export function maybeInstallE2eTauriMocks() { return handleDeleteWorkflow( payload as Parameters[0], ); + case "prepare_workflow_trigger": { + // UI-only bridge. Native HTTP tests verify actual signing and replay. + const args = payload as { + workflowId: string; + expectedSignerPubkey: string; + }; + return { + id: crypto.randomUUID(), + pubkey: args.expectedSignerPubkey, + created_at: Math.floor(Date.now() / 1000), + kind: 46020, + tags: [["d", args.workflowId]], + content: "", + sig: "mock-trigger-signature", + }; + } case "trigger_workflow": return handleTriggerWorkflow( payload as Parameters[0],