diff --git a/.env.example b/.env.example index 8e1fe159391..61f44af72d6 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,8 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # Deployment-global operator-listener routes. Each configured Nostr identity # may register target pubkeys and receives mention notifications at its URL. +# URLs may use HTTP or HTTPS. Use HTTP only for operator-controlled internal +# connections: NIP-98 signs requests. # Format: pubkey:deliveryUrl;pubkey:deliveryUrl # BUZZ_OPERATOR_LISTENER_TIMEOUT_MS=5000 # BUZZ_OPERATOR_LISTENERS=<64-char hex pubkey>:https://listener.example/mentions diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 310ab676b75..34f33f70f39 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -61,7 +61,8 @@ jobs: name: Mobile runs-on: ubuntu-latest timeout-minutes: 30 - if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.mobile) + # The Dreamforge fork does not build or ship the phone app (owners use Block's store build). + if: inputs.lane == 'required' && github.repository == 'block/buzz' && (github.event_name == 'push' || inputs.mobile) permissions: contents: read steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c70729c00d8..50ab3910a74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,8 @@ jobs: mobile-swift-domain: name: Mobile Swift Domain needs: [changes] - if: needs.changes.outputs.mobile == 'true' + # The Dreamforge fork does not build or ship the phone app (owners use Block's store build). + if: github.repository == 'block/buzz' && needs.changes.outputs.mobile == 'true' uses: ./.github/workflows/_ci-clients.yml with: web: ${{ needs.changes.outputs.web == 'true' }} @@ -416,7 +417,8 @@ jobs: mobile: name: Mobile - if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.mobile == 'true'))) + # The Dreamforge fork does not build or ship the phone app (owners use Block's store build). + if: always() && github.repository == 'block/buzz' && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.mobile == 'true'))) needs: [changes, clients] runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/Justfile b/Justfile index 8d107af69ee..312ec115b97 100644 --- a/Justfile +++ b/Justfile @@ -448,10 +448,11 @@ test-unit: # suites are otherwise absent from the infra-free unit lane. cargo nextest run -p buzz-media --lib \ -E 'test(=bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys)' - # buzz-admin: storage-snapshot worker plus the community archive - # command parser, validation, and propagation-evidence tests. + # buzz-admin: storage-snapshot worker, the community archive command + # parser, validation, and propagation-evidence tests, and the partition + # audit outcome contract. cargo nextest run -p buzz-admin \ - -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/)' + -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the @@ -530,7 +531,7 @@ test-unit: # are infra-free; config parses env into a struct and never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, @@ -539,7 +540,9 @@ test-unit: # DB-backed audio join tests use #[ignore] and run in the postgres lane. # NIP-FI (S3/S4) relay witnesses: the wholly-new nip_fi_upgrade and # api::nip_fi modules, - # the auth metrics contract module, plus the exact NIP-FI tests added, + # the auth metrics contract module, the DB-free partition alert series + # tests (their Postgres case is #[ignore]d and runs in the PostgreSQL + # lane), plus the exact NIP-FI tests added, # or whose assertions changed, in mixed modules (audio::room, # connection, handlers::*, state). nip_fi_config and router are # selected whole by the command above. Mixed modules are listed by exact @@ -550,6 +553,7 @@ test-unit: cargo nextest run -p buzz-relay --lib -E ' test(/^nip_fi_upgrade::/) + test(/^metrics::contract_tests::/) + + test(/^metrics::partition_alert_tests::/) + test(=audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative) + test(=connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge) + test(=audio::room::tests::b1_pending_peer_removed_before_commit_emits_no_delta) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 1161dc1034e..48b767ca222 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -74,8 +74,8 @@ That's it. The harness spawns `goose acp`, connects to the relay, discovers chan [codex-acp](https://github.com/agentclientprotocol/codex-acp) wraps OpenAI Codex in an ACP interface. ```bash -# Install the adapter (npm package — no Rust build required) -npm install -g @agentclientprotocol/codex-acp +# Install the adapter (npm package — no Rust build required; pinned to the tested version) +npm install -g @agentclientprotocol/codex-acp@1.13.1 # Run export OPENAI_API_KEY="sk-..." # required — use an OpenAI API key, not a ChatGPT subscription @@ -90,8 +90,8 @@ buzz-acp [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp) wraps the Claude Agent SDK in an ACP interface. ```bash -# Install the current adapter package -npm install -g @agentclientprotocol/claude-agent-acp +# Install the adapter (pinned to the version Dreamforge is tested with) +npm install -g @agentclientprotocol/claude-agent-acp@0.70.0 # Run export ANTHROPIC_API_KEY="sk-ant-..." diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 6f41d0d0a68..5c912a56330 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -15,7 +15,7 @@ Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes 0 ok, 1 user error, 2 network, 3 auth, 4 other, 5 write conflict. Output is structured JSON. `--format compact` is global — it goes before the subcommand. -Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. +Run `buzz --help` or `buzz --help` for full usage. For multiline or non-ASCII content (em dashes, curly quotes, emoji), write the text with your file-editing tool to a new UTF-8 file in the OS temp directory, named with the channel UUID plus a timestamp or random suffix, pass its absolute path, and delete the file after sending: `buzz messages send ... --content-file /buzz--.md` (`buzz issues create` and `buzz canvas set` take `--content-file` too). Never reuse a fixed file name or write the file inside a git checkout: turns in other channels run in parallel from the same working directory and would send each other's text, and `git add -A` would commit it. On Windows always use `--content-file`: PowerShell re-encodes piped text, so every non-ASCII character arrives as `?`. Elsewhere you can also pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. When opening a pull request in response to channel work, always pass `--channel ` using the UUID from ``. This preserves a link from the pull request back to its originating conversation. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1c3531f93df..0ccacf3d1c0 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -40,7 +40,6 @@ use buzz_core::kind::{ }; use buzz_core::observer::{ decrypt_observer_payload, encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY, - OBSERVER_MAX_PLAINTEXT_LEN, }; use clap::Parser; use config::{ @@ -1310,7 +1309,7 @@ impl ObserverPublishQueue { /// Pack and remove AT MOST ONE publishable frame: the front event's /// channel, gathered queue-wide in FIFO order (packed greedily until /// adding the next event would push the envelope over - /// `OBSERVER_MAX_PLAINTEXT_LEN`). Singletons ship unwrapped. + /// `OBSERVER_FRAME_MAX_LEN`). Singletons ship unwrapped. /// /// Two invariants bound the gather: /// - A frame never mixes channels (the desktop archive indexes a frame @@ -1348,7 +1347,7 @@ impl ObserverPublishQueue { if gathering && event.channel_id == channel { picked.push(event); if picked.len() > 1 - && serialized_len(&batch_envelope(&picked)) > OBSERVER_MAX_PLAINTEXT_LEN + && serialized_len(&batch_envelope(&picked)) > OBSERVER_FRAME_MAX_LEN { // Frame full: the overflow event stays queued and leads // its channel's next slot. @@ -1534,7 +1533,7 @@ struct ObserverChunkKey { /// Flush coalesced chunks before they exceed the NIP-44 plaintext limit (65,535 bytes). /// Leave headroom for the JSON envelope wrapping the text. This is a SOFT pre-flush /// of raw text below the hard cap; `fit_observer_event_to_budget` (the final ceiling, -/// keyed to `OBSERVER_MAX_PLAINTEXT_LEN` in buzz-core/observer.rs:25) is what actually +/// keyed to `OBSERVER_FRAME_MAX_LEN`) is what actually /// guarantees the serialized frame fits. Edit one of these two and review the other. const OBSERVER_CHUNK_MAX_TEXT_BYTES: usize = 60_000; @@ -1668,8 +1667,14 @@ fn set_observer_chunk_text(payload: &mut serde_json::Value, text: String) { /// eliding actually shrinks the frame. const OBSERVER_LEAF_RETAIN_BYTES: usize = 3_000; +/// Serialized-frame ceiling for observer telemetry. nostr's NIP-44 v2 encoder +/// rejects plaintext over 65_536 - 128 bytes (its `MAX_SUPPORTED_PLAINTEXT_SIZE`), +/// tighter than buzz-core's 65_535 `OBSERVER_MAX_PLAINTEXT_LEN`, so frames fitted +/// to 65_535 failed with "NIP-44 error: message too long" and were dropped. +const OBSERVER_FRAME_MAX_LEN: usize = 65_536 - 128; + /// Trim an oversized observer telemetry frame so its SERIALIZED form fits under -/// `OBSERVER_MAX_PLAINTEXT_LEN`, instead of dropping the whole frame (silent +/// `OBSERVER_FRAME_MAX_LEN`, instead of dropping the whole frame (silent /// telemetry loss). The common case — a frame already under budget — is left /// byte-identical. /// @@ -1694,7 +1699,7 @@ const OBSERVER_LEAF_RETAIN_BYTES: usize = 3_000; /// are out of this change's scope (buzz-core stays untouched). The clean `&mut` /// signature with one cheap redundant serialize is the deliberate tradeoff. fn fit_observer_event_to_budget(event: &mut observer::ObserverEvent) { - if serialized_len(event) <= OBSERVER_MAX_PLAINTEXT_LEN { + if serialized_len(event) <= OBSERVER_FRAME_MAX_LEN { return; } @@ -1710,7 +1715,7 @@ fn fit_observer_event_to_budget(event: &mut observer::ObserverEvent) { // never be re-elided, so the loop is bounded by the leaf count. while let Some(leaf) = largest_shrinkable_leaf(&mut event.payload) { elide_leaf(leaf); - if serialized_len(event) <= OBSERVER_MAX_PLAINTEXT_LEN { + if serialized_len(event) <= OBSERVER_FRAME_MAX_LEN { return; } } @@ -6128,6 +6133,17 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("buzz messages send ... --content -")); } + #[test] + fn shared_base_prompt_teaches_content_file_for_non_ascii_on_windows() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains( + "buzz messages send ... --content-file /buzz--.md" + )); + assert!(prompt + .contains("Never reuse a fixed file name or write the file inside a git checkout")); + assert!(prompt.contains("On Windows always use `--content-file`")); + } + #[test] fn shared_base_prompt_teaches_repo_context_and_learning_loop() { let prompt = include_str!("base_prompt.md"); @@ -9826,7 +9842,7 @@ mod observer_publish_queue_tests { frames.len() ); for frame in &frames { - assert!(serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized_len(frame) <= OBSERVER_FRAME_MAX_LEN); } // Within each channel, FIFO order survives the gather. let mut seqs_a = Vec::new(); @@ -9867,7 +9883,7 @@ mod observer_publish_queue_tests { let mut seen = Vec::new(); for frame in &frames { assert!( - serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN, + serialized_len(frame) <= OBSERVER_FRAME_MAX_LEN, "every emitted frame must fit the plaintext cap" ); seen.extend(frame_seqs(frame)); @@ -13881,7 +13897,7 @@ mod observer_payload_trim_tests { fit_observer_event_to_budget(&mut event); assert!( - serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN, + serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN, "frame must fit after trimming" ); // Envelope intact. @@ -13944,14 +13960,14 @@ mod observer_payload_trim_tests { }), ); assert!( - serialized(&event).len() > OBSERVER_MAX_PLAINTEXT_LEN, + serialized(&event).len() > OBSERVER_FRAME_MAX_LEN, "precondition: oversized event body pushes the frame over the cap" ); fit_observer_event_to_budget(&mut event); assert!( - serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN, + serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN, "frame must fit after trimming" ); let blocks = event.payload["params"]["prompt"] @@ -13981,6 +13997,45 @@ mod observer_payload_trim_tests { ); } + #[test] + fn test_fitted_oversized_frame_encrypts_and_decrypts_to_valid_json() { + // 65_500 sits in the band nostr's NIP-44 encoder rejects (> 65_536 - 128) + // but the 65_535 hard cap allowed, which is where frames were dropped. + let sender = nostr::Keys::generate(); + let recipient = nostr::Keys::generate(); + let base = serialized(&event_with_payload( + "acp_read", + serde_json::json!({ "body": "" }), + )) + .len(); + for target in [65_500, 100_000] { + let body = "x".repeat(target - base); + let mut event = event_with_payload("acp_read", serde_json::json!({ "body": body })); + assert_eq!(serialized(&event).len(), target, "precondition: frame size"); + + fit_observer_event_to_budget(&mut event); + let encrypted = encrypt_observer_payload(&sender, &recipient.public_key(), &event) + .unwrap_or_else(|error| panic!("{target}-byte frame must encrypt: {error}")); + + let plaintext = nostr::nips::nip44::decrypt( + recipient.secret_key(), + &sender.public_key(), + encrypted, + ) + .expect("decrypt"); + let decoded: serde_json::Value = + serde_json::from_str(&plaintext).expect("decrypted frame is valid JSON"); + assert_eq!(decoded["kind"], "acp_read"); + assert!( + decoded["payload"]["body"] + .as_str() + .unwrap() + .contains("…[elided"), + "{target}-byte frame was trimmed with the elision marker" + ); + } + } + #[test] fn test_multi_leaf_elides_largest_shrinkable_first_and_stops_when_it_fits() { // One leaf alone over the cap; a second smaller-but-still-large leaf. @@ -13994,7 +14049,7 @@ mod observer_payload_trim_tests { ); fit_observer_event_to_budget(&mut event); - assert!(serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN); assert!( event.payload["huge"].as_str().unwrap().contains("…[elided"), "the largest leaf is elided" @@ -14024,7 +14079,7 @@ mod observer_payload_trim_tests { ); fit_observer_event_to_budget(&mut event); - assert!(serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN); let text = event.payload["params"]["update"]["content"]["text"] .as_str() .unwrap(); @@ -14042,13 +14097,13 @@ mod observer_payload_trim_tests { .collect(); let mut event = event_with_payload("acp_read", serde_json::json!({ "items": items })); assert!( - serialized(&event).len() > OBSERVER_MAX_PLAINTEXT_LEN, + serialized(&event).len() > OBSERVER_FRAME_MAX_LEN, "precondition: frame is over the cap" ); fit_observer_event_to_budget(&mut event); - assert!(serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN); assert_eq!( event.payload["elided"].as_str().unwrap(), "acp_read payload too large", @@ -14079,7 +14134,7 @@ mod observer_payload_trim_tests { let mut event = event_with_payload("acp_read", serde_json::json!({ "body": big })); fit_observer_event_to_budget(&mut event); - assert!(serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN); let leaf = event.payload["body"].as_str().unwrap(); // Valid UTF-8 by construction (it's a &str); confirm head/tail are whole // multi-byte chars and the marker is present. diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 21e43d4dacf..758dcdad8a4 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -279,7 +279,7 @@ impl SetupPayload { let footer = if has_doctor_requirement { "Open Agent runtimes in Settings, install Git for Windows, then re-check and restart the agent.".to_string() } else if all_missing_binary { - "Install the missing binary or update PATH, then restart Buzz.".to_string() + "Install the missing binary or update PATH, then restart Dreamforge.".to_string() } else if all_external { // All requirements are external config files — Edit Agent cannot // help. Don't send the user there. @@ -797,7 +797,7 @@ mod tests { )); let body = payload.nudge_body(); assert!(body.contains("install `buzz-pi-acp` or add it to PATH")); - assert!(body.contains("restart Buzz")); + assert!(body.contains("restart Dreamforge")); assert!(!body.contains("Open Edit Agent")); } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 66780e20419..33fc74b6d1d 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -31,7 +31,10 @@ use std::time::Instant; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; use buzz_core::tenant::{relay_url_authority, TenantContext}; -use buzz_db::{partition::PartitionAuditReport, Db, DbConfig}; +use buzz_db::{ + partition::{PartitionAuditOutcome, PartitionAuditReport}, + Db, DbConfig, +}; use buzz_media::{BucketSnapshot, MediaConfig, MediaStorage, S3AddressingStyle, SweepError}; use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; @@ -358,7 +361,7 @@ struct PartitionAuditOutput { source_sha: &'static str, build_id: &'static str, build_url: &'static str, - outcome: &'static str, + outcome: PartitionAuditOutcome, months_ahead: u32, identity: PartitionAuditIdentity, report: PartitionAuditReport, @@ -399,20 +402,8 @@ async fn cmd_partition_audit(months_ahead: u32) -> Result { let report = Db::from_pool(pool) .audit_partitions_report(months_ahead) .await; - let outcome = if !report.errors.is_empty() { - "error" - } else if !report.serving_safe() { - "unsafe" - } else if report.tables.iter().any(|table| table.degraded()) { - "degraded" - } else { - "ok" - }; - let code = match outcome { - "error" => 5, - "unsafe" => 2, - _ => 0, - }; + let outcome = report.outcome(); + let code = partition_audit_exit_code(outcome); println!( "{}", serde_json::to_string_pretty(&PartitionAuditOutput { @@ -430,6 +421,15 @@ async fn cmd_partition_audit(months_ahead: u32) -> Result { Ok(code) } +/// Exit nonzero only when the audit is incomplete or a table cannot serve. +fn partition_audit_exit_code(outcome: PartitionAuditOutcome) -> i32 { + match outcome { + PartitionAuditOutcome::Error => 5, + PartitionAuditOutcome::Unsafe => 2, + PartitionAuditOutcome::Degraded | PartitionAuditOutcome::Ok => 0, + } +} + async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { if let Err(msg) = validate_role(&role) { eprintln!("error: {msg}"); @@ -1090,4 +1090,17 @@ mod tests { assert!(command.is_err()); } + + #[test] + fn partition_audit_outcomes_keep_their_json_names_and_exit_codes() { + for (outcome, name, code) in [ + (PartitionAuditOutcome::Ok, "ok", 0), + (PartitionAuditOutcome::Degraded, "degraded", 0), + (PartitionAuditOutcome::Unsafe, "unsafe", 2), + (PartitionAuditOutcome::Error, "error", 5), + ] { + assert_eq!(serde_json::to_value(outcome).unwrap(), name); + assert_eq!(partition_audit_exit_code(outcome), code, "{name}"); + } + } } diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json index fb291a7db54..95ab9ff5910 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -44,7 +44,7 @@ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ], "system_prompt": null, - "turn_timeout_seconds": 300 + "turn_timeout_seconds": 0 }, "provider_config": { "namespace": "buzz-agents-test", diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 29ec19a04c2..c610a65ab11 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -32,6 +32,7 @@ export BUZZ_RELAY_URL="https://relay.example.com" buzz messages send --channel --content "Hello" buzz messages send --channel --content "Reply" --reply-to --broadcast buzz messages send --channel --content - < message.md # read body from stdin +buzz messages send --channel --content-file message.md # UTF-8 file; use on Windows (PowerShell pipes drop non-ASCII) buzz messages get --channel --limit 20 buzz messages thread --channel --event buzz messages thread --link 'buzz://message?channel=&id=&thread=' diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index f956d0f7f9d..1fd1ddc2b1a 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -15,7 +15,7 @@ use crate::commands::agents::fetch_archived_snapshot; use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster}; use crate::commands::users::presence_subject; use crate::error::CliError; -use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; +use crate::validate::{parse_uuid, read_content_or_file, validate_hex64, validate_uuid}; fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value { serde_json::json!({ @@ -1825,7 +1825,6 @@ pub async fn cmd_set_canvas( channel_id: &str, content: &str, ) -> Result<(), CliError> { - let content = read_or_stdin(content)?; let channel_uuid = parse_uuid(channel_id)?; // `set` is an unconditional replace — no `expected-revision` tag, so the @@ -1846,11 +1845,11 @@ pub async fn cmd_set_canvas( let head_created_at = head.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); buzz_sdk::build_set_canvas_unconditional_after_head( channel_uuid, - &content, + content, head_created_at, ) } - None => buzz_sdk::build_set_canvas(channel_uuid, &content, None), + None => buzz_sdk::build_set_canvas(channel_uuid, content, None), } .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; @@ -1971,7 +1970,14 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu CanvasCmd::Get { channel, revision } => { cmd_get_canvas(client, &channel, revision.as_deref()).await } - CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await, + CanvasCmd::Set { + channel, + content, + content_file, + } => { + let content = read_content_or_file(content.as_deref(), content_file.as_deref())?; + cmd_set_canvas(client, &channel, &content).await + } CanvasCmd::History { channel, limit } => cmd_canvas_history(client, &channel, limit).await, CanvasCmd::Restore { channel, revision } => { cmd_restore_canvas(client, &channel, &revision, &mut std::io::stdout()).await diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 7c90d47b423..8ae756617d5 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -4,7 +4,9 @@ use crate::client::BuzzClient; use crate::commands::with_git_provenance; use crate::commands::GIT_ORIGIN_CHANNEL_ENV; use crate::error::CliError; -use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; +use crate::validate::{ + read_content_or_file, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, +}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; use nostr::Timestamp; use serde::Deserialize; @@ -240,7 +242,6 @@ pub async fn cmd_create_issue( ) -> Result<(), CliError> { validate_hex64(repo_owner)?; validate_repo_id(repo_id)?; - let body = read_or_stdin(content)?; let meta = GitIssueMeta { labels: labels.to_vec(), @@ -253,7 +254,7 @@ pub async fn cmd_create_issue( }; let builder = with_git_provenance( - buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, + buzz_sdk::build_git_issue(&repo, subject, content, &meta).map_err(sdk_err)?, )?; let event = client.sign_event(builder)?; let event_id = event.id.to_hex(); @@ -598,9 +599,12 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), channel, title, content, + content_file, label, to, } => { + // Read the body first so an unreadable file fails before a repo is created. + let body = read_content_or_file(content.as_deref(), content_file.as_deref())?; let (repo_owner, repo_id) = resolve_issue_repo_target( client, repo_owner.as_deref(), @@ -608,7 +612,7 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), channel.as_deref(), ) .await?; - cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await + cmd_create_issue(client, &repo_owner, &repo_id, &title, &body, &label, &to).await } IssuesCmd::Get { event } => cmd_get_issue(client, &event).await, IssuesCmd::List { diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d316..bd53ac1da74 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ - infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, + infer_language, parse_event_id, parse_uuid, read_content_or_file, read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ @@ -608,15 +608,7 @@ pub struct SendMessageParams { pub mentions: Vec, } -pub async fn cmd_send_message( - client: &BuzzClient, - mut p: SendMessageParams, -) -> Result<(), CliError> { - // Allow '-' to read content from stdin. This keeps callers from having to - // jam shell-metacharacter-heavy text (backticks, $vars, etc.) through argv - // quoting — the source of countless self-inflicted command-substitution - // bugs for agent and human users alike. - p.content = read_or_stdin(&p.content)?; +pub async fn cmd_send_message(client: &BuzzClient, p: SendMessageParams) -> Result<(), CliError> { validate_content_size(&p.content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; @@ -940,6 +932,7 @@ pub async fn dispatch( MessagesCmd::Send { channel, content, + content_file, kind, reply_to, broadcast, @@ -950,7 +943,12 @@ pub async fn dispatch( client, SendMessageParams { channel_id: channel, - content, + // Allow '-' (stdin) or --content-file. This keeps callers from + // having to jam shell-metacharacter-heavy text (backticks, + // $vars, etc.) through argv quoting — the source of countless + // self-inflicted command-substitution bugs for agent and human + // users alike. + content: read_content_or_file(content.as_deref(), content_file.as_deref())?, kind, reply_to, broadcast, @@ -1815,6 +1813,40 @@ mod tests { ); } + #[tokio::test] + async fn send_content_file_preserves_non_ascii_through_cli_dispatch() { + use clap::Parser; + // Windows PowerShell pipes turn these into `?`; --content-file must not. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("msg.txt"); + std::fs::write(&path, "\u{feff}Ship it \u{2014} now \u{1f680}\n").unwrap(); + + let cli = crate::Cli::try_parse_from([ + "buzz", + "messages", + "send", + "--channel", + SEND_TEST_CHANNEL, + "--content-file", + path.to_str().unwrap(), + ]) + .unwrap(); + let crate::Cmd::Messages(cmd) = cli.command else { + panic!("expected a messages command"); + }; + + let (url, _, captured_event) = fake_send_relay(send_palette_response()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + super::dispatch(cmd, &client, &crate::OutputFormat::Json) + .await + .unwrap(); + + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + assert_eq!(event["content"], "Ship it \u{2014} now \u{1f680}\n"); + } + #[tokio::test] async fn cmd_send_message_succeeds_when_palette_query_errors() { // Palette enrichment is decorative — a 500 from the `/query` endpoint diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089c..2ee4d5bbb51 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -410,15 +410,19 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -\n buzz messages send --channel --content-file message.md # UTF-8 file; use this on Windows" )] Send { /// Channel UUID (from 'buzz channels list') #[arg(long)] channel: String, /// Message text — supports @mentions and markdown. Use '-' to read from stdin. - #[arg(long)] - content: String, + #[arg(long, required_unless_present = "content_file")] + content: Option, + /// Read the message text from a UTF-8 file. Use this on Windows, where + /// PowerShell pipes turn non-ASCII characters into '?'. + #[arg(long, conflicts_with = "content")] + content_file: Option, /// Nostr event kind (default: channel default) #[arg(long)] kind: Option, @@ -771,8 +775,11 @@ pub enum CanvasCmd { #[arg(long)] channel: String, /// Canvas content (markdown; use '-' to read from stdin) - #[arg(long)] - content: String, + #[arg(long, required_unless_present = "content_file")] + content: Option, + /// Read the canvas content from a UTF-8 file (use this on Windows) + #[arg(long, conflicts_with = "content")] + content_file: Option, }, /// List canvas revision history for a channel, newest first History { @@ -1790,8 +1797,11 @@ pub enum IssuesCmd { #[arg(long, alias = "subject")] title: String, /// Issue body, markdown. Use '-' to read from stdin. - #[arg(long)] - content: String, + #[arg(long, required_unless_present = "content_file")] + content: Option, + /// Read the issue body from a UTF-8 file (use this on Windows) + #[arg(long, conflicts_with = "content")] + content_file: Option, /// Label — can be specified multiple times #[arg(long = "label")] label: Vec, @@ -2352,6 +2362,29 @@ mod tests { } } + #[test] + fn content_and_content_file_are_mutually_exclusive() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + for cmd in [ + &["messages", "send", "--channel", channel][..], + &["canvas", "set", "--channel", channel][..], + &["issues", "create", "--title", "t"][..], + ] { + let parse = |extra: &[&str]| { + Cli::try_parse_from(["buzz"].iter().chain(cmd).chain(extra).copied()) + }; + let both = parse(&["--content", "x", "--content-file", "m.txt"]); + assert_eq!( + both.err().map(|e| e.kind()), + Some(clap::error::ErrorKind::ArgumentConflict), + "{cmd:?}" + ); + assert!(parse(&[]).is_err(), "{cmd:?} must require one of them"); + assert!(parse(&["--content", "x"]).is_ok(), "{cmd:?}"); + assert!(parse(&["--content-file", "m.txt"]).is_ok(), "{cmd:?}"); + } + } + #[test] fn set_status_requires_text_or_clear() { assert!(Cli::try_parse_from(["buzz", "users", "set-status"]).is_err()); diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b441417..55b66fed9aa 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -178,6 +178,33 @@ pub fn read_or_stdin(value: &str) -> Result { } } +/// Resolve a `--content` value (literal text, or `-` for stdin) or, when +/// given, a `--content-file` path read as UTF-8. +/// +/// The file path exists for Windows: PowerShell 5.1 re-encodes piped text +/// with `$OutputEncoding` (ASCII), so non-ASCII characters reach stdin as `?`. +pub fn read_content_or_file( + content: Option<&str>, + content_file: Option<&str>, +) -> Result { + let Some(path) = content_file else { + return read_or_stdin(content.unwrap_or_default()); + }; + let bytes = std::fs::read(path) + .map_err(|e| CliError::Usage(format!("failed to read {path:?}: {e}")))?; + let text = String::from_utf8(bytes).map_err(|e| { + CliError::Usage(format!( + "{path:?} is not valid UTF-8 (invalid byte at offset {}); save it as UTF-8", + e.utf8_error().valid_up_to() + )) + })?; + // `Set-Content -Encoding UTF8` in Windows PowerShell 5.1 writes a BOM. + Ok(match text.strip_prefix('\u{feff}') { + Some(rest) => rest.to_owned(), + None => text, + }) +} + /// Read content from a file path, or stdin if the value is "-". /// /// Unlike [`read_or_stdin`], `value` is never treated as literal content — @@ -503,4 +530,28 @@ mod tests { let err = super::read_file_or_stdin("0001-does-not-exist.patch").unwrap_err(); assert!(matches!(err, CliError::Usage(_))); } + + // --- read_content_or_file --- + + #[test] + fn read_content_or_file_strips_one_bom_and_rejects_invalid_utf8() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("msg.txt"); + let path_str = path.to_str().unwrap(); + + std::fs::write(&path, "\u{feff}\u{feff}a — b 🚀").unwrap(); + assert_eq!( + super::read_content_or_file(None, Some(path_str)).unwrap(), + "\u{feff}a — b 🚀", + "exactly one leading BOM is dropped" + ); + + // Windows-1252 em dash: what an ANSI-encoded save produces. + std::fs::write(&path, b"a \x97 b").unwrap(); + let err = super::read_content_or_file(None, Some(path_str)).unwrap_err(); + assert!( + matches!(&err, CliError::Usage(msg) if msg.contains("not valid UTF-8")), + "got {err:?}" + ); + } } diff --git a/crates/buzz-db/src/store/partition.rs b/crates/buzz-db/src/store/partition.rs index 02dfa4e7087..8322313de3c 100644 --- a/crates/buzz-db/src/store/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -186,7 +186,7 @@ pub struct PartitionTableAudit { pub children: Vec, /// Cached effective bounds for every routable leaf in the catalog tree. pub coverage_leaves: Vec, - /// Coverage of the current month and the requested future months. + /// Coverage of the current month, first, and the requested future months. pub months: Vec, /// Whether a parseable routable leaf covers the audit timestamp. pub serving_safe: bool, @@ -234,15 +234,18 @@ impl PartitionTableAudit { } /// Whether the table is serving but has state requiring operator attention. + /// + /// A legacy leaf that ends at or before the current month start is + /// historical: it stays in `children` but no longer degrades the table. pub fn degraded(&self) -> bool { !self.partition_key_valid || self.children.iter().any(|child| { matches!( child.kind, - PartitionChildKind::LegacyLeaf - | PartitionChildKind::Default - | PartitionChildKind::Anomalous - ) || !child.missing_triggers.is_empty() + PartitionChildKind::Default | PartitionChildKind::Anomalous + ) || (child.kind == PartitionChildKind::LegacyLeaf + && !self.ends_by_current_month(child)) + || !child.missing_triggers.is_empty() || !child.extra_triggers.is_empty() || child.catch_all_nonempty == Some(true) || child.default_nonempty == Some(true) @@ -253,6 +256,18 @@ impl PartitionTableAudit { .any(|month| month.kind != MonthCoverageKind::CoveredByMonthly) } + /// Whether `child` has a finite upper bound at or before the audited month start. + /// + /// Without an audited month, no child is treated as historical. + fn ends_by_current_month(&self, child: &PartitionChildAudit) -> bool { + self.months.first().is_some_and(|current_month| { + matches!( + &child.upper, + Some(PartitionBound::Finite(upper)) if *upper <= current_month.start + ) + }) + } + /// Whether the catalog is understood well enough to authorize automatic DDL. fn structurally_safe_for_creation(&self) -> bool { self.partition_key_valid @@ -297,6 +312,20 @@ pub struct PartitionAuditReport { pub errors: Vec, } +/// Operator-facing verdict for a best-effort catalog audit report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PartitionAuditOutcome { + /// Every managed parent is serving safely without operator attention. + Ok, + /// Every managed parent is serving safely, but one needs operator attention. + Degraded, + /// The report is complete, but a managed parent is not serving safely. + Unsafe, + /// At least one managed parent could not be audited. + Error, +} + impl PartitionAuditReport { /// Whether the report is complete and every managed parent is serving safely. pub fn serving_safe(&self) -> bool { @@ -305,6 +334,19 @@ impl PartitionAuditReport { && self.tables.iter().all(|table| table.serving_safe) } + /// Most severe verdict across audit errors, serving safety, and degradation. + pub fn outcome(&self) -> PartitionAuditOutcome { + if !self.errors.is_empty() { + PartitionAuditOutcome::Error + } else if !self.serving_safe() { + PartitionAuditOutcome::Unsafe + } else if self.tables.iter().any(PartitionTableAudit::degraded) { + PartitionAuditOutcome::Degraded + } else { + PartitionAuditOutcome::Ok + } + } + fn into_complete_audit(self) -> Result { if self.errors.is_empty() { Ok(PartitionAudit { @@ -421,6 +463,7 @@ async fn audit_partition_catalog_report_at_with_timeout( "outcome" => "error" ) .increment(1); + refresh_last_success_recency(table); metrics::histogram!( "buzz_partition_audit_duration_seconds", "table" => table @@ -438,6 +481,7 @@ async fn audit_partition_catalog_report_at_with_timeout( "outcome" => "error" ) .increment(1); + refresh_last_success_recency(table); metrics::histogram!( "buzz_partition_audit_duration_seconds", "table" => table @@ -923,17 +967,7 @@ async fn audit_table_on( .map(|leaf| leaf.audit) .collect::>(); - let mut months = Vec::with_capacity(months_ahead as usize + 1); - for offset in 0..=months_ahead { - let (year, month) = add_months(now.year(), now.month(), offset)?; - let start = month_start(year, month)?; - let (end_year, end_month) = add_months(year, month, 1)?; - let end = month_start(end_year, end_month)?; - months.push(MonthCoverage { - start, - kind: coverage_for_range(&coverage_leaves, &start, &end), - }); - } + let months = month_coverage(&coverage_leaves, months_ahead, now)?; let serving_safe = coverage_leaves .iter() @@ -951,6 +985,26 @@ async fn audit_table_on( }) } +/// Classify the month containing `now`, then each lookahead month, in order. +fn month_coverage( + coverage_leaves: &[PartitionLeafAudit], + months_ahead: i32, + now: DateTime, +) -> Result> { + let mut months = Vec::with_capacity(months_ahead as usize + 1); + for offset in 0..=months_ahead { + let (year, month) = add_months(now.year(), now.month(), offset)?; + let start = month_start(year, month)?; + let (end_year, end_month) = add_months(year, month, 1)?; + let end = month_start(end_year, end_month)?; + months.push(MonthCoverage { + start, + kind: coverage_for_range(coverage_leaves, &start, &end), + }); + } + Ok(months) +} + fn validated_months_ahead(months_ahead: u32) -> Result { if months_ahead > MAX_PARTITION_MONTHS_AHEAD { return Err(DbError::InvalidData(format!( @@ -987,6 +1041,35 @@ fn qualified_relation_name(schema: &str, relation: &str) -> String { ) } +/// Export every `buzz_partition_audit_runs_total` series at zero. +/// +/// Prometheus `increase()` ignores the first sample of a new series, so an +/// outcome first seen mid-run would otherwise be invisible to rate-based +/// alerts. Call once at process start, before the first audit. +pub fn initialize_audit_metric_series() { + for &table in PARTITIONED_TABLES { + for outcome in ["ok", "degraded", "error"] { + metrics::counter!( + "buzz_partition_audit_runs_total", + "table" => table, + "outcome" => outcome + ) + .increment(0); + } + } +} + +/// Keep a table's last success timestamp exported through failed audits +/// without changing it, so the exporter's idle timeout cannot evict it. A +/// table that never succeeded exports 0, which reads as maximally stale. +fn refresh_last_success_recency(table: &'static str) { + metrics::gauge!( + "buzz_partition_audit_last_success_timestamp_seconds", + "table" => table + ) + .increment(0.0); +} + fn emit_audit_metrics(audit: &PartitionTableAudit, duration_seconds: f64, now: DateTime) { let outcome = if audit.degraded() { "degraded" } else { "ok" }; let uncovered = audit @@ -1788,6 +1871,335 @@ mod tests { assert_eq!(extra, Some(1.0)); } + fn month_bound(year: i32, month: u32) -> PartitionBound { + PartitionBound::Finite(Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0).unwrap()) + } + + fn child_audit( + name: &str, + kind: PartitionChildKind, + lower: PartitionBound, + upper: PartitionBound, + ) -> PartitionChildAudit { + PartitionChildAudit { + schema: "public".to_string(), + name: name.to_string(), + relation_kind: "r".to_string(), + catch_all_nonempty: (upper == PartitionBound::MaxValue).then_some(false), + lower: Some(lower), + upper: Some(upper), + kind, + pending_detach: false, + default_nonempty: None, + missing_triggers: Vec::new(), + extra_triggers: Vec::new(), + } + } + + /// The repaired incident catalog, classified and covered by production code at `now`. + fn repaired_incident_audit(table: &'static str, now: DateTime) -> PartitionTableAudit { + let mut bounds = vec![ + ( + format!("{table}_p_past"), + PartitionBound::MinValue, + month_bound(2026, 7), + ), + ( + format!("{table}_p2026_07_08_legacy"), + month_bound(2026, 7), + month_bound(2026, 9), + ), + ]; + for month in 9..=12 { + let (end_year, end_month) = add_months(2026, month, 1).unwrap(); + bounds.push(( + format!("{table}_p2026_{month:02}"), + month_bound(2026, month), + month_bound(end_year, end_month), + )); + } + bounds.push(( + format!("{table}_p_future_next"), + month_bound(2027, 1), + PartitionBound::MaxValue, + )); + let children = bounds + .iter() + .map(|(name, lower, upper)| { + let kind = classify_child("r", table, name, Some(lower), Some(upper)); + child_audit(name, kind, lower.clone(), upper.clone()) + }) + .collect::>(); + let coverage_leaves = bounds + .into_iter() + .map(|(name, lower, upper)| PartitionLeafAudit { + schema: "public".to_string(), + root_child_schema: "public".to_string(), + root_child: name.clone(), + name, + catch_all_nonempty: (upper == PartitionBound::MaxValue).then_some(false), + lower, + upper, + nested: false, + is_default: false, + default_routed: false, + routing_exclusions: Vec::new(), + default_nonempty: None, + }) + .collect::>(); + let months = month_coverage(&coverage_leaves, 3, now).expect("month coverage"); + let serving_safe = coverage_leaves + .iter() + .any(|leaf| leaf_covers_timestamp(leaf, &now)); + PartitionTableAudit { + table, + partition_key: expected_partition_key(table).map(str::to_string), + expected_partition_key: expected_partition_key(table).unwrap(), + partition_key_valid: true, + children, + coverage_leaves, + months, + serving_safe, + } + } + + #[test] + fn repaired_incident_layout_is_historical_once_its_months_have_passed() { + for (now, degraded, outcome) in [ + ( + Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(), + true, + PartitionAuditOutcome::Degraded, + ), + ( + // The legacy upper bound equals the current month start. + Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap(), + false, + PartitionAuditOutcome::Ok, + ), + ( + Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap(), + false, + PartitionAuditOutcome::Ok, + ), + ] { + let report = PartitionAuditReport { + audited_at: now, + tables: PARTITIONED_TABLES + .iter() + .map(|table| repaired_incident_audit(table, now)) + .collect(), + errors: Vec::new(), + }; + assert!(report.serving_safe(), "{now}"); + assert_eq!(report.outcome(), outcome, "{now}"); + for table in &report.tables { + assert_eq!(table.degraded(), degraded, "{} at {now}", table.table); + assert!(table + .months + .iter() + .all(|month| month.kind == MonthCoverageKind::CoveredByMonthly)); + // Historical leaves stay visible to operators. + assert_eq!( + table + .children + .iter() + .filter(|child| child.kind == PartitionChildKind::LegacyLeaf) + .map(|child| child.name.as_str()) + .collect::>(), + [format!("{}_p2026_07_08_legacy", table.table)] + ); + } + let json = serde_json::to_value(&report).expect("serialize report"); + assert!(json["tables"][0]["children"] + .as_array() + .expect("children") + .iter() + .any(|child| child["name"] == "events_p2026_07_08_legacy" + && child["kind"] == "legacy_leaf")); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, || { + emit_audit_metrics(&report.tables[0], 0.01, now) + }); + let run_outcome = snapshotter + .snapshot() + .into_vec() + .into_iter() + .find_map(|(key, _, _, _)| { + (key.key().name() == "buzz_partition_audit_runs_total").then(|| { + key.key() + .labels() + .find(|label| label.key() == "outcome") + .map(|label| label.value().to_string()) + }) + }) + .flatten(); + let expected = if degraded { "degraded" } else { "ok" }; + assert_eq!(run_outcome.as_deref(), Some(expected), "{now}"); + } + } + + #[test] + fn report_outcome_ranks_errors_then_unsafe_then_degraded() { + let august = Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(); + let degraded_tables = || { + PARTITIONED_TABLES + .iter() + .map(|table| repaired_incident_audit(table, august)) + .collect::>() + }; + let mut incomplete = degraded_tables(); + incomplete.pop(); + let error = PartitionAuditError { + table: "delivery_log", + error: "statement timeout".to_string(), + }; + for (tables, errors, outcome) in [ + ( + degraded_tables(), + Vec::new(), + PartitionAuditOutcome::Degraded, + ), + ( + incomplete.clone(), + Vec::new(), + PartitionAuditOutcome::Unsafe, + ), + (incomplete, vec![error], PartitionAuditOutcome::Error), + ] { + let report = PartitionAuditReport { + audited_at: august, + tables, + errors, + }; + assert_eq!(report.outcome(), outcome); + } + } + + #[test] + fn legacy_leaf_degrades_until_its_upper_bound_reaches_the_current_month() { + let now = Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap(); + let september = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap(); + let historical = || { + child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 6), + month_bound(2026, 8), + ) + }; + let with = |child: PartitionChildAudit| vec![child]; + let mut missing_trigger = historical(); + missing_trigger.missing_triggers = vec!["partition_probe".to_string()]; + let mut extra_trigger = historical(); + extra_trigger.extra_triggers = vec!["child_only_probe".to_string()]; + let mut anomalous = historical(); + anomalous.kind = PartitionChildKind::Anomalous; + let mut default = historical(); + default.kind = PartitionChildKind::Default; + let cases = [ + ( + "ends before the current month", + with(historical()), + 3, + false, + ), + ( + "ends exactly at the current month start", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 7), + month_bound(2026, 9), + )), + 3, + false, + ), + ( + "ends one second into the current month", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 7), + PartitionBound::Finite(september + chrono::Duration::seconds(1)), + )), + 3, + true, + ), + ( + "covers lookahead months", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 10), + month_bound(2026, 12), + )), + 3, + true, + ), + ( + "starts beyond the lookahead", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2027, 6), + month_bound(2027, 8), + )), + 3, + true, + ), + ( + "historical but missing a trigger", + with(missing_trigger), + 3, + true, + ), + ( + "historical but has an extra trigger", + with(extra_trigger), + 3, + true, + ), + ( + "historical bounds on an anomalous child", + with(anomalous), + 3, + true, + ), + ( + "historical bounds on a default child", + with(default), + 3, + true, + ), + ("no audited month to compare", with(historical()), -1, true), + ]; + for (case, children, months_ahead, degraded) in cases { + let months = (0..=months_ahead) + .map(|offset| { + let (year, month) = add_months(2026, 9, offset).unwrap(); + MonthCoverage { + start: month_start(year, month).unwrap(), + kind: MonthCoverageKind::CoveredByMonthly, + } + }) + .collect(); + let audit = PartitionTableAudit { + table: "events", + partition_key: Some("RANGE (created_at)".to_string()), + expected_partition_key: "RANGE (created_at)", + partition_key_valid: true, + children, + coverage_leaves: Vec::new(), + months, + serving_safe: true, + }; + assert_eq!(audit.degraded(), degraded, "{case} at {now}"); + } + } + pub(super) mod postgres_tests { use sqlx::postgres::PgPoolOptions; use uuid::Uuid; @@ -2480,7 +2892,20 @@ mod tests { .months .iter() .all(|month| month.kind == MonthCoverageKind::CoveredByMonthly)); + assert!( + !table.degraded(), + "{} is historical in September", + table.table + ); } + + let september = audit_partition_catalog_report_at(&pool, 3, fixed_now()).await; + assert_eq!(september.outcome(), PartitionAuditOutcome::Ok); + let august = Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(); + let august = audit_partition_catalog_report_at(&pool, 3, august).await; + assert!(august.serving_safe()); + assert!(august.tables.iter().all(PartitionTableAudit::degraded)); + assert_eq!(august.outcome(), PartitionAuditOutcome::Degraded); assert_eq!(catalog_snapshot(&pool).await, before); drop_schema(&admin, &schema).await; } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 44662bb9e10..45c8f5e35d2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -289,7 +289,7 @@ pub struct Config { /// skipped — a typo must not silently disable an operator. pub relay_operator_pubkeys: Vec, - /// Configured operator-listener identities and their HTTPS delivery URLs. + /// Configured operator-listener identities and their HTTP(S) delivery URLs. pub operator_listener_delivery_urls: HashMap, /// Allow NIP-OA owner attestation for relay membership. @@ -532,7 +532,7 @@ fn parse_operator_listener_delivery_urls( "BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}" )) })?; - if url.scheme() != "https" + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() || !url.username().is_empty() || url.password().is_some() @@ -540,7 +540,7 @@ fn parse_operator_listener_delivery_urls( || url.fragment().is_some() { return Err(ConfigError::InvalidValue( - "BUZZ_OPERATOR_LISTENERS endpoints must be HTTPS URLs without credentials, query, or fragment" + "BUZZ_OPERATOR_LISTENERS endpoints must be HTTP(S) URLs without credentials, query, or fragment" .to_string(), )); } @@ -2022,7 +2022,7 @@ mod tests { #[test] fn partition_manager_config_has_bounded_interval_and_create_kill_switch() { - let _guard = ENV_MUTEX.lock().unwrap(); + let _guards = env_guards(); let previous_interval = std::env::var_os("BUZZ_PARTITION_AUDIT_INTERVAL_SECS"); let previous_create = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED"); @@ -2054,7 +2054,7 @@ mod tests { #[test] fn partition_manager_create_kill_switch_parses_false_values_strictly() { - let _guard = ENV_MUTEX.lock().unwrap(); + let _guards = env_guards(); let previous = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED"); for value in ["FALSE", "off", " false "] { @@ -2686,9 +2686,14 @@ mod tests { fn operator_listener_routes_reject_unsafe_or_duplicate_entries() { let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; for raw in [ - format!("{key}:http://listener.example/mentions"), + format!("{key}:ftp://listener.example/mentions"), + format!("{key}:http://user:pass@listener:8080/mentions"), + format!("{key}:http://listener:8080/mentions?token=secret"), + format!("{key}:http://listener:8080/mentions#fragment"), format!("{key}:https://user:pass@listener.example/mentions"), format!("{key}:https://listener.example/mentions?token=secret"), + format!("{key}:https://listener.example/mentions#fragment"), + format!("{key}:http://listener:8080/mentions;{key}:https://other.example"), format!("{key}:https://listener.example/mentions;{key}:https://other.example"), ] { assert!( @@ -2740,15 +2745,23 @@ mod tests { fn valid_and_absent_operator_listener_config_preserve_routes() { let _guards = env_guards(); let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let raw = format!("{key}:https://listener.example/mentions"); - let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); - assert_eq!( - config - .operator_listener_delivery_urls - .get(key) - .map(url::Url::as_str), - Some("https://listener.example/mentions") - ); + for endpoint in [ + "https://listener.example/mentions", + "http://operator-listener/mentions", + "http://operator-listener:8080/mentions", + "http://127.0.0.1:8080/mentions", + "http://[::1]:8080/mentions", + ] { + let raw = format!("{key}:{endpoint}"); + let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); + assert_eq!( + config + .operator_listener_delivery_urls + .get(key) + .map(url::Url::as_str), + Some(endpoint) + ); + } let config = config_with_operator_listeners(None).expect("absent listener config"); assert!(config.operator_listener_delivery_urls.is_empty()); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index e42c6a49756..4ed127dfffa 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -12,7 +12,10 @@ use uuid::Uuid; use buzz_audit::AuditService; use buzz_auth::AuthService; use buzz_core::CommunityId; -use buzz_db::{partition::PARTITION_MANAGER_MONTHS_AHEAD, Db, DbConfig}; +use buzz_db::{ + partition::{PartitionAudit, PARTITION_MANAGER_MONTHS_AHEAD}, + Db, DbConfig, +}; use buzz_pubsub::PubSubManager; use buzz_search::SearchService; @@ -249,6 +252,49 @@ impl FleetUsageSchedule { const USAGE_METRICS_LOCK_KEY: i64 = 0x4255_5A5A_4D45_5452; +/// Run one explicit partition catalog audit attempt. +/// +/// `buzz_partition_audit_failures_total` counts failed explicit attempts: the +/// startup fallback audit and each periodic audit, or the periodic fallback +/// audit when advancement runs periodic maintenance. The audit inside +/// `maintain_partitions` is not an explicit attempt; its failure is counted +/// only through the fallback that follows it. +async fn counted_partition_audit( + attempt: impl std::future::Future>, +) -> buzz_db::Result { + let result = attempt.await; + if result.is_err() { + metrics::counter!("buzz_partition_audit_failures_total").increment(1); + } + result +} + +/// Maintain startup partitions, falling back to a read-only audit on failure. +/// +/// A maintenance failure alone never counts as an audit failure; a failed +/// fallback audit counts once. +async fn startup_partition_audit( + maintain: impl std::future::Future>, + fallback_audit: impl FnOnce() -> F, +) -> Option +where + F: std::future::Future>, +{ + match maintain.await { + Ok(audit) => Some(audit), + Err(error) => { + error!(%error, "Failed to ensure partitions"); + match counted_partition_audit(fallback_audit()).await { + Ok(audit) => Some(audit), + Err(error) => { + error!(%error, "Initial partition catalog audit failed"); + None + } + } + } + } +} + /// Retry missing diagnostics promptly, without tying audit recovery to readiness. /// Delays start after each attempt finishes, so slow audits cannot cause bursts. struct PartitionAuditSchedule { @@ -486,23 +532,15 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { advance_enabled: config.partition_manager_advance_enabled, }; let mut step = StepTimer::start(StartupStep::PartitionEnsure); - let startup_partition_audit = match db - .maintain_partitions(PARTITION_MANAGER_MONTHS_AHEAD, partition_policy) - .await - { - Ok(audit) => Some(audit), - Err(error) => { - step.degrade(); - error!(%error, "Failed to ensure partitions"); - match db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await { - Ok(audit) => Some(audit), - Err(error) => { - error!(%error, "Initial partition catalog audit failed"); - None - } - } - } - }; + let startup_partition_audit = startup_partition_audit( + async { + db.maintain_partitions(PARTITION_MANAGER_MONTHS_AHEAD, partition_policy) + .await + .inspect_err(|_| step.degrade()) + }, + || db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD), + ) + .await; step.finish(); let step = StepTimer::start(StartupStep::DeletionFenceVerify); @@ -837,19 +875,20 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { Ok(audit) => Ok(audit), Err(error) => { warn!(%error, "Periodic partition maintenance failed"); - db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await + counted_partition_audit( + db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD), + ) + .await } } } else { - db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await + counted_partition_audit(db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD)) + .await }; schedule.record_attempt(result.is_ok()); match result { Ok(audit) => partition_state.record_partition_audit(audit), - Err(error) => { - metrics::counter!("buzz_partition_audit_failures_total").increment(1); - warn!(%error, "Periodic partition catalog audit failed") - } + Err(error) => warn!(%error, "Periodic partition catalog audit failed"), } } }); @@ -3030,12 +3069,12 @@ mod tests { use super::{ apply_activity_refresh, apply_stock_refresh, buzz_auto_migrate_enabled, connect_audit_pool, - dropped_in_memory_keys, emit_cached_fleet_usage_metrics, idle_timeout_secs, - jwks_next_retry_after_failed_refresh, nip_fi_jwks_refresh_loop, + counted_partition_audit, dropped_in_memory_keys, emit_cached_fleet_usage_metrics, + idle_timeout_secs, jwks_next_retry_after_failed_refresh, nip_fi_jwks_refresh_loop, record_legacy_usage_collection, refresh_due_fleet_usage, refresh_legacy_active_gauge_recency, relay_keypair_from_config, - run_jwks_refresh_supervisor, run_periodic_until_cancelled, EmissionScope, - FleetUsageSchedule, InMemoryMetricKey, PartitionAuditSchedule, + run_jwks_refresh_supervisor, run_periodic_until_cancelled, startup_partition_audit, + EmissionScope, FleetUsageSchedule, InMemoryMetricKey, PartitionAuditSchedule, FLEET_ACTIVITY_COLLECTION_DEADLINE, FLEET_STOCK_COLLECTION_DEADLINE, }; use buzz_db::DbConfig; @@ -3204,6 +3243,140 @@ mod tests { ); } + fn partition_audit_ok() -> buzz_db::Result { + Ok(buzz_db::partition::PartitionAudit { + audited_at: chrono::Utc::now(), + tables: Vec::new(), + }) + } + + fn partition_audit_err(detail: &str) -> buzz_db::Result { + Err(buzz_db::DbError::InvalidData(detail.to_string())) + } + + /// One recorded counter: name, labels, and value. + type CounterSample = (String, Vec<(String, String)>, u64); + + /// Drive `future` under a local recorder and return its counter values. + fn with_partition_counters( + future: F, + ) -> (F::Output, Vec) { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let output = metrics::with_local_recorder(&recorder, || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime") + .block_on(future) + }); + let counters = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| match value { + DebugValue::Counter(value) => Some(( + key.key().name().to_string(), + key.key() + .labels() + .map(|label| (label.key().to_string(), label.value().to_string())) + .collect(), + value, + )), + _ => None, + }) + .collect(); + (output, counters) + } + + fn aggregate_audit_failures(counters: &[CounterSample]) -> u64 { + counters + .iter() + .filter(|(name, _, _)| name == "buzz_partition_audit_failures_total") + .map(|(_, _, value)| value) + .sum() + } + + #[test] + fn startup_partition_audit_counts_only_a_failed_fallback_once() { + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_err("partition creation failed") }, + || async { partition_audit_err("catalog audit failed") }, + )); + assert!(audit.is_none()); + assert_eq!(aggregate_audit_failures(&counters), 1); + + // A DDL failure recovered by the fallback audit is not an audit failure. + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_err("partition creation failed") }, + || async { partition_audit_ok() }, + )); + assert!(audit.is_some()); + assert_eq!(aggregate_audit_failures(&counters), 0); + + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_ok() }, + || async { panic!("successful ensure must not run the fallback audit") }, + )); + assert!(audit.is_some()); + assert_eq!(aggregate_audit_failures(&counters), 0); + } + + #[test] + fn periodic_partition_audit_counts_each_failed_attempt() { + let (result, counters) = + with_partition_counters(counted_partition_audit(async { partition_audit_ok() })); + assert!(result.is_ok()); + assert_eq!(aggregate_audit_failures(&counters), 0); + + let ((), counters) = with_partition_counters(async { + for _ in 0..2 { + assert!( + counted_partition_audit(async { partition_audit_err("timeout") }) + .await + .is_err() + ); + } + }); + assert_eq!(aggregate_audit_failures(&counters), 2); + } + + #[test] + fn unreachable_database_startup_counts_one_aggregate_audit_failure() { + let (audit, counters) = with_partition_counters(async { + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_millis(200)) + .connect_lazy("postgres://127.0.0.1:1/buzz") + .expect("lazy test pool"); + let db = buzz_db::Db::from_pool(pool); + let months_ahead = buzz_db::partition::PARTITION_MANAGER_MONTHS_AHEAD; + let policy = buzz_db::partition::PartitionMaintenancePolicy { + create_enabled: true, + advance_enabled: false, + }; + startup_partition_audit(db.maintain_partitions(months_ahead, policy), || { + db.audit_partitions(months_ahead) + }) + .await + }); + assert!(audit.is_none()); + assert_eq!(aggregate_audit_failures(&counters), 1); + // Each per-table attempt, including the one inside maintenance, still has + // its own error outcome. + for table in ["events", "delivery_log"] { + let errors: u64 = counters + .iter() + .filter(|(name, labels, _)| { + name == "buzz_partition_audit_runs_total" + && labels.contains(&("table".to_string(), table.to_string())) + && labels.contains(&("outcome".to_string(), "error".to_string())) + }) + .map(|(_, _, value)| value) + .sum(); + assert_eq!(errors, 2, "{table}"); + } + } + #[test] fn buzz_auto_migrate_is_opt_in() { assert!(!buzz_auto_migrate_enabled(None)); diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 3566c0c019f..67e4d3b35e2 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -299,6 +299,7 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric describe_auth_metrics(); crate::startup_steps::describe_metrics(); initialize_auth_metric_series(); + initialize_partition_audit_metric_series(); tokio::spawn(exporter); Ok(()) } @@ -522,6 +523,13 @@ pub(crate) fn initialize_auth_metric_series() { metrics::gauge!("buzz_ws_authenticated_connections_active").set(0.0); } +/// Export the partition audit counters at zero before the first audit, so +/// `increase()`-based alerts count the first failure and every new outcome. +pub(crate) fn initialize_partition_audit_metric_series() { + buzz_db::partition::initialize_audit_metric_series(); + metrics::counter!("buzz_partition_audit_failures_total").increment(0); +} + /// Record one issued NIP-42 challenge after it enters the connection writer. /// /// Before this lifecycle contract, `buzz_auth_attempts_total` incremented only @@ -1161,3 +1169,167 @@ mod tests { ); } } + +/// Fixtures for the partition audit alerts in +/// `docs/partition-catalog-monitoring.md`: the production exporter configuration +/// and audit metric emission export the series those rules read. +#[cfg(test)] +mod partition_alert_tests { + use std::collections::BTreeMap; + use std::time::Duration; + + const RUNS: &str = "buzz_partition_audit_runs_total"; + const LAST_SUCCESS: &str = "buzz_partition_audit_last_success_timestamp_seconds"; + const MANAGED_TABLES: [&str; 2] = ["delivery_log", "events"]; + + /// Sample value by `table` label for every sample of `metric` in a scrape. + /// Counters with several `outcome` labels keep the last sample per table. + fn samples(scrape: &str, metric: &str) -> BTreeMap { + scrape + .lines() + .filter_map(|line| line.strip_prefix(metric)?.strip_prefix('{')) + .filter_map(|labels| { + let (_, rest) = labels.split_once(r#"table=""#)?; + let (table, rest) = rest.split_once('"')?; + let (_, value) = rest.rsplit_once(' ')?; + Some((table.to_string(), value.parse().expect("numeric sample"))) + }) + .collect() + } + + fn tables(scrape: &str, metric: &str) -> Vec { + samples(scrape, metric).into_keys().collect() + } + + fn managed_tables() -> Vec { + MANAGED_TABLES + .iter() + .map(|table| table.to_string()) + .collect() + } + + fn unreachable_db() -> buzz_db::Db { + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_millis(200)) + .connect_lazy("postgres://127.0.0.1:1/buzz") + .expect("lazy test pool"); + buzz_db::Db::from_pool(pool) + } + + fn current_thread_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime") + } + + #[test] + fn never_successful_audits_export_a_zero_success_timestamp() { + let (recorder, handle) = super::readiness_test_recorder_with_idle_timeout(1); + metrics::with_local_recorder(&recorder, || { + current_thread_runtime().block_on(async { + assert!(unreachable_db().audit_partitions(3).await.is_err()); + }); + }); + + let scrape = handle.render(); + assert_eq!(tables(&scrape, RUNS), managed_tables(), "{scrape}"); + let zero = MANAGED_TABLES + .iter() + .map(|table| (table.to_string(), 0.0)) + .collect::>(); + assert_eq!(samples(&scrape, LAST_SUCCESS), zero, "{scrape}"); + } + + #[test] + fn audit_counters_export_zero_baselines_before_the_first_audit() { + let (recorder, handle) = super::readiness_test_recorder_with_idle_timeout(1); + metrics::with_local_recorder(&recorder, super::initialize_partition_audit_metric_series); + + let scrape = handle.render(); + let zero_runs = scrape + .lines() + .filter(|line| line.starts_with(&format!("{RUNS}{{")) && line.ends_with(" 0")) + .count(); + assert_eq!(zero_runs, MANAGED_TABLES.len() * 3, "{scrape}"); + assert!( + scrape + .lines() + .any(|line| line == "buzz_partition_audit_failures_total 0"), + "{scrape}" + ); + + // A first failure then reads as an increase from the zero baseline. + metrics::with_local_recorder(&recorder, || { + current_thread_runtime().block_on(async { + assert!(unreachable_db().audit_partitions(3).await.is_err()); + }); + }); + let scrape = handle.render(); + for table in MANAGED_TABLES { + for (outcome, value) in [("ok", 0), ("degraded", 0), ("error", 1)] { + let line = scrape + .lines() + .find(|line| { + line.starts_with(&format!("{RUNS}{{")) + && line.contains(&format!(r#"table="{table}""#)) + && line.contains(&format!(r#"outcome="{outcome}""#)) + }) + .unwrap_or_else(|| panic!("{table}/{outcome} missing:\n{scrape}")); + assert!(line.ends_with(&format!(" {value}")), "{line}"); + } + } + } + + mod postgres_tests { + use super::*; + + #[test] + #[ignore = "requires Postgres"] + fn failed_audits_keep_the_last_success_past_the_idle_timeout() { + let idle_timeout = Duration::from_secs(1); + let (recorder, handle) = + crate::metrics::readiness_test_recorder_with_idle_timeout(idle_timeout.as_secs()); + let runtime = current_thread_runtime(); + let (working, failing) = metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect test database"); + let working = buzz_db::Db::from_pool(pool); + working.audit_partitions(3).await.expect("successful audit"); + (working, unreachable_db()) + }) + }); + + let scrape = handle.render(); + let first = samples(&scrape, LAST_SUCCESS); + assert_eq!(first.keys().cloned().collect::>(), managed_tables()); + + let audit = |db: &buzz_db::Db| { + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { db.audit_partitions(3).await.is_ok() }) + }) + }; + // A failed audit never makes the last success look fresh, and + // sustained failures keep it exported past the idle timeout, so its + // age keeps rising instead of the series disappearing. + assert!(!audit(&failing)); + assert_eq!(samples(&handle.render(), LAST_SUCCESS), first); + std::thread::sleep(idle_timeout + Duration::from_millis(250)); + assert!(!audit(&failing)); + assert_eq!(samples(&handle.render(), LAST_SUCCESS), first); + + // When audits stop running altogether the timestamp is evicted, + // but the run counters remain for the not-running rule. + std::thread::sleep(idle_timeout + Duration::from_millis(250)); + let scrape = handle.render(); + assert!(tables(&scrape, LAST_SUCCESS).is_empty(), "{scrape}"); + assert_eq!(tables(&scrape, RUNS), managed_tables(), "{scrape}"); + + // The next success exports a fresh timestamp again. + assert!(audit(&working)); + assert_eq!(tables(&handle.render(), LAST_SUCCESS), managed_tables()); + } + } +} diff --git a/crates/buzz-relay/src/operator_listener.rs b/crates/buzz-relay/src/operator_listener.rs index a21292d2aec..8545497e388 100644 --- a/crates/buzz-relay/src/operator_listener.rs +++ b/crates/buzz-relay/src/operator_listener.rs @@ -636,7 +636,7 @@ mod tests { ); } - async fn read_http_request(stream: &mut TcpStream) -> (String, Vec) { + async fn read_http_request(stream: &mut TcpStream) -> (String, Vec, String) { let mut reader = BufReader::new(stream); let mut request_line = String::new(); reader @@ -645,6 +645,7 @@ mod tests { .expect("read request line"); let mut content_length = 0; + let mut authorization = String::new(); loop { let mut line = String::new(); reader.read_line(&mut line).await.expect("read header"); @@ -654,6 +655,8 @@ mod tests { if let Some((name, value)) = line.split_once(':') { if name.eq_ignore_ascii_case("content-length") { content_length = value.trim().parse().expect("valid content length"); + } else if name.eq_ignore_ascii_case("authorization") { + authorization = value.trim().to_owned(); } } } @@ -663,7 +666,67 @@ mod tests { .read_exact(&mut body) .await .expect("read request body"); - (request_line, body) + (request_line, body, authorization) + } + + #[tokio::test] + async fn successful_http_delivery_is_signed_and_completes_claim() { + use nostr::nips::nip98::{verify_auth_header, HttpMethod}; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("HTTP listener"); + let endpoint = url::Url::parse(&format!( + "http://{}/mentions", + listener.local_addr().expect("listener address") + )) + .expect("HTTP endpoint"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept delivery"); + let request = read_http_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .expect("write success response"); + request + }); + let (_, relay_keypair, delivery) = delivery_fixture(1); + let routes = HashMap::from([(hex::encode(&delivery.listener_pubkey), endpoint.clone())]); + let store = MockStore::default(); + let http = build_delivery_http_client(Duration::from_secs(2)).expect("delivery client"); + let transport = ReqwestDeliveryTransport { http: &http }; + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let (request_line, body, authorization) = + tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("HTTP server completed") + .expect("HTTP server task"); + assert!(request_line.starts_with("POST /mentions HTTP/1.1")); + let signer = verify_auth_header( + &authorization, + &endpoint, + HttpMethod::POST, + nostr::Timestamp::now(), + Some(&body), + ) + .expect("valid signature binding the HTTP endpoint and body"); + assert_eq!(signer, relay_keypair.public_key()); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("notification JSON"); + assert_eq!(payload["pubkey"], hex::encode(&delivery.target_pubkey)); + assert_eq!(payload["event_id"], hex::encode(&delivery.event_id)); + assert_eq!( + store.calls(), + [StoreCall::Complete(delivery.id, delivery.claim_id)] + ); } #[tokio::test] diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index e762095ca32..7e015a5dc89 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1649,9 +1649,11 @@ impl AppState { /// Snapshot the last completed audit without accessing the database. /// - /// Periodic refresh failures retain the last-known-good audit by design; - /// operators should alert on staleness of - /// `buzz_partition_audit_last_success_timestamp_seconds`. + /// Periodic refresh failures retain the last-known-good audit by design. + /// Alert on the age of + /// `buzz_partition_audit_last_success_timestamp_seconds`, which failed + /// audits keep exported, and on audit runs stopping. See + /// `docs/partition-catalog-monitoring.md`. pub fn partition_audit_snapshot(&self) -> Option { let cached = match self.partition_audit.read() { Ok(cached) => cached, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 5ff3407f37c..32fe607d2cb 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -18,7 +18,7 @@ fn active_installs() -> &'static std::sync::Mutex( @@ -40,10 +40,10 @@ pub(crate) fn plan_adapter_install<'c>( } // Codex adapter is outdated: uninstall the old package first so npm // doesn't hit EEXIST on the shared `codex-acp` bin-link, then install. - Some(_) => Some(vec![ - "npm uninstall -g @zed-industries/codex-acp", - "npm install -g @agentclientprotocol/codex-acp", - ]), + Some(_) => { + let uninstall: &[&str] = &["npm uninstall -g @zed-industries/codex-acp"]; + Some([uninstall, adapter_install_commands].concat()) + } // Adapter missing: use the catalog's install commands directly. None => Some(adapter_install_commands.to_vec()), } @@ -1053,14 +1053,14 @@ mod tests { #[test] fn test_is_npm_global_install_accepts_catalog_claude_command() { assert!(is_npm_global_install( - "npm install -g @agentclientprotocol/claude-agent-acp" + "npm install -g @agentclientprotocol/claude-agent-acp@0.70.0" )); } #[test] fn test_is_npm_global_install_accepts_catalog_codex_command() { assert!(is_npm_global_install( - "npm install -g @agentclientprotocol/codex-acp" + "npm install -g @agentclientprotocol/codex-acp@1.13.1" )); } @@ -1138,7 +1138,7 @@ mod tests { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) .expect("chmod script"); - let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp@1.13.1"]; let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); assert!( @@ -1151,7 +1151,7 @@ mod tests { cmds, vec![ "npm uninstall -g @zed-industries/codex-acp", - "npm install -g @agentclientprotocol/codex-acp", + "npm install -g @agentclientprotocol/codex-acp@1.13.1", ], "outdated codex adapter must produce uninstall-then-install sequence; got {cmds:?}" ); @@ -1173,7 +1173,7 @@ mod tests { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) .expect("chmod script"); - let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp@1.13.1"]; let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); assert!( @@ -1198,7 +1198,7 @@ mod tests { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) .expect("chmod script"); - let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp@1.13.1"]; let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); assert!( @@ -1209,14 +1209,14 @@ mod tests { #[test] fn test_plan_adapter_install_returns_catalog_cmds_when_no_adapter_path() { - let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp@1.13.1"]; let plan = plan_adapter_install("codex", None, install_cmds, None); assert!(plan.is_some(), "missing adapter must trigger install plan"); // Missing arm: use the catalog's install commands directly (no prior // package to uninstall — fresh install, not a reinstall). assert_eq!( plan.unwrap(), - vec!["npm install -g @agentclientprotocol/codex-acp"], + vec!["npm install -g @agentclientprotocol/codex-acp@1.13.1"], "missing codex adapter must use catalog install commands only" ); } diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index fbfb068c0e5..679caac60e2 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -80,7 +80,7 @@ fn managed_node_unsupported_step() -> InstallStepResult { ), exit_code: None, hint: Some( - "Install Node.js from https://nodejs.org, restart Buzz, then click Install again." + "Install Node.js from https://nodejs.org, restart Dreamforge, then click Install again." .to_string(), ), } @@ -596,7 +596,7 @@ fn verify_node_tree(dir: &std::path::Path) -> Result<(), String> { /// Guidance text shown when the Buzz-private npm prefix is not available. fn managed_npm_prefix_hint() -> String { - "Buzz could not create its private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again.".to_string() + "Dreamforge could not create its private Node tools directory. Check app-data directory permissions, restart Dreamforge, then click Install again.".to_string() } pub(super) fn managed_npm_command(command: &str) -> Result, Box> { @@ -657,7 +657,7 @@ fn shell_quote(path: &std::path::Path) -> String { pub(super) fn npm_eacces_hint(stderr: &str, _command: &str) -> Option { if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") { Some( - "npm could not write to Buzz's private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again." + "npm could not write to Dreamforge's private Node tools directory. Check app-data directory permissions, restart Dreamforge, then click Install again." .to_string(), ) } else { diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs index a8e1d7f4c81..e679eb3d215 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs @@ -4,7 +4,7 @@ use super::*; fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap(); assert!( - hint.contains("Buzz's private Node tools directory"), + hint.contains("Dreamforge's private Node tools directory"), "hint: {hint}" ); } @@ -13,10 +13,10 @@ fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { fn test_rewrite_npm_install_uses_private_prefix() { assert_eq!( rewrite_npm_global_install( - "npm install -g @agentclientprotocol/codex-acp", + "npm install -g @agentclientprotocol/codex-acp@1.13.1", "'/tmp/Buzz Node'" ), - "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp" + "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp@1.13.1" ); } diff --git a/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs index 535d4c9ecb1..48473c86f2e 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs @@ -38,7 +38,7 @@ fn failure( ), exit_code: None, hint: Some( - "Buzz requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Buzz was open, restart Buzz and check again." + "Dreamforge requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Dreamforge was open, restart Dreamforge and check again." .to_string(), ), }) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index bdc0c7880e2..01a9c0ef617 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -13,7 +13,7 @@ use crate::{ stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, }, relay::relay_ws_url_with_override, util::now_iso, @@ -681,11 +681,9 @@ async fn create_managed_agent_in( agent_command_override, agent_args, mcp_command, - // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness; - // store the schema default only. Use idle_timeout_seconds or - // max_turn_duration_seconds for actual turn-length control. - turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, - // 0 or None → harness uses its own default (320s idle, 3600s max), and the CLI also clamps 0 → minimum. + // Deprecated and ignored by the harness (see the record field). + turn_timeout_seconds: 0, + // 0 or None → harness uses its own defaults, and the CLI also clamps 0 → minimum. idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0), max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0), parallelism: minted.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM), diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs index 8f48b396c14..9e920923c08 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -9,6 +9,20 @@ use super::runtime_metadata::{ }; use super::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; +// Adapter installs are pinned to versions verified with Dreamforge: unpinned +// ones pull whatever npm has, including codex-acp 2.x (skipped, block/buzz +// #8247). Macros rather than consts so `concat!` can reuse them in the hints. +macro_rules! claude_agent_acp_install_command { + () => { + "npm install -g @agentclientprotocol/claude-agent-acp@0.70.0" + }; +} +macro_rules! codex_acp_install_command { + () => { + "npm install -g @agentclientprotocol/codex-acp@1.13.1" + }; +} + pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ KnownAcpRuntime { id: "goose", @@ -58,11 +72,11 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ underlying_cli: Some("claude"), cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + adapter_install_commands: &[claude_agent_acp_install_command!()], cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", - adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", + adapter_install_hint: concat!("Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: ", claude_agent_acp_install_command!(), "."), skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, model_env_var: None, @@ -97,11 +111,11 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ underlying_cli: None, cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + adapter_install_commands: &[codex_acp_install_command!()], cli_install_instructions_url: "https://developers.openai.com/codex/cli/", adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", cli_install_hint: "Buzz talks to Codex through the Codex CLI.", - adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", + adapter_install_hint: concat!("Buzz talks to the Codex CLI through an ACP adapter. Install it with: ", codex_acp_install_command!(), "."), skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, model_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index e1c94c1d136..f8fb7c11a14 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -110,7 +110,7 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ command: "buzz-pi-acp", args: &[], install_instructions_url: "https://github.com/salman1993/buzz-pi-acp", - install_hint: "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Buzz.", + install_hint: "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Dreamforge.", underlying_cli: Some("pi"), underlying_cli_install_hint: Some( "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider.", @@ -434,7 +434,7 @@ mod tests { assert!(adapter_missing.default_args.is_empty()); assert_eq!( adapter_missing.install_hint, - "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Buzz." + "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Dreamforge." ); assert_eq!( adapter_missing.install_instructions_url, @@ -462,7 +462,7 @@ mod tests { ); assert_eq!( not_installed.install_hint, - "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider. Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Buzz." + "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider. Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Dreamforge." ); } diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 6b45183e869..3fb3307ef5d 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -209,6 +209,13 @@ mod tests { .adapter_install_instructions_url .contains("claude-agent-acp")); assert!(claude.cli_install_hint.contains("Claude Code CLI")); + assert_eq!( + claude.adapter_install_commands, + ["npm install -g @agentclientprotocol/claude-agent-acp@0.70.0"] + ); + assert!(claude + .adapter_install_hint + .contains(claude.adapter_install_commands[0])); let codex = known_acp_runtime_exact("codex").unwrap(); assert_eq!( @@ -217,6 +224,13 @@ mod tests { ); assert!(codex.adapter_install_instructions_url.contains("codex-acp")); assert!(codex.cli_install_hint.contains("Codex CLI")); + assert_eq!( + codex.adapter_install_commands, + ["npm install -g @agentclientprotocol/codex-acp@1.13.1"] + ); + assert!(codex + .adapter_install_hint + .contains(codex.adapter_install_commands[0])); } /// codex-acp runs the Codex engine it bundles (or `CODEX_PATH`), so an diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 4c57a58ec3a..8f8461c101f 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -143,7 +143,7 @@ impl AgentDefinition { agent_command_override: None, agent_args: Vec::new(), mcp_command: String::new(), - turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + turn_timeout_seconds: 0, idle_timeout_seconds: None, max_turn_duration_seconds: None, parallelism: default_agent_parallelism(), @@ -303,9 +303,12 @@ pub struct ManagedAgentRecord { /// serde compatibility with existing stores. pub mcp_command: String, /// Deprecated: `BUZZ_ACP_TURN_TIMEOUT` is ignored by the harness and the - /// desktop no longer emits or edits it. Kept for serde compatibility with - /// existing stores; use `idle_timeout_seconds` or - /// `max_turn_duration_seconds` for turn-length control. + /// desktop no longer emits or edits it; use `idle_timeout_seconds` or + /// `max_turn_duration_seconds` for turn-length control. Any stored value + /// is dropped on load (a stale 320 read like a live limit) and the field + /// is written as 0 only because older builds require the key to parse + /// the store. + #[serde(skip_deserializing)] pub turn_timeout_seconds: u64, /// Idle timeout in seconds (`BUZZ_ACP_IDLE_TIMEOUT`): how long the agent /// may stay silent on its ACP channel mid-turn before the harness times @@ -850,8 +853,6 @@ pub struct AgentModelInfo { } pub const DEFAULT_ACP_COMMAND: &str = "buzz-acp"; -/// ~5 min (320s) — matches the CLI harness default (BUZZ_ACP_IDLE_TIMEOUT). -pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320; pub const DEFAULT_AGENT_PARALLELISM: u32 = 10; fn default_agent_parallelism() -> u32 { diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 650dce09e82..9049127a47a 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -460,6 +460,17 @@ fn pending_provider_policy_round_trips() { assert!(reloaded.provider_policy_pending); } +/// The harness ignores `turn_timeout_seconds`, so a stored value must not +/// survive a load, but the key must still be written: older builds require it. +#[test] +fn deprecated_turn_timeout_is_dropped_on_load_but_still_written() { + let record = sample_agent_record(); // the fixture stores 320 + assert_eq!(record.turn_timeout_seconds, 0); + + let json = serde_json::to_value(&record).expect("serialize"); + assert_eq!(json["turn_timeout_seconds"], 0); +} + #[test] fn stored_record_unknown_or_null_session_policy_degrades_to_channel() { for session_policy in [serde_json::json!("future"), serde_json::Value::Null] { diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index 60c77bb04cc..1ef234369a2 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -43,7 +43,7 @@ export const MODEL_NOT_FOUND_COPY = "The configured model is not available — open agent settings and select a different one from the dropdown."; export const CLI_ACP_INTERNAL_ERROR_COPY = - "The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or upgrade the adapter (`brew upgrade codex-acp`)."; + "The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or reinstall the adapter from Settings → Agents → Agent runtimes."; const EMBEDDED_CODE_RE = /^Agent reported error \(code (-?\d+)\): /; /** Bare form of the standard JSON-RPC -32603 message (after stripping the ACP wrapper prefix). */ diff --git a/docs/nips/NIP-FA.md b/docs/nips/NIP-FA.md index d7c06fe6432..f2554f93de6 100644 --- a/docs/nips/NIP-FA.md +++ b/docs/nips/NIP-FA.md @@ -171,7 +171,7 @@ WebSocket connections send only `Nostr-Federated-Identity` on the upgrade reques ## Security Considerations -- Clients MUST send `session_token` only to the adapter endpoints defined in this NIP, and MUST NOT send it to a relay or any other origin. A leaked session token lets its holder request assertions for a key they control until the adapter binds the user to a key. +- Clients MUST send `session_token` only to the adapter's origin (the scheme, host, and port of `{adapter_base}`), and MUST NOT send it to a relay or any other origin. A leaked session token lets its holder request assertions for a key they control until the adapter binds the user to a key. - Clients and adapters MUST NOT log credentials: the `Authorization` and `Nostr-Authorization` header values on every adapter request, including `/v1/session`, and the `handoff_secret` and `session_token` values in the code exchange request and response. ## Privacy diff --git a/docs/partition-catalog-monitoring.md b/docs/partition-catalog-monitoring.md new file mode 100644 index 00000000000..8371403d934 --- /dev/null +++ b/docs/partition-catalog-monitoring.md @@ -0,0 +1,150 @@ +# Partition catalog monitoring + +The relay audits the `events` and `delivery_log` partition catalogs at startup +and every `BUZZ_PARTITION_AUDIT_INTERVAL_SECS` (default 900 seconds). Results are +diagnostic only. They appear in `/_status.partition_catalog` and in the metrics +below, and they never change `/_readiness` or `/_liveness`. Alert on them; do not +gate traffic on them. + +## Outcomes + +`buzz-admin partition-audit` prints one outcome for the whole catalog. + +| Outcome | Meaning | `buzz-admin` exit code | +|---|---|---| +| `ok` | Every managed table covers now and needs no operator attention. | 0 | +| `degraded` | Every table covers now, but one needs attention. | 0 | +| `unsafe` | A table cannot route a row timestamped now. | 2 | +| `error` | At least one table could not be audited. | 5 | + +The relay's `buzz_partition_audit_runs_total{outcome}` is per table and is only +`ok`, `degraded`, or `error`. There is no `unsafe` outcome there: a table that +cannot route a row timestamped now is `degraded` and has +`buzz_partition_serving_safe` 0. + +A table is `degraded` when it has a partition-key mismatch, a `DEFAULT` or +anomalous child, trigger drift, an occupied catch-all or `DEFAULT` leaf, or a +current or lookahead month not covered by a bounded leaf. A bounded legacy leaf +with a non-canonical name degrades the table while it covers the current month or +later. Once its upper bound is at or before the start of the current month, it is +historical. It remains in the per-child report as `legacy_leaf` but no longer +degrades the table. + +## Metrics + +Per-table gauges are set only by a successful audit of that table: +`buzz_partition_audit_last_success_timestamp_seconds`, +`buzz_partition_serving_safe`, `buzz_partition_uncovered_months`, +`buzz_partition_catch_all_covered_months`, `buzz_partition_default_covered_months`, +`buzz_partition_anomalous_children`, `buzz_partition_catch_all_nonempty`, +`buzz_partition_default_nonempty`, `buzz_partition_trigger_parity_missing`, and +`buzz_partition_trigger_parity_extra`. A failed audit leaves them unchanged, so a +failure never makes the last success look fresh. + +The exporter removes a gauge that has not been updated within its idle timeout. +The default is 2,700 seconds: three times the larger of the usage-metrics and +partition-audit intervals. A failed audit of a table refreshes that table's +`buzz_partition_audit_last_success_timestamp_seconds` without changing its +value, so sustained failures keep the last success exported and its age keeps +rising. A table that has never been audited successfully exports `0`. The +timestamp disappears only when audits stop running for longer than the idle +timeout; the other per-table gauges can be evicted during sustained failures. + +Counters are not idle-evicted while the process runs. The relay exports every +run outcome for both managed tables, and the failure counter, at 0 when its +metrics exporter starts, so `increase()` counts the first failure and the first +run with a new outcome: + +- `buzz_partition_audit_runs_total{table, outcome}` increments once per table + per audit. The outcome is `ok`, `degraded`, or `error`, including the opening + audit of each partition maintenance pass. +- `buzz_partition_audit_failures_total` counts failed explicit audit attempts: + the fallback audit that runs when a maintenance pass fails, and each periodic + audit when maintenance does not run. A maintenance failure is not counted by + itself. If the fallback audit then fails, that attempt counts once. +- `buzz_partition_create_attempts_total{table, outcome}` counts per-month + decisions in each maintenance pass: `created`, `error`, or `skipped_covered` + for a month that was already covered. Maintenance runs at startup, and on + every periodic audit when `BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED` is on, so + `skipped_covered` then grows on every pass in steady state. + +## Alerts + +Use all three rules below. Aggregate with `without (...)`, not `by (...)`, so +cluster, account, environment, namespace, and pod labels stay in the alert. + +When a pod terminates, its scrape target disappears and its series go stale. +Instant selectors drop a stale series at once, but range selectors such as +`[30m]` keep reading its earlier samples until they leave the window. Each +range-based rule therefore also requires the counter to be exported now, which +it is for the whole life of a relay, so a terminated pod cannot keep or start an +alert. + +`BuzzPartitionAuditNotRunning` also requires the run counter to have existed one +window ago. After a successful startup audit, the first periodic audit runs a +full interval later, so a new pod's counter stays flat until then and would +otherwise read as a stopped loop. + +```yaml +# The last success is older than two default audit intervals. This also fires +# for a table that has never audited successfully, whose timestamp is 0, and +# keeps firing through sustained failures. Scale 1800 with +# BUZZ_PARTITION_AUDIT_INTERVAL_SECS. +- alert: BuzzPartitionAuditStale + expr: time() - buzz_partition_audit_last_success_timestamp_seconds > 1800 + for: 10m + +# The relay is running but its audit loop has stopped. Use a window of about +# two audit intervals, and keep the window plus `for` below the gauge idle +# timeout (2,700 s by default), so this fires before the stopped loop's +# timestamp is evicted and the stale rule resolves. Use the same duration for +# the window and the offset. A loop that stops in a pod's first window is +# caught once the pod is a window old, at about 35 minutes. +- alert: BuzzPartitionAuditNotRunning + expr: | + sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0 + and sum without (outcome) (buzz_partition_audit_runs_total) + and sum without (outcome) (buzz_partition_audit_runs_total offset 30m) + for: 5m + +# Explicit audit attempts keep failing, even if a later attempt succeeds. +- alert: BuzzPartitionAuditFailing + expr: | + increase(buzz_partition_audit_failures_total[1h]) >= 2 + and buzz_partition_audit_failures_total +``` + +The counters exist from relay start, and the success timestamp exists after the +first audit attempt, whether or not it succeeded. The startup audit runs within +seconds of the zero baselines, usually before the first scrape, so +`BuzzPartitionAuditFailing` can miss a failure in that one attempt; if it does +not recover, `BuzzPartitionAuditStale` fires. The run counters are exported +before the relay connects to the database, so a live relay that is stuck before +its first audit shows flat zero counters, which `BuzzPartitionAuditNotRunning` +catches. + +`degraded` asks for operator attention; it does not mean rows cannot be +written. Historical legacy leaves no longer count, so a repaired catalog +returns to `ok` once the current month passes the repaired range and every +current and lookahead month is a bounded monthly partition. Months still covered +by a catch-all stay `degraded` until advancement moves the catch-all. Coverage +problems show directly in `buzz_partition_serving_safe` and +`buzz_partition_uncovered_months`. + +The tests in `crates/buzz-relay/src/metrics.rs` (`partition_alert_tests`) check +that the production exporter and audit emit the series these rules read: zero +counter baselines before the first audit, a `0` timestamp before any success, +the last success kept through failures past the idle timeout, and run counters +that outlive an evicted timestamp. They do not +evaluate the rules, their `for` durations, or label matching. +`partition-catalog-monitoring.rules.test.yml` evaluates the rules in +`partition-catalog-monitoring.rules.yml`, including their `for` durations, +against a fresh pod, a stopped loop, a terminated pod, a table that never +succeeded, and repeated failures. CI does not run it. Keep the rules file +identical to the block above, and run it after changing either: + +```bash +promtool test rules docs/partition-catalog-monitoring.rules.test.yml +``` + +Then validate label matching against your own alerting configuration. diff --git a/docs/partition-catalog-monitoring.rules.test.yml b/docs/partition-catalog-monitoring.rules.test.yml new file mode 100644 index 00000000000..3a897c5b61b --- /dev/null +++ b/docs/partition-catalog-monitoring.rules.test.yml @@ -0,0 +1,122 @@ +# Run with: promtool test rules docs/partition-catalog-monitoring.rules.test.yml +# +# Series are sampled every minute and use the default 900 s audit interval. The +# startup audit finishes before the first scrape, so run counters start at 1. +rule_files: + - partition-catalog-monitoring.rules.yml + +evaluation_interval: 1m + +tests: + # A healthy new pod audits at startup and then every 15 minutes. Its counter + # is flat for the first interval, which must not read as a stopped loop. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="fresh",table="events",outcome="ok"}' + values: '1x14 2x14 3x14 4x14 5x14' + - series: 'buzz_partition_audit_runs_total{pod="fresh",table="events",outcome="degraded"}' + values: '0x74' + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="fresh",table="events"}' + values: '0x14 900x14 1800x14 2700x14 3600x14' + alert_rule_test: + - eval_time: 10m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 14m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 29m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 70m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 70m + alertname: BuzzPartitionAuditStale + exp_alerts: [] + + # The audit loop stops after the 15-minute audit while the pod keeps running. + # Both rules fire before the timestamp's 2,700 s idle eviction at 60 minutes. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="stopped",table="events",outcome="ok"}' + values: '1x14 2x60' + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="stopped",table="events"}' + values: '0x14 900x45' + alert_rule_test: + - eval_time: 40m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 52m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: + - exp_labels: {pod: stopped, table: events} + - eval_time: 57m + alertname: BuzzPartitionAuditStale + exp_alerts: + - exp_labels: {pod: stopped, table: events} + + # The audit loop stops right after the startup audit. It is caught once the + # pod is one window old. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="early",table="events",outcome="ok"}' + values: '1x80' + alert_rule_test: + - eval_time: 33m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 36m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: + - exp_labels: {pod: early, table: events} + + # A pod terminates at 24 minutes. Range selectors still read its earlier + # samples, long enough to complete `for: 5m` at 50 minutes, but it must not + # start an alert. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="gone",table="events",outcome="ok"}' + values: '1x14 2x9 stale' + - series: 'buzz_partition_audit_failures_total{pod="gone"}' + values: '0x10 1 2 3x7 stale' + alert_rule_test: + - eval_time: 45m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 52m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 60m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 30m + alertname: BuzzPartitionAuditFailing + exp_alerts: [] + + # A table that has never audited successfully exports a 0 timestamp. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="never",table="events"}' + values: '0x60' + alert_rule_test: + - eval_time: 35m + alertname: BuzzPartitionAuditStale + exp_alerts: [] + - eval_time: 45m + alertname: BuzzPartitionAuditStale + exp_alerts: + - exp_labels: {pod: never, table: events} + + # One failure does not alert; a second within an hour does. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_failures_total{pod="once"}' + values: '0x10 1x50' + - series: 'buzz_partition_audit_failures_total{pod="twice"}' + values: '0x10 1x10 2x40' + alert_rule_test: + - eval_time: 40m + alertname: BuzzPartitionAuditFailing + exp_alerts: + - exp_labels: {pod: twice} diff --git a/docs/partition-catalog-monitoring.rules.yml b/docs/partition-catalog-monitoring.rules.yml new file mode 100644 index 00000000000..cef7430109c --- /dev/null +++ b/docs/partition-catalog-monitoring.rules.yml @@ -0,0 +1,20 @@ +# Alert rules from docs/partition-catalog-monitoring.md. Keep them identical to +# the doc; partition-catalog-monitoring.rules.test.yml tests them. +groups: + - name: buzz-partition-catalog + rules: + - alert: BuzzPartitionAuditStale + expr: time() - buzz_partition_audit_last_success_timestamp_seconds > 1800 + for: 10m + + - alert: BuzzPartitionAuditNotRunning + expr: | + sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0 + and sum without (outcome) (buzz_partition_audit_runs_total) + and sum without (outcome) (buzz_partition_audit_runs_total offset 30m) + for: 5m + + - alert: BuzzPartitionAuditFailing + expr: | + increase(buzz_partition_audit_failures_total[1h]) >= 2 + and buzz_partition_audit_failures_total diff --git a/scripts/ci-required-gates.test.mjs b/scripts/ci-required-gates.test.mjs index db0bb38480d..5a4a475cfd4 100644 --- a/scripts/ci-required-gates.test.mjs +++ b/scripts/ci-required-gates.test.mjs @@ -45,9 +45,10 @@ for (const gate of gates) { const expression = condition .replace(/always\(\)/g, "true") .replace( - /github\.event_name|needs\.[\w-]+\.(?:result|outputs\.[\w-]+)/g, + /github\.(?:event_name|repository)|needs\.[\w-]+\.(?:result|outputs\.[\w-]+)/g, (key) => { if (key === "github.event_name") return JSON.stringify(event); + if (key === "github.repository") return JSON.stringify("block/buzz"); if (key === "needs.changes.result") return JSON.stringify(selection); if (key === "needs.relay-artifacts-domain.result") return JSON.stringify(artifacts); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index b51e5759737..efdd511dba7 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -135,6 +135,9 @@ run_unit_tests() { run_test_step "buzz-admin community archive command tests" \ cargo test -p buzz-admin communities -- --nocapture + run_test_step "buzz-admin partition audit outcome contract test" \ + cargo test -p buzz-admin tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes -- --exact --nocapture + # Multi-tenant conformance gate: independent replay checker + golden # fixtures (buzz-conformance). Pure in-process trace replay, no infra. run_test_step "buzz-conformance tests" \ @@ -175,6 +178,9 @@ run_unit_tests() { run_test_step "buzz-relay push runtime tests" \ cargo test -p buzz-relay --lib push_runtime:: -- --nocapture + run_test_step "buzz-relay operator listener tests" \ + cargo test -p buzz-relay --lib operator_listener::tests:: -- --nocapture + run_test_step "buzz-relay channel authorization tests" \ cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture @@ -287,6 +293,9 @@ run_unit_tests() { run_test_step "buzz-relay auth metrics contract tests" \ cargo test -p buzz-relay --lib metrics::contract_tests:: -- --nocapture + run_test_step "buzz-relay partition alert series tests" \ + cargo test -p buzz-relay --lib metrics::partition_alert_tests:: -- --nocapture + local nip_fi_exact_tests=( audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge