From 1d3ae055dd50a8a7ed3a70e2b225289b61545bf5 Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Wed, 12 Aug 2026 15:16:51 +0700 Subject: [PATCH 01/11] feat(activity): define member-safe agent frames Add a closed, bounded schema and typed builder for signed ephemeral\nchannel activity summaries. Keep owner observer frames unchanged and reject\nunknown or class-incompatible fields before publication. Signed-off-by: lordfarquad --- crates/buzz-core/src/agent_activity.rs | 301 ++++++++++++++++++ crates/buzz-core/src/kind.rs | 3 + crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/tests/agent_activity.rs | 199 ++++++++++++ crates/buzz-sdk/src/builders.rs | 39 ++- .../buzz-sdk/tests/agent_activity_builder.rs | 85 +++++ 6 files changed, 621 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-core/src/agent_activity.rs create mode 100644 crates/buzz-core/tests/agent_activity.rs create mode 100644 crates/buzz-sdk/tests/agent_activity_builder.rs diff --git a/crates/buzz-core/src/agent_activity.rs b/crates/buzz-core/src/agent_activity.rs new file mode 100644 index 00000000000..20342cf3be4 --- /dev/null +++ b/crates/buzz-core/src/agent_activity.rs @@ -0,0 +1,301 @@ +//! Member-visible, privacy-sanitized managed-agent activity frames. +//! +//! This module is deliberately independent from [`crate::observer`]. Observer +//! frames are owner-only and may contain full ACP payloads; activity frames are +//! a closed, channel-scoped projection suitable for current channel members. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +/// Current member-visible activity frame schema version. +pub const AGENT_ACTIVITY_FRAME_VERSION: u8 = 1; +/// Maximum activities carried by one ephemeral event. +pub const AGENT_ACTIVITY_MAX_ITEMS: usize = 32; +/// Maximum serialized JSON bytes for one activity frame. +pub const AGENT_ACTIVITY_MAX_FRAME_BYTES: usize = 4_096; +/// Maximum reported duration (seven days). +pub const AGENT_ACTIVITY_MAX_DURATION_MS: u64 = 7 * 24 * 60 * 60 * 1_000; +/// Maximum accepted token count in a single usage field. +pub const AGENT_ACTIVITY_MAX_TOKEN_COUNT: u64 = 1_000_000_000_000; +/// Exact cleartext tag name containing the authoring agent pubkey. +pub const AGENT_ACTIVITY_AGENT_TAG: &str = "agent"; + +/// Errors returned while parsing or validating shared activity frames. +#[derive(Debug, Error)] +pub enum AgentActivityError { + /// The serialized frame exceeds the protocol byte cap. + #[error("agent activity frame too large: maximum {max} bytes, got {got}")] + FrameTooLarge { + /// Maximum accepted bytes. + max: usize, + /// Actual bytes. + got: usize, + }, + /// JSON serialization or deserialization failed. + #[error("invalid agent activity JSON: {0}")] + Json(#[from] serde_json::Error), + /// A semantic schema invariant failed. + #[error("invalid agent activity frame: {0}")] + Invalid(String), +} + +/// One ephemeral frame containing a bounded set of sanitized activities. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentActivityFrame { + /// Schema version. Must equal [`AGENT_ACTIVITY_FRAME_VERSION`]. + pub version: u8, + /// Ordered activity updates. + pub activities: Vec, +} + +impl AgentActivityFrame { + /// Parse and validate a frame from bounded JSON content. + pub fn parse(content: &str) -> Result { + check_frame_size(content.len())?; + let frame: Self = serde_json::from_str(content)?; + frame.validate()?; + Ok(frame) + } + + /// Validate and serialize a frame to compact JSON. + pub fn to_json(&self) -> Result { + self.validate()?; + let content = serde_json::to_string(self)?; + check_frame_size(content.len())?; + Ok(content) + } + + /// Validate all closed-schema semantic invariants. + pub fn validate(&self) -> Result<(), AgentActivityError> { + if self.version != AGENT_ACTIVITY_FRAME_VERSION { + return Err(AgentActivityError::Invalid(format!( + "unsupported version {}", + self.version + ))); + } + if self.activities.is_empty() || self.activities.len() > AGENT_ACTIVITY_MAX_ITEMS { + return Err(AgentActivityError::Invalid(format!( + "activities must contain 1..={AGENT_ACTIVITY_MAX_ITEMS} items" + ))); + } + for activity in &self.activities { + activity.validate()?; + } + Ok(()) + } +} + +fn check_frame_size(got: usize) -> Result<(), AgentActivityError> { + if got > AGENT_ACTIVITY_MAX_FRAME_BYTES { + return Err(AgentActivityError::FrameTooLarge { + max: AGENT_ACTIVITY_MAX_FRAME_BYTES, + got, + }); + } + Ok(()) +} + +/// A single sanitized lifecycle, tool, or usage update. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentActivity { + /// Random opaque identifier generated by the producer. + pub activity_id: Uuid, + /// UTC occurrence time. + pub occurred_at: DateTime, + /// Closed activity class. + pub activity_class: AgentActivityClass, + /// Closed lifecycle status. + pub status: AgentActivityStatus, + /// Safe tool category. Present only for tool activity. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_kind: Option, + /// Bounded elapsed duration. Present only for terminal turn/tool updates. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + /// Per-turn token counts. Present only for completed usage updates. + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, +} + +impl AgentActivity { + fn validate(&self) -> Result<(), AgentActivityError> { + if self + .duration_ms + .is_some_and(|duration| duration > AGENT_ACTIVITY_MAX_DURATION_MS) + { + return Err(AgentActivityError::Invalid(format!( + "durationMs exceeds {AGENT_ACTIVITY_MAX_DURATION_MS}" + ))); + } + + if self.duration_ms.is_some() + && !matches!( + self.status, + AgentActivityStatus::Completed + | AgentActivityStatus::Failed + | AgentActivityStatus::Cancelled + ) + { + return Err(AgentActivityError::Invalid( + "durationMs is allowed only on terminal status updates".into(), + )); + } + + match self.activity_class { + AgentActivityClass::Turn => { + if self.tool_kind.is_some() || self.usage.is_some() { + return Err(AgentActivityError::Invalid( + "turn activity forbids toolKind and usage".into(), + )); + } + if self.status == AgentActivityStatus::Pending { + return Err(AgentActivityError::Invalid( + "turn activity does not support pending status".into(), + )); + } + } + AgentActivityClass::Tool => { + if self.tool_kind.is_none() { + return Err(AgentActivityError::Invalid( + "tool activity requires toolKind".into(), + )); + } + if self.usage.is_some() { + return Err(AgentActivityError::Invalid( + "tool activity forbids usage".into(), + )); + } + if self.status == AgentActivityStatus::Started { + return Err(AgentActivityError::Invalid( + "tool activity uses pending or running rather than started".into(), + )); + } + } + AgentActivityClass::Usage => { + if self.status != AgentActivityStatus::Completed { + return Err(AgentActivityError::Invalid( + "usage activity requires completed status".into(), + )); + } + if self.tool_kind.is_some() || self.duration_ms.is_some() { + return Err(AgentActivityError::Invalid( + "usage activity forbids toolKind and durationMs".into(), + )); + } + let usage = self.usage.as_ref().ok_or_else(|| { + AgentActivityError::Invalid("usage activity requires usage".into()) + })?; + usage.validate()?; + } + } + Ok(()) + } +} + +/// Sanitized activity class. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentActivityClass { + /// Agent turn lifecycle. + Turn, + /// Tool lifecycle with only a safe category. + Tool, + /// Reliable per-turn token usage. + Usage, +} + +/// Sanitized lifecycle status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentActivityStatus { + /// Turn began. + Started, + /// Tool was announced but has not started. + Pending, + /// Turn or tool is active. + Running, + /// Turn, tool, or usage completed successfully. + Completed, + /// Turn or tool failed, without error details. + Failed, + /// Turn or tool was cancelled. + Cancelled, +} + +/// Closed, non-user-controlled tool category. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentActivityToolKind { + /// Read-only data access. + Read, + /// File or record modification. + Edit, + /// Deletion. + Delete, + /// Move or rename. + Move, + /// Search or lookup. + Search, + /// Command or program execution. + Execute, + /// Internal planning category; rendered neutrally, never as chain-of-thought. + Think, + /// Remote fetch. + Fetch, + /// Mode switch. + SwitchMode, + /// Any unrecognized tool category. + Other, +} + +/// Reliable per-turn token counts, without provider, model, price, or cumulative totals. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentActivityUsage { + /// Input tokens for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_tokens: Option, + /// Output tokens for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + /// Total tokens for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + pub total_tokens: Option, + /// Cache-read tokens for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read_tokens: Option, + /// Cache-write tokens for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, +} + +impl AgentActivityUsage { + fn validate(&self) -> Result<(), AgentActivityError> { + let counts = [ + self.input_tokens, + self.output_tokens, + self.total_tokens, + self.cache_read_tokens, + self.cache_write_tokens, + ]; + if counts.iter().all(Option::is_none) { + return Err(AgentActivityError::Invalid( + "usage requires at least one token count".into(), + )); + } + if counts + .iter() + .flatten() + .any(|count| *count > AGENT_ACTIVITY_MAX_TOKEN_COUNT) + { + return Err(AgentActivityError::Invalid(format!( + "usage count exceeds {AGENT_ACTIVITY_MAX_TOKEN_COUNT}" + ))); + } + Ok(()) + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913d..59c0bb189e7 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -467,6 +467,8 @@ pub const KIND_PAIRING: u32 = 24134; pub const KIND_TYPING_INDICATOR: u32 = 20002; /// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame. pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200; +/// Ephemeral: privacy-sanitized managed-agent activity for current channel members. +pub const KIND_AGENT_ACTIVITY_SUMMARY: u32 = 24201; /// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral /// huddle channel with an `h` tag; never stored in the timeline. pub const KIND_HUDDLE_REACTION: u32 = 24810; @@ -698,6 +700,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_BLOSSOM_AUTH, KIND_PAIRING, KIND_AGENT_OBSERVER_FRAME, + KIND_AGENT_ACTIVITY_SUMMARY, KIND_HTTP_AUTH, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c83e..17b27477e5a 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -5,6 +5,8 @@ //! Provides [`StoredEvent`], filter matching, kind constants, and event //! verification. All other Buzz crates depend on this one. +/// Member-visible, privacy-sanitized managed-agent activity frames. +pub mod agent_activity; /// NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers. pub mod agent_turn_metric; /// Channel and membership enums shared across crates. diff --git a/crates/buzz-core/tests/agent_activity.rs b/crates/buzz-core/tests/agent_activity.rs new file mode 100644 index 00000000000..671d8ba75e4 --- /dev/null +++ b/crates/buzz-core/tests/agent_activity.rs @@ -0,0 +1,199 @@ +use buzz_core::agent_activity::{ + AgentActivity, AgentActivityClass, AgentActivityFrame, AgentActivityStatus, + AgentActivityToolKind, AgentActivityUsage, AGENT_ACTIVITY_FRAME_VERSION, + AGENT_ACTIVITY_MAX_DURATION_MS, AGENT_ACTIVITY_MAX_FRAME_BYTES, +}; +use chrono::{TimeZone, Utc}; +use uuid::Uuid; + +fn occurred_at() -> chrono::DateTime { + Utc.timestamp_opt(1_723_456_789, 0).single().unwrap() +} + +fn turn(status: AgentActivityStatus) -> AgentActivity { + AgentActivity { + activity_id: Uuid::parse_str("63ca9483-c457-4b24-88de-1f14fa97c499").unwrap(), + occurred_at: occurred_at(), + activity_class: AgentActivityClass::Turn, + status, + tool_kind: None, + duration_ms: None, + usage: None, + } +} + +#[test] +fn valid_frame_round_trips_with_a_closed_camel_case_schema() { + let frame = AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![ + turn(AgentActivityStatus::Started), + AgentActivity { + activity_id: Uuid::parse_str("dd55208d-05a9-41d1-8199-d57664885212").unwrap(), + occurred_at: occurred_at(), + activity_class: AgentActivityClass::Tool, + status: AgentActivityStatus::Completed, + tool_kind: Some(AgentActivityToolKind::Search), + duration_ms: Some(325), + usage: None, + }, + AgentActivity { + activity_id: Uuid::parse_str("684d0d5f-aacc-4670-9b63-72ecf805fa0d").unwrap(), + occurred_at: occurred_at(), + activity_class: AgentActivityClass::Usage, + status: AgentActivityStatus::Completed, + tool_kind: None, + duration_ms: None, + usage: Some(AgentActivityUsage { + input_tokens: Some(100), + output_tokens: Some(25), + total_tokens: Some(125), + cache_read_tokens: None, + cache_write_tokens: None, + }), + }, + ], + }; + + let json = frame.to_json().expect("valid frame"); + assert!(json.len() <= AGENT_ACTIVITY_MAX_FRAME_BYTES); + assert!(json.contains("\"activityId\"")); + assert!(json.contains("\"occurredAt\"")); + assert!(json.contains("\"activityClass\":\"tool\"")); + assert!(json.contains("\"toolKind\":\"search\"")); + assert!(!json.contains("session")); + assert!(!json.contains("prompt")); + assert_eq!(AgentActivityFrame::parse(&json).unwrap(), frame); +} + +#[test] +fn unknown_or_sensitive_fields_are_rejected_instead_of_ignored() { + let hostile = r#"{ + "version": 1, + "activities": [{ + "activityId": "63ca9483-c457-4b24-88de-1f14fa97c499", + "occurredAt": "2024-08-12T08:39:49Z", + "activityClass": "tool", + "status": "running", + "toolKind": "execute", + "title": "cat /private/file", + "arguments": {"path": "/private/file"}, + "result": "secret", + "thought": "hidden reasoning", + "sessionId": "raw-session-id" + }] + }"#; + + let error = AgentActivityFrame::parse(hostile).unwrap_err().to_string(); + assert!(error.contains("unknown field"), "unexpected error: {error}"); +} + +#[test] +fn invalid_class_specific_fields_fail_closed() { + let mut bad_tool = turn(AgentActivityStatus::Running); + bad_tool.activity_class = AgentActivityClass::Tool; + let err = AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![bad_tool], + } + .to_json() + .unwrap_err() + .to_string(); + assert!(err.contains("toolKind")); + + let mut bad_usage = turn(AgentActivityStatus::Completed); + bad_usage.activity_class = AgentActivityClass::Usage; + let err = AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![bad_usage], + } + .to_json() + .unwrap_err() + .to_string(); + assert!(err.contains("usage")); + + let mut turn_with_tool = turn(AgentActivityStatus::Started); + turn_with_tool.tool_kind = Some(AgentActivityToolKind::Other); + assert!(AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![turn_with_tool], + } + .to_json() + .is_err()); +} + +#[test] +fn version_cardinality_duration_and_byte_limits_are_enforced() { + assert!(AgentActivityFrame { + version: 2, + activities: vec![turn(AgentActivityStatus::Started)], + } + .to_json() + .is_err()); + + assert!(AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![], + } + .to_json() + .is_err()); + + let mut too_long = turn(AgentActivityStatus::Completed); + too_long.duration_ms = Some(AGENT_ACTIVITY_MAX_DURATION_MS + 1); + assert!(AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![too_long], + } + .to_json() + .is_err()); + + let oversized = format!( + "{{\"padding\":\"{}\"}}", + "x".repeat(AGENT_ACTIVITY_MAX_FRAME_BYTES) + ); + let error = AgentActivityFrame::parse(&oversized) + .unwrap_err() + .to_string(); + assert!(error.contains("too large"), "unexpected error: {error}"); +} + +#[test] +fn usage_requires_completed_status_and_at_least_one_count() { + let usage = |status, counts| AgentActivity { + activity_id: Uuid::new_v4(), + occurred_at: occurred_at(), + activity_class: AgentActivityClass::Usage, + status, + tool_kind: None, + duration_ms: None, + usage: Some(counts), + }; + + let empty = AgentActivityUsage { + input_tokens: None, + output_tokens: None, + total_tokens: None, + cache_read_tokens: None, + cache_write_tokens: None, + }; + assert!(AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![usage(AgentActivityStatus::Completed, empty)], + } + .to_json() + .is_err()); + + let counts = AgentActivityUsage { + input_tokens: Some(1), + output_tokens: None, + total_tokens: Some(1), + cache_read_tokens: None, + cache_write_tokens: None, + }; + assert!(AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![usage(AgentActivityStatus::Running, counts)], + } + .to_json() + .is_err()); +} diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 948fa775f51..9f14317180a 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -4,15 +4,17 @@ //! The caller signs: `builder.sign_with_keys(&keys)?`. use buzz_core::{ + agent_activity::AgentActivityFrame, kind::{ - KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION, - KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, - KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_AGENT_ACTIVITY_SUMMARY, KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, + KIND_APPROVAL_GRANT, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, + KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, + KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, + KIND_PRESENCE_UPDATE, KIND_PROJECT, KIND_USER_STATUS, KIND_WORKFLOW_DEF, + KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -244,6 +246,27 @@ pub fn build_message( .allow_self_tagging()) } +/// Build a member-visible, channel-scoped managed-agent activity frame (kind 24201). +/// +/// The frame is validated against the closed privacy-sanitized schema before +/// serialization. The caller MUST sign with `agent_pubkey`; relay ingest enforces +/// that signer/tag equality and current channel membership. +pub fn build_agent_activity_summary( + channel_id: Uuid, + agent_pubkey: &str, + frame: &AgentActivityFrame, +) -> Result { + let agent_pubkey = check_pubkey_hex(agent_pubkey, "agent_pubkey")?; + let content = frame + .to_json() + .map_err(|error| SdkError::InvalidInput(error.to_string()))?; + let tags = vec![ + tag(&["h", &channel_id.to_string()])?, + tag(&["agent", &agent_pubkey])?, + ]; + Ok(EventBuilder::new(Kind::Custom(KIND_AGENT_ACTIVITY_SUMMARY as u16), content).tags(tags)) +} + /// Build an encrypted agent observer frame (kind 24200). /// /// `recipient_pubkey` is the cleartext `p` tag used by the relay for owner-only diff --git a/crates/buzz-sdk/tests/agent_activity_builder.rs b/crates/buzz-sdk/tests/agent_activity_builder.rs new file mode 100644 index 00000000000..a35fcb357fc --- /dev/null +++ b/crates/buzz-sdk/tests/agent_activity_builder.rs @@ -0,0 +1,85 @@ +use buzz_core::agent_activity::{ + AgentActivity, AgentActivityClass, AgentActivityFrame, AgentActivityStatus, + AGENT_ACTIVITY_FRAME_VERSION, +}; +use buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY; +use buzz_sdk::{build_agent_activity_summary, SdkError}; +use nostr::Keys; +use uuid::Uuid; + +fn frame() -> AgentActivityFrame { + AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![AgentActivity { + activity_id: Uuid::new_v4(), + occurred_at: "2026-08-12T00:00:00Z".parse().unwrap(), + activity_class: AgentActivityClass::Turn, + status: AgentActivityStatus::Started, + tool_kind: None, + duration_ms: None, + usage: None, + }], + } +} + +fn tag_values(event: &nostr::Event, name: &str) -> Vec { + event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() == 2 && parts[0].as_str() == name).then(|| parts[1].as_str().to_owned()) + }) + .collect() +} + +#[test] +fn builder_emits_exact_channel_and_agent_tags_with_validated_content() { + let signer = Keys::generate(); + let channel_id = Uuid::new_v4(); + let activity = frame(); + + let event = build_agent_activity_summary(channel_id, &signer.public_key().to_hex(), &activity) + .unwrap() + .sign_with_keys(&signer) + .unwrap(); + + assert_eq!(event.kind.as_u16(), KIND_AGENT_ACTIVITY_SUMMARY as u16); + assert_eq!(tag_values(&event, "h"), vec![channel_id.to_string()]); + assert_eq!( + tag_values(&event, "agent"), + vec![signer.public_key().to_hex()] + ); + assert_eq!(AgentActivityFrame::parse(&event.content).unwrap(), activity); +} + +#[test] +fn builder_rejects_invalid_agent_key_and_invalid_frame() { + assert!(matches!( + build_agent_activity_summary(Uuid::new_v4(), "not-a-key", &frame()), + Err(SdkError::InvalidInput(_)) + )); + + let invalid = AgentActivityFrame { + version: 99, + activities: frame().activities, + }; + assert!(matches!( + build_agent_activity_summary(Uuid::new_v4(), &"a".repeat(64), &invalid), + Err(SdkError::InvalidInput(_)) + )); +} + +#[test] +fn builder_does_not_add_owner_or_observer_tags() { + let signer = Keys::generate(); + let event = + build_agent_activity_summary(Uuid::new_v4(), &signer.public_key().to_hex(), &frame()) + .unwrap() + .sign_with_keys(&signer) + .unwrap(); + + assert!(tag_values(&event, "p").is_empty()); + assert!(tag_values(&event, "frame").is_empty()); + assert_eq!(event.pubkey, signer.public_key()); +} From 72f4985c36f03fafaf14395e6219ac2e4f34bfa2 Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Wed, 12 Aug 2026 17:20:46 +0700 Subject: [PATCH 02/11] feat(relay): authorize member-safe agent activity Signed-off-by: lordfarquad --- crates/buzz-relay/src/handlers/event.rs | 791 +++++++++++++++++++++++- crates/buzz-relay/src/handlers/req.rs | 282 ++++++++- crates/buzz-relay/src/state.rs | 8 +- 3 files changed, 1052 insertions(+), 29 deletions(-) diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f3282..b336fbdefce 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -5,10 +5,11 @@ use std::{collections::HashMap, sync::Arc}; use axum::body::Bytes; use tracing::{debug, error, info, warn}; +use buzz_core::agent_activity::{AgentActivityFrame, AGENT_ACTIVITY_AGENT_TAG}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS, - KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, + KIND_AGENT_ACTIVITY_SUMMARY, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, }; use buzz_core::observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -96,15 +97,39 @@ where drop_count } -/// Drop recipients without access before fan-out on a private channel. +/// Keep only subscriptions that explicitly opted into the dedicated live-only +/// kind-24201 contract for this exact channel. Channel wildcard and mixed-kind +/// filters remain valid for ordinary traffic, but are never authority to receive +/// member-only agent activity. +fn filter_agent_activity_subscription_matches( + registry: &crate::subscription::SubscriptionRegistry, + channel_id: uuid::Uuid, + matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, +) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> { + matches + .into_iter() + .filter(|(conn_id, sub_id)| { + registry + .get_filters(*conn_id, sub_id) + .is_some_and(|filters| { + crate::handlers::req::agent_activity_req_channel(&filters) + == Ok(Some(channel_id)) + }) + }) + .collect() +} + +/// Drop recipients without access before fan-out. +/// +/// Kind 24201 is fenced first: channel-less events fail closed, the channel must +/// currently be a stream/forum, and every authenticated recipient must appear in +/// one fresh batched `membership_pairs` result. No visibility or membership cache +/// is consulted, including for open channels. This chokepoint is shared by local +/// and Redis fan-out and therefore also protects generic/kindless subscriptions. /// -/// Open and channel-less events skip membership filtering (open channel-scoped -/// events pay one visibility lookup; see `channel_visibility_cached`). For a -/// private channel, each recipient is kept only if its connection's -/// authenticated pubkey is a current member; unknown/unauthenticated recipients -/// fail closed. This is the cluster-wide backstop: even if a stale subscription -/// survives on another node after an open->private flip, its events are not -/// delivered here. +/// Other open and channel-less events retain their existing behavior. For a +/// private channel, each recipient is kept only if its connection's authenticated +/// pubkey is a member according to the existing membership-cache policy. /// /// `threaded` is an optional visibility read resolved earlier in the same /// request (E1 phase-2, §4.8 phase-2 addendum). It is consulted only when its @@ -174,6 +199,84 @@ pub async fn filter_fanout_by_access( matches }; + // Authoritative kind-24201 fence: run before channel-less/open-channel + // short-circuits and before every cache-backed membership path. + if event_kind_u32(&stored_event.event) == KIND_AGENT_ACTIVITY_SUMMARY { + let Some(channel_id) = stored_event.channel_id else { + return Vec::new(); + }; + let matches = + filter_agent_activity_subscription_matches(&state.sub_registry, channel_id, matches); + if matches.is_empty() { + return Vec::new(); + } + let channel = match state.db.get_channel(community_id, channel_id).await { + Ok(channel) + if agent_activity_channel_allowed( + &channel.channel_type, + channel.archived_at.is_some(), + ) => + { + channel + } + Ok(_) => return Vec::new(), + Err(error) => { + warn!( + %channel_id, + "agent activity fan-out fence: channel lookup failed: {error}" + ); + return Vec::new(); + } + }; + debug_assert!(agent_activity_channel_allowed( + &channel.channel_type, + channel.archived_at.is_some() + )); + + let mut recipient_pubkeys: Vec> = matches + .iter() + .filter_map(|(conn_id, _)| state.conn_manager.pubkey_for_conn(*conn_id)) + .collect(); + recipient_pubkeys.sort_unstable(); + recipient_pubkeys.dedup(); + if recipient_pubkeys.is_empty() { + return Vec::new(); + } + + let active_pairs = match state + .db + .membership_pairs(community_id, &[channel_id], &recipient_pubkeys) + .await + { + Ok(pairs) => pairs, + Err(error) => { + warn!( + %channel_id, + "agent activity fan-out fence: membership lookup failed: {error}" + ); + return Vec::new(); + } + }; + let active_pubkeys: std::collections::HashSet> = active_pairs + .into_iter() + .filter_map(|(pair_channel, pubkey)| (pair_channel == channel_id).then_some(pubkey)) + .collect(); + + return matches + .into_iter() + .filter(|(conn_id, _)| { + let pubkey = state.conn_manager.pubkey_for_conn(*conn_id); + agent_activity_delivery_allowed( + Some(channel_id), + Some(&channel.channel_type), + Some(&channel.visibility), + pubkey.as_deref(), + &active_pubkeys, + ) + }) + .collect(); + } + let Some(channel_id) = stored_event.channel_id else { return matches; }; @@ -691,6 +794,20 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc bool { let now = std::time::Instant::now(); - let mut entry = state - .observer_rate_limiter - .entry((community_id, agent_key)) - .or_insert((0, now)); + let mut entry = limiter.entry((community_id, agent_key)).or_insert((0, now)); let (count, window_start) = entry.value_mut(); if now.duration_since(*window_start).as_secs() >= 1 { *count = 1; @@ -940,8 +1051,247 @@ fn observer_frame_rate_limited( false } else { *count += 1; - *count > 100 + *count > max_per_second + } +} + +fn observer_frame_rate_limited( + state: &AppState, + community_id: CommunityId, + agent_key: [u8; 32], +) -> bool { + // Preserve the established kind-24200 budget exactly. + scoped_agent_rate_limited(&state.observer_rate_limiter, community_id, agent_key, 100) +} + +fn agent_activity_rate_limited( + state: &AppState, + community_id: CommunityId, + agent_key: [u8; 32], +) -> bool { + // Producers target <=30 summaries/minute. Permit a bounded catch-up burst, + // but keep this far below kind 24200's telemetry budget. + scoped_agent_rate_limited( + &state.agent_activity_rate_limiter, + community_id, + agent_key, + 10, + ) +} + +fn agent_activity_timestamp_is_fresh(event_ts: i64, now: i64) -> bool { + (event_ts - now).unsigned_abs() <= 300 +} + +fn agent_activity_principal_allowed( + policy: Option<&(String, Option>)>, + is_member: bool, +) -> bool { + is_member && policy.is_some_and(|(_, owner)| owner.is_some()) +} + +fn agent_activity_channel_type_allowed(channel_type: &str) -> bool { + matches!(channel_type, "stream" | "forum") +} + +fn agent_activity_channel_allowed(channel_type: &str, is_archived: bool) -> bool { + !is_archived && agent_activity_channel_type_allowed(channel_type) +} + +/// Pure final-delivery decision used after the fresh DB batch returns. +/// +/// `channel_visibility` is deliberately not an allow condition: kind 24201 is +/// member-only on open channels too. Keeping it explicit in this seam makes that +/// otherwise easy-to-regress policy testable without a live Postgres fixture. +fn agent_activity_delivery_allowed( + channel_id: Option, + channel_type: Option<&str>, + _channel_visibility: Option<&str>, + recipient_pubkey: Option<&[u8]>, + active_pubkeys: &std::collections::HashSet>, +) -> bool { + channel_id.is_some() + && channel_type.is_some_and(agent_activity_channel_type_allowed) + && recipient_pubkey.is_some_and(|pubkey| active_pubkeys.contains(pubkey)) +} + +fn agent_activity_token_allows_channel( + token_channel_ids: Option<&[uuid::Uuid]>, + channel_id: uuid::Uuid, +) -> bool { + token_channel_ids.is_none_or(|allowed| allowed.contains(&channel_id)) +} + +fn agent_activity_route(event: &Event) -> Result { + if event.tags.len() != 2 { + return Err("invalid: agent activity requires exactly two tags".into()); + } + + let mut channel_id = None; + let mut tagged_agent = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() != 2 { + return Err("invalid: agent activity tags must contain exactly two elements".into()); + } + match parts[0].as_str() { + "h" if channel_id.is_none() => { + let parsed = uuid::Uuid::parse_str(&parts[1]) + .map_err(|_| "invalid: agent activity h tag must be a UUID")?; + if parts[1] != parsed.to_string() { + return Err( + "invalid: agent activity h tag must be canonical lowercase UUID".into(), + ); + } + channel_id = Some(parsed); + } + AGENT_ACTIVITY_AGENT_TAG if tagged_agent.is_none() => { + let parsed = PublicKey::from_hex(&parts[1]) + .map_err(|_| "invalid: agent activity agent tag must be a hex pubkey")?; + if parts[1] != parsed.to_hex() { + return Err( + "invalid: agent activity agent tag must be 64-char lowercase hex".into(), + ); + } + tagged_agent = Some(parsed); + } + _ => return Err("invalid: agent activity contains duplicate or extra tags".into()), + } + } + + if tagged_agent != Some(event.pubkey) { + return Err("invalid: agent activity agent tag must equal signer".into()); + } + AgentActivityFrame::parse(&event.content) + .map_err(|error| format!("invalid: agent activity content: {error}"))?; + channel_id.ok_or_else(|| "invalid: agent activity missing h tag".into()) +} + +fn validate_agent_activity_envelope(event: &Event, now: i64) -> Result { + verify_event(event).map_err(|error| format!("invalid: {error}"))?; + let event_ts = event.created_at.as_secs() as i64; + if !agent_activity_timestamp_is_fresh(event_ts, now) { + return Err("invalid: agent activity timestamp outside ±5 minute freshness window".into()); } + agent_activity_route(event) +} + +async fn handle_agent_activity_event( + event: Event, + conn_id: uuid::Uuid, + event_id_hex: &str, + token_channel_ids: Option>, + conn: Arc, + state: Arc, +) { + let now = chrono::Utc::now().timestamp(); + let event_clone = event.clone(); + let channel_id = match tokio::task::spawn_blocking(move || { + validate_agent_activity_envelope(&event_clone, now) + }) + .await + { + Ok(Ok(channel_id)) => channel_id, + Ok(Err(message)) => { + reject("invalid"); + conn.send(RelayMessage::ok(event_id_hex, false, &message)); + return; + } + Err(_) => { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal error", + )); + return; + } + }; + + if !agent_activity_token_allows_channel(token_channel_ids.as_deref(), channel_id) { + reject("scope"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: token does not include agent activity channel", + )); + return; + } + + let community_id = conn.tenant.community(); + let agent_bytes = event.pubkey.to_bytes().to_vec(); + let (policy, member, channel) = tokio::join!( + state + .db + .get_agent_channel_policy(community_id, &agent_bytes), + state.db.is_member(community_id, channel_id, &agent_bytes), + state.db.get_channel(community_id, channel_id), + ); + let (policy, member, channel) = match (policy, member, channel) { + (Ok(policy), Ok(member), Ok(channel)) => (policy, member, channel), + _ => { + warn!( + conn_id = %conn_id, + event_id = %event_id_hex, + %channel_id, + "agent activity authorization lookup failed" + ); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal server error", + )); + return; + } + }; + + if !agent_activity_channel_allowed(&channel.channel_type, channel.archived_at.is_some()) { + reject("invalid"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: agent activity requires a nonarchived stream or forum channel", + )); + return; + } + if !agent_activity_principal_allowed(policy.as_ref(), member) { + reject("auth"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: agent activity requires a managed agent that is a current channel member", + )); + return; + } + + if agent_activity_rate_limited(&state, community_id, event.pubkey.to_bytes()) { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "rate-limited: agent activity rate exceeded (10/sec per agent)", + )); + return; + } + + state.mark_local_event(community_id, &event.id); + if let Err(error) = state + .pubsub + .publish_event(&conn.tenant, EventTopic::Channel(channel_id), &event) + .await + { + state + .local_event_ids + .invalidate(&(community_id, event.id.to_bytes())); + warn!( + conn_id = %conn_id, + event_id = %event_id_hex, + %channel_id, + "agent activity publish failed: {error}" + ); + } + + let stored = StoredEvent::new(event, Some(channel_id)); + fan_out_event_to_local_subscribers(&state, community_id, &stored).await; + conn.send(RelayMessage::ok(event_id_hex, true, "")); } /// Handle encrypted agent observer frames (kind 24200). @@ -1170,14 +1520,15 @@ mod tests { use std::sync::Arc; use buzz_core::kind::{ - KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, - KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_AGENT_ACTIVITY_SUMMARY, KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_FORUM_COMMENT, + KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_DIFF, }; use buzz_core::observer::{ encrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY, }; - use nostr::{EventBuilder, Keys, Kind, Tag}; + use nostr::{EventBuilder, Filter, Keys, Kind, Tag}; use tokio::sync::{mpsc, Mutex, RwLock}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -1321,6 +1672,343 @@ mod tests { assert!(err.contains("NIP-44")); } + #[test] + fn agent_activity_route_accepts_exact_canonical_tags_and_strict_content() { + let agent = Keys::generate(); + let channel_id = Uuid::new_v4(); + let event = agent_activity_event(&agent, channel_id, valid_agent_activity_content(), None); + + assert_eq!( + super::agent_activity_route(&event).expect("valid route"), + channel_id + ); + } + + #[test] + fn agent_activity_route_rejects_duplicate_extended_extra_and_noncanonical_tags() { + let agent = Keys::generate(); + let channel_id = Uuid::new_v4(); + let agent_hex = agent.public_key().to_hex(); + let content = valid_agent_activity_content(); + let invalid_tags = [ + vec![ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["agent", &agent_hex]).unwrap(), + ], + vec![ + Tag::parse(["h", &channel_id.to_string(), "extra"]).unwrap(), + Tag::parse(["agent", &agent_hex]).unwrap(), + ], + vec![ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["agent", &agent_hex, "extra"]).unwrap(), + ], + vec![ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["agent", &agent_hex]).unwrap(), + Tag::parse(["e", &"11".repeat(32)]).unwrap(), + ], + vec![ + Tag::parse(["h", &channel_id.to_string().to_uppercase()]).unwrap(), + Tag::parse(["agent", &agent_hex]).unwrap(), + ], + vec![ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["agent", &agent_hex.to_uppercase()]).unwrap(), + ], + ]; + + for (case, tags) in invalid_tags.into_iter().enumerate() { + let event = + EventBuilder::new(Kind::Custom(KIND_AGENT_ACTIVITY_SUMMARY as u16), &content) + .tags(tags) + .sign_with_keys(&agent) + .unwrap(); + assert!( + super::agent_activity_route(&event).is_err(), + "invalid tag shape case {case} was accepted: {:?}", + event.tags + ); + } + } + + #[test] + fn agent_activity_route_rejects_agent_tag_not_equal_to_signer() { + let agent = Keys::generate(); + let other = Keys::generate(); + let event = agent_activity_event( + &agent, + Uuid::new_v4(), + valid_agent_activity_content(), + Some(other.public_key().to_hex()), + ); + + assert!(super::agent_activity_route(&event).is_err()); + } + + #[test] + fn agent_activity_route_rejects_unknown_content_field() { + let agent = Keys::generate(); + let channel_id = Uuid::new_v4(); + let content = serde_json::json!({ + "version": 1, + "activities": [{ + "activityId": Uuid::new_v4(), + "occurredAt": "2026-08-12T12:00:00Z", + "activityClass": "turn", + "status": "started", + "secret": "must not pass" + }] + }) + .to_string(); + let event = agent_activity_event(&agent, channel_id, content, None); + + assert!(super::agent_activity_route(&event).is_err()); + } + + #[test] + fn agent_activity_envelope_rejects_stale_timestamp_and_bad_signature() { + let agent = Keys::generate(); + let channel_id = Uuid::new_v4(); + let now = 20_000_i64; + let stale = EventBuilder::new( + Kind::Custom(KIND_AGENT_ACTIVITY_SUMMARY as u16), + valid_agent_activity_content(), + ) + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["agent", &agent.public_key().to_hex()]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from((now - 301) as u64)) + .sign_with_keys(&agent) + .unwrap(); + assert!(super::validate_agent_activity_envelope(&stale, now).is_err()); + + let valid = agent_activity_event(&agent, channel_id, valid_agent_activity_content(), None); + let mut json = serde_json::to_value(valid).unwrap(); + json["sig"] = serde_json::Value::String("00".repeat(64)); + let forged: nostr::Event = serde_json::from_value(json).unwrap(); + assert!(super::validate_agent_activity_envelope( + &forged, + forged.created_at.as_secs() as i64 + ) + .is_err()); + } + + #[test] + fn agent_activity_timestamp_accepts_exact_five_minute_boundary_only() { + let now = 10_000_i64; + assert!(super::agent_activity_timestamp_is_fresh(now - 300, now)); + assert!(super::agent_activity_timestamp_is_fresh(now + 300, now)); + assert!(!super::agent_activity_timestamp_is_fresh(now - 301, now)); + assert!(!super::agent_activity_timestamp_is_fresh(now + 301, now)); + } + + #[test] + fn agent_activity_principal_requires_managed_agent_and_fresh_membership() { + let owner = vec![7u8; 32]; + let managed = Some(("anyone".to_string(), Some(owner))); + let human = Some(("anyone".to_string(), None)); + + assert!(super::agent_activity_principal_allowed( + managed.as_ref(), + true + )); + assert!(!super::agent_activity_principal_allowed( + managed.as_ref(), + false + )); + assert!(!super::agent_activity_principal_allowed( + human.as_ref(), + true + )); + assert!(!super::agent_activity_principal_allowed(None, true)); + } + + #[test] + fn agent_activity_channel_policy_requires_nonarchived_stream_or_forum() { + assert!(super::agent_activity_channel_allowed("stream", false)); + assert!(super::agent_activity_channel_allowed("forum", false)); + assert!(!super::agent_activity_channel_allowed("stream", true)); + assert!(!super::agent_activity_channel_allowed("forum", true)); + assert!(!super::agent_activity_channel_allowed("dm", false)); + assert!(!super::agent_activity_channel_allowed("workflow", false)); + } + + #[test] + fn agent_activity_channel_and_token_policy_reject_dm_other_and_scope_mismatch() { + let channel = Uuid::new_v4(); + let other = Uuid::new_v4(); + assert!(super::agent_activity_channel_type_allowed("stream")); + assert!(super::agent_activity_channel_type_allowed("forum")); + assert!(!super::agent_activity_channel_type_allowed("dm")); + assert!(!super::agent_activity_channel_type_allowed("workflow")); + assert!(super::agent_activity_token_allows_channel(None, channel)); + assert!(super::agent_activity_token_allows_channel( + Some(&[channel]), + channel + )); + assert!(!super::agent_activity_token_allows_channel( + Some(&[other]), + channel + )); + } + + #[test] + fn agent_activity_delivery_keeps_only_explicit_exact_activity_subscriptions() { + let registry = crate::subscription::SubscriptionRegistry::new(); + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let channel = Uuid::new_v4(); + let explicit_conn = Uuid::new_v4(); + let wildcard_conn = Uuid::new_v4(); + let mixed_conn = Uuid::new_v4(); + let activity = Kind::Custom(KIND_AGENT_ACTIVITY_SUMMARY as u16); + let h = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + + registry.register_scoped( + community, + explicit_conn, + "explicit".to_string(), + vec![Filter::new() + .kind(activity) + .custom_tags(h, [channel.to_string()])], + Some(channel), + ); + registry.register_scoped( + community, + wildcard_conn, + "wildcard".to_string(), + vec![Filter::new().custom_tags(h, [channel.to_string()])], + Some(channel), + ); + registry.register_scoped( + community, + mixed_conn, + "mixed".to_string(), + vec![Filter::new() + .kinds([activity, Kind::TextNote]) + .custom_tags(h, [channel.to_string()])], + Some(channel), + ); + + let matches = vec![ + (explicit_conn, "explicit".to_string()), + (wildcard_conn, "wildcard".to_string()), + (mixed_conn, "mixed".to_string()), + ]; + assert_eq!( + super::filter_agent_activity_subscription_matches(®istry, channel, matches), + vec![(explicit_conn, "explicit".to_string())] + ); + } + + #[test] + fn agent_activity_delivery_requires_authenticated_current_stream_or_forum_member() { + let channel = Uuid::new_v4(); + let member = vec![1u8; 32]; + let removed = vec![2u8; 32]; + let active = std::collections::HashSet::from([member.clone()]); + + assert!(super::agent_activity_delivery_allowed( + Some(channel), + Some("stream"), + Some("open"), + Some(&member), + &active, + )); + assert!(super::agent_activity_delivery_allowed( + Some(channel), + Some("forum"), + Some("private"), + Some(&member), + &active, + )); + assert!(!super::agent_activity_delivery_allowed( + Some(channel), + Some("stream"), + Some("open"), + Some(&removed), + &active, + )); + assert!(!super::agent_activity_delivery_allowed( + Some(channel), + Some("stream"), + Some("open"), + None, + &active, + )); + assert!(!super::agent_activity_delivery_allowed( + None, + Some("stream"), + Some("open"), + Some(&member), + &active, + )); + assert!(!super::agent_activity_delivery_allowed( + Some(channel), + Some("dm"), + Some("private"), + Some(&member), + &active, + )); + } + + #[tokio::test] + async fn agent_activity_rate_limiter_is_bounded_and_scoped_by_community() { + let state = fanout_access::test_state().await; + let agent_key = Keys::generate().public_key().to_bytes(); + let community_a = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xA1)); + let community_b = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xB1)); + + for _ in 0..10 { + assert!(!super::agent_activity_rate_limited( + &state, + community_a, + agent_key + )); + } + assert!(super::agent_activity_rate_limited( + &state, + community_a, + agent_key + )); + assert!(!super::agent_activity_rate_limited( + &state, + community_b, + agent_key + )); + } + + fn valid_agent_activity_content() -> String { + serde_json::json!({ + "version": 1, + "activities": [{ + "activityId": Uuid::new_v4(), + "occurredAt": "2026-08-12T12:00:00Z", + "activityClass": "turn", + "status": "started" + }] + }) + .to_string() + } + + fn agent_activity_event( + agent: &Keys, + channel_id: Uuid, + content: String, + agent_tag: Option, + ) -> nostr::Event { + let agent_tag = agent_tag.unwrap_or_else(|| agent.public_key().to_hex()); + EventBuilder::new(Kind::Custom(KIND_AGENT_ACTIVITY_SUMMARY as u16), content) + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["agent", &agent_tag]).unwrap(), + ]) + .sign_with_keys(agent) + .unwrap() + } + #[tokio::test] async fn observer_frame_rate_limiter_is_scoped_by_community() { let state = fanout_access::test_state().await; @@ -2128,6 +2816,31 @@ mod tests { StoredEvent::new(event, channel_id) } + fn agent_activity_event(channel_id: Option) -> StoredEvent { + let agent = Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as u16), + serde_json::json!({ + "version": 1, + "activities": [{ + "activityId": Uuid::new_v4(), + "occurredAt": "2026-08-12T12:00:00Z", + "activityClass": "turn", + "status": "started" + }] + }) + .to_string(), + ) + .tags([ + nostr::Tag::parse(["h", &channel_id.unwrap_or_default().to_string()]) + .expect("h tag"), + nostr::Tag::parse(["agent", &agent.public_key().to_hex()]).expect("agent tag"), + ]) + .sign_with_keys(&agent) + .expect("sign event"); + StoredEvent::new(event, channel_id) + } + #[tokio::test] async fn channel_less_event_passes_through() { let state = test_state().await; @@ -2144,6 +2857,36 @@ mod tests { assert_eq!(out, matches); } + #[tokio::test] + async fn agent_activity_missing_channel_fails_closed_before_db_lookup() { + let state = test_state().await; + let conn = register_conn(&state, Some(vec![1u8; 32])); + let out = filter_fanout_by_access( + &state, + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + &agent_activity_event(None), + vec![(conn, "generic".to_string())], + None, + ) + .await; + assert!(out.is_empty()); + } + + #[tokio::test] + async fn agent_activity_db_or_unknown_channel_error_fails_closed() { + let state = test_state().await; + let conn = register_conn(&state, Some(vec![1u8; 32])); + let out = filter_fanout_by_access( + &state, + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + &agent_activity_event(Some(Uuid::new_v4())), + vec![(conn, "generic".to_string())], + None, + ) + .await; + assert!(out.is_empty()); + } + #[tokio::test] async fn open_channel_event_passes_through_unfiltered() { let state = test_state().await; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fd7deadf51e..e490a65f60d 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,9 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, + is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ACTIVITY_SUMMARY, KIND_AGENT_ENGRAM, + KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, + SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -39,6 +40,101 @@ pub(crate) const FILTER_QUERY_CONCURRENCY: usize = 4; // the range fails the build. const _: () = assert!(FILTER_QUERY_CONCURRENCY >= 2 && FILTER_QUERY_CONCURRENCY <= 8); +/// Validate the special live-only subscription shape for shared agent activity. +/// +/// A request enters this validator when any filter explicitly names kind 24201. +/// At that point every filter must be a kind-24201-only live filter for one +/// canonical channel. Kindless subscriptions remain valid NIP-01 subscriptions; +/// the authoritative delivery fence still protects them if they happen to match +/// a kind-24201 event. +fn filters_request_agent_activity(filters: &[Filter]) -> bool { + filters.iter().any(|filter| { + filter.kinds.as_ref().is_some_and(|kinds| { + kinds + .iter() + .any(|kind| kind.as_u16() as u32 == KIND_AGENT_ACTIVITY_SUMMARY) + }) + }) +} + +pub(crate) fn agent_activity_req_channel( + filters: &[Filter], +) -> Result, &'static str> { + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + let mut request_channel = None; + for filter in filters { + let kinds = filter + .kinds + .as_ref() + .ok_or("restricted: agent activity filters must name kind 24201")?; + if kinds.len() != 1 + || kinds + .iter() + .next() + .is_none_or(|kind| kind.as_u16() as u32 != KIND_AGENT_ACTIVITY_SUMMARY) + { + return Err("restricted: agent activity filters must exclusively name kind 24201"); + } + + if filter.ids.is_some() + || filter.search.is_some() + || filter.since.is_some() + || filter.until.is_some() + || filter.limit.is_some_and(|limit| limit != 0) + { + return Err("restricted: agent activity subscriptions are live-only"); + } + if filter + .authors + .as_ref() + .is_some_and(|authors| authors.is_empty()) + { + return Err("restricted: agent activity authors must be exact public keys"); + } + if filter.generic_tags.len() != 1 { + return Err("restricted: agent activity filters only permit #h and authors"); + } + + let channels = filter + .generic_tags + .get(&h_tag) + .ok_or("restricted: agent activity filters require one #h channel")?; + if channels.len() != 1 { + return Err("restricted: agent activity filters require exactly one #h channel"); + } + let raw_channel = channels + .iter() + .next() + .expect("one channel was checked above"); + let channel = uuid::Uuid::parse_str(raw_channel) + .map_err(|_| "restricted: agent activity #h must be a UUID")?; + if raw_channel != &channel.to_string() { + return Err("restricted: agent activity #h must be a canonical lowercase UUID"); + } + if request_channel.is_some_and(|existing| existing != channel) { + return Err("restricted: agent activity filters must name the same channel"); + } + request_channel = Some(channel); + } + + request_channel + .map(Some) + .ok_or("restricted: agent activity requires at least one filter") +} + +fn agent_activity_req_access_allowed( + is_member: bool, + channel_type: &str, + is_archived: bool, + token_channel_ids: Option<&[uuid::Uuid]>, + channel_id: uuid::Uuid, +) -> bool { + is_member + && !is_archived + && matches!(channel_type, "stream" | "forum") + && token_channel_ids.is_none_or(|allowed| allowed.contains(&channel_id)) +} + /// Handle a REQ message: register the subscription, deliver historical events, then send EOSE. pub async fn handle_req( sub_id: String, @@ -85,7 +181,71 @@ pub async fn handle_req( } }; - let mut accessible_channels = if filters_are_nip43_membership_only(&filters) { + // Any explicit kind-24201 filter opts the whole REQ into the dedicated, + // fail-closed live subscription contract. Run this before either cache-based + // access resolution or subscription registration. + let agent_activity_channel = if filters_request_agent_activity(&filters) { + match agent_activity_req_channel(&filters) { + Ok(channel) => channel, + Err(message) => { + conn.send(RelayMessage::closed(&sub_id, message)); + return; + } + } + } else { + None + }; + + let mut accessible_channels = if let Some(channel_id) = agent_activity_channel { + if !token_channel_ids + .as_deref() + .is_none_or(|allowed| allowed.contains(&channel_id)) + { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: token does not include agent activity channel", + )); + return; + } + + // Deliberately bypass both accessible-channel and membership caches. + // Kind 24201 is member-only even for open channels, so current DB state + // is authoritative at registration time. + let (member, channel) = tokio::join!( + state + .db + .is_member(conn.tenant.community(), channel_id, &pubkey_bytes), + state.db.get_channel(conn.tenant.community(), channel_id), + ); + let (member, channel) = match (member, channel) { + (Ok(member), Ok(channel)) => (member, channel), + (member_result, channel_result) => { + warn!( + conn_id = %conn_id, + %channel_id, + membership_lookup_failed = member_result.is_err(), + channel_lookup_failed = channel_result.is_err(), + "Agent activity REQ authorization lookup failed" + ); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); + return; + } + }; + if !agent_activity_req_access_allowed( + member, + &channel.channel_type, + channel.archived_at.is_some(), + token_channel_ids.as_deref(), + channel_id, + ) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: agent activity requires current membership in a nonarchived stream or forum", + )); + return; + } + vec![channel_id] + } else if filters_are_nip43_membership_only(&filters) { metrics::counter!("buzz_req_global_access_resolution_skips_total", "kind" => "13534") .increment(1); Vec::new() @@ -106,7 +266,7 @@ pub async fn handle_req( accessible_channels.retain(|channel_id| allowed.contains(channel_id)); } - let channel_id = extract_channel_id_from_filters(&filters); + let channel_id = agent_activity_channel.or_else(|| extract_channel_id_from_filters(&filters)); // Build the conformance `AbstractState` once at request entry. The // `Option` only goes `None` on malformed pubkey bytes (already a @@ -1301,6 +1461,120 @@ mod tests { use super::*; use nostr::{Alphabet, Filter, SingleLetterTag}; + #[test] + fn agent_activity_req_accepts_live_only_single_channel_filters_with_optional_authors() { + let channel = uuid::Uuid::new_v4(); + let kind = nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as u16); + let h = SingleLetterTag::lowercase(Alphabet::H); + let author = nostr::Keys::generate().public_key(); + + for filter in [ + Filter::new() + .kind(kind) + .custom_tags(h, [channel.to_string()]), + Filter::new() + .kind(kind) + .author(author) + .custom_tags(h, [channel.to_string()]) + .limit(0), + ] { + assert_eq!( + agent_activity_req_channel(&[filter]).expect("valid activity REQ"), + Some(channel) + ); + } + } + + #[test] + fn agent_activity_req_rejects_kindless_mixed_and_malformed_channel_shapes() { + let channel = uuid::Uuid::new_v4(); + let other = uuid::Uuid::new_v4(); + let activity = nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as u16); + let h = SingleLetterTag::lowercase(Alphabet::H); + let cases = [ + vec![Filter::new().custom_tags(h, [channel.to_string()])], + vec![Filter::new() + .kinds([activity, nostr::Kind::TextNote]) + .custom_tags(h, [channel.to_string()])], + vec![Filter::new().kind(activity)], + vec![Filter::new() + .kind(activity) + .custom_tags(h, [channel.to_string(), other.to_string()])], + vec![Filter::new() + .kind(activity) + .custom_tags(h, [channel.to_string().to_uppercase()])], + vec![ + Filter::new() + .kind(activity) + .custom_tags(h, [channel.to_string()]), + Filter::new() + .kind(activity) + .custom_tags(h, [other.to_string()]), + ], + ]; + + for filters in cases { + assert!(agent_activity_req_channel(&filters).is_err()); + } + } + + #[test] + fn agent_activity_req_rejects_historical_and_non_author_constraints() { + let channel = uuid::Uuid::new_v4(); + let activity = nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as u16); + let h = SingleLetterTag::lowercase(Alphabet::H); + let p = SingleLetterTag::lowercase(Alphabet::P); + let base = || { + Filter::new() + .kind(activity) + .custom_tags(h, [channel.to_string()]) + }; + let cases = [ + base().id(nostr::EventId::all_zeros()), + base().search("history"), + base().since(nostr::Timestamp::from(1)), + base().until(nostr::Timestamp::from(2)), + base().limit(1), + base().custom_tags(p, ["11".repeat(32)]), + ]; + + for filter in cases { + assert!(agent_activity_req_channel(&[filter]).is_err()); + } + } + + #[test] + fn agent_activity_req_policy_requires_current_membership_stream_or_forum_and_token_scope() { + let channel = uuid::Uuid::new_v4(); + let other = uuid::Uuid::new_v4(); + assert!(agent_activity_req_access_allowed( + true, "stream", false, None, channel + )); + assert!(agent_activity_req_access_allowed( + true, + "forum", + false, + Some(&[channel]), + channel + )); + assert!(!agent_activity_req_access_allowed( + false, "stream", false, None, channel + )); + assert!(!agent_activity_req_access_allowed( + true, "stream", true, None, channel + )); + assert!(!agent_activity_req_access_allowed( + true, "dm", false, None, channel + )); + assert!(!agent_activity_req_access_allowed( + true, + "stream", + false, + Some(&[other]), + channel + )); + } + #[test] fn global_queries_push_access_scope_before_limit() { let accessible = vec![uuid::Uuid::new_v4(), uuid::Uuid::new_v4()]; diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2f544e188c0..3cc128923bc 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -85,10 +85,12 @@ impl CommunityConnectionControl { } } +/// Process-local fixed-window limiter keyed by community and public key. +pub(crate) type ScopedRateLimiter = DashMap; + /// Leaves headroom under the process-wide drain deadline for a stalled writer. const RESTART_CLOSE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); type SlidingWindowCounter = (u32, Instant); -type ScopedRateLimiter = DashMap; /// Per-connection entry in the connection manager. struct ConnEntry { @@ -729,6 +731,9 @@ pub struct AppState { /// Key: (community_id, agent pubkey bytes). Value: (count, window_start). /// 100 events/sec per agent — prevents relay/DB pressure from bursty telemetry. pub observer_rate_limiter: Arc, + /// Per-agent sliding-window admission limiter for shared activity (kind 24201). + /// Kept separate from owner-only observer traffic so either path cannot starve the other. + pub agent_activity_rate_limiter: Arc, /// Per-uploader sliding-window rate limiter for media upload starts. /// Key: (community_id, uploader pubkey bytes). Value: (count, window_start). pub media_upload_rate_limiter: Arc, @@ -914,6 +919,7 @@ impl AppState { nip98_replay, admission_rate_limiter, observer_rate_limiter: Arc::new(DashMap::new()), + agent_activity_rate_limiter: Arc::new(DashMap::new()), media_upload_rate_limiter: Arc::new(DashMap::new()), invite_claim_rate_limiter: Arc::new( moka::sync::Cache::builder() From c3fa4255f38a5a2f1e22c97037d6d54880639110 Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Wed, 12 Aug 2026 17:33:36 +0700 Subject: [PATCH 03/11] feat(acp): publish member-safe agent activity Signed-off-by: lordfarquad --- crates/buzz-acp/src/acp.rs | 9 +- crates/buzz-acp/src/agent_activity.rs | 1565 +++++++++++++++++++++++++ crates/buzz-acp/src/lib.rs | 83 +- 3 files changed, 1649 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-acp/src/agent_activity.rs diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8460372abab..b146b6348da 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -878,7 +878,14 @@ impl AcpClient { /// Intended for consumption by `publish_agent_turn_metric` in `pool.rs` to /// publish a kind 44200 NIP-AM event. pub fn take_turn_usage(&mut self) -> Option { - self.goose_usage.take() + let usage = self.goose_usage.take(); + if let Some(payload) = usage + .as_ref() + .and_then(crate::agent_activity::usage_observer_payload) + { + self.observe("agent_activity_turn_usage", payload); + } + usage } /// Notify the usage tracker that buzz-acp just spawned a new session. diff --git a/crates/buzz-acp/src/agent_activity.rs b/crates/buzz-acp/src/agent_activity.rs new file mode 100644 index 00000000000..da42ab19835 --- /dev/null +++ b/crates/buzz-acp/src/agent_activity.rs @@ -0,0 +1,1565 @@ +use std::collections::{HashMap, VecDeque}; +use std::time::Duration; + +use buzz_core::agent_activity::{ + AgentActivity, AgentActivityClass, AgentActivityFrame, AgentActivityStatus, + AgentActivityToolKind, AgentActivityUsage, AGENT_ACTIVITY_FRAME_VERSION, + AGENT_ACTIVITY_MAX_DURATION_MS, AGENT_ACTIVITY_MAX_ITEMS, AGENT_ACTIVITY_MAX_TOKEN_COUNT, +}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +const MAX_TRACKED_TURNS: usize = 256; +const MAX_RAW_ID_BYTES: usize = 128; +/// A global two-second cadence caps summary traffic at 30 events/minute. +pub(crate) const ACTIVITY_PUBLISH_TICK: Duration = Duration::from_secs(2); + +pub(crate) struct ProjectedActivity { + pub(crate) channel_id: Uuid, + pub(crate) activity: AgentActivity, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +struct TurnKey { + channel_id: Uuid, + raw_turn_id: String, +} + +#[derive(Clone, Copy)] +struct TurnState { + activity_id: Uuid, + started_at: DateTime, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +struct ToolKey { + channel_id: Uuid, + raw_turn_id: String, + raw_tool_id: String, +} + +#[derive(Clone, Copy)] +struct ToolState { + activity_id: Uuid, + started_at: DateTime, + tool_kind: AgentActivityToolKind, +} + +#[derive(Default)] +pub(crate) struct ActivityProjector { + turns: HashMap, + turn_order: VecDeque, + tools: HashMap, + tool_order: VecDeque, +} + +impl ActivityProjector { + pub(crate) fn project( + &mut self, + event: &crate::observer::ObserverEvent, + ) -> Option { + match event.kind.as_str() { + "turn_started" => self.project_turn_started(event), + "turn_liveness" => self.project_turn_running(event), + "agent_activity_turn_terminal" => self.project_turn_terminal(event), + "agent_activity_turn_usage" => self.project_turn_usage(event), + "acp_read" => self.project_acp_tool(event), + _ => None, + } + } + + fn turn_event_fields( + event: &crate::observer::ObserverEvent, + ) -> Option<(Uuid, DateTime, TurnKey)> { + let channel_id = event.channel_id.as_deref()?.parse().ok()?; + let occurred_at = event.timestamp.parse().ok()?; + let raw_turn_id = bounded_raw_id(event.turn_id.as_deref()?)?.to_owned(); + Some(( + channel_id, + occurred_at, + TurnKey { + channel_id, + raw_turn_id, + }, + )) + } + + fn project_turn_started( + &mut self, + event: &crate::observer::ObserverEvent, + ) -> Option { + let (channel_id, occurred_at, key) = Self::turn_event_fields(event)?; + let state = if let Some(state) = self.turns.get(&key) { + *state + } else { + while self.turns.len() >= MAX_TRACKED_TURNS { + let oldest = self.turn_order.pop_front()?; + self.turns.remove(&oldest); + } + let state = TurnState { + activity_id: Uuid::new_v4(), + started_at: occurred_at, + }; + self.turns.insert(key.clone(), state); + self.turn_order.push_back(key); + state + }; + Some(ProjectedActivity { + channel_id, + activity: turn_activity( + state.activity_id, + occurred_at, + AgentActivityStatus::Started, + None, + ), + }) + } + + fn project_turn_running( + &mut self, + event: &crate::observer::ObserverEvent, + ) -> Option { + let (channel_id, occurred_at, key) = Self::turn_event_fields(event)?; + let state = *self.turns.get(&key)?; + (occurred_at >= state.started_at).then_some(ProjectedActivity { + channel_id, + activity: turn_activity( + state.activity_id, + occurred_at, + AgentActivityStatus::Running, + None, + ), + }) + } + + fn project_turn_terminal( + &mut self, + event: &crate::observer::ObserverEvent, + ) -> Option { + let (channel_id, occurred_at, key) = Self::turn_event_fields(event)?; + let status = match event.payload.get("status")?.as_str()? { + "completed" => AgentActivityStatus::Completed, + "failed" => AgentActivityStatus::Failed, + "cancelled" => AgentActivityStatus::Cancelled, + _ => return None, + }; + let state = *self.turns.get(&key)?; + let elapsed_ms = occurred_at + .signed_duration_since(state.started_at) + .num_milliseconds(); + let duration_ms = u64::try_from(elapsed_ms) + .ok()? + .min(AGENT_ACTIVITY_MAX_DURATION_MS); + self.turns.remove(&key); + self.turn_order.retain(|pending| pending != &key); + Some(ProjectedActivity { + channel_id, + activity: turn_activity(state.activity_id, occurred_at, status, Some(duration_ms)), + }) + } + + fn project_turn_usage( + &mut self, + event: &crate::observer::ObserverEvent, + ) -> Option { + let (channel_id, occurred_at, key) = Self::turn_event_fields(event)?; + self.turns.get(&key)?; + if !event.payload.get("deltaReliable")?.as_bool()? { + return None; + } + let usage = AgentActivityUsage { + input_tokens: bounded_token_count(&event.payload, "inputTokens")?, + output_tokens: bounded_token_count(&event.payload, "outputTokens")?, + total_tokens: bounded_token_count(&event.payload, "totalTokens")?, + cache_read_tokens: bounded_token_count(&event.payload, "cacheReadTokens")?, + cache_write_tokens: bounded_token_count(&event.payload, "cacheWriteTokens")?, + }; + if [ + usage.input_tokens, + usage.output_tokens, + usage.total_tokens, + usage.cache_read_tokens, + usage.cache_write_tokens, + ] + .iter() + .all(Option::is_none) + { + return None; + } + Some(ProjectedActivity { + channel_id, + activity: AgentActivity { + activity_id: Uuid::new_v4(), + occurred_at, + activity_class: AgentActivityClass::Usage, + status: AgentActivityStatus::Completed, + tool_kind: None, + duration_ms: None, + usage: Some(usage), + }, + }) + } + + fn project_acp_tool( + &mut self, + event: &crate::observer::ObserverEvent, + ) -> Option { + if event.payload.get("method")?.as_str()? != "session/update" { + return None; + } + let channel_id = event.channel_id.as_deref()?.parse().ok()?; + let occurred_at: DateTime = event.timestamp.parse().ok()?; + let raw_turn_id = bounded_raw_id(event.turn_id.as_deref()?)?.to_owned(); + let update = event.payload.pointer("/params/update")?.as_object()?; + let update_type = update.get("sessionUpdate")?.as_str()?; + let raw_tool_id = bounded_raw_id(update.get("toolCallId")?.as_str()?)?.to_owned(); + let key = ToolKey { + channel_id, + raw_turn_id, + raw_tool_id, + }; + + match update_type { + "tool_call" => { + let status = match update.get("status")?.as_str()? { + "pending" => AgentActivityStatus::Pending, + "in_progress" => AgentActivityStatus::Running, + _ => return None, + }; + let tool_kind = safe_tool_kind(update.get("kind")?.as_str()?); + let state = if let Some(state) = self.tools.get(&key) { + *state + } else { + while self.tools.len() >= MAX_TRACKED_TURNS { + let oldest = self.tool_order.pop_front()?; + self.tools.remove(&oldest); + } + let state = ToolState { + activity_id: Uuid::new_v4(), + started_at: occurred_at, + tool_kind, + }; + self.tools.insert(key.clone(), state); + self.tool_order.push_back(key); + state + }; + Some(ProjectedActivity { + channel_id, + activity: tool_activity( + state.activity_id, + occurred_at, + status, + state.tool_kind, + None, + ), + }) + } + "tool_call_update" => { + let status = match update.get("status")?.as_str()? { + "in_progress" => AgentActivityStatus::Running, + "completed" => AgentActivityStatus::Completed, + "failed" => AgentActivityStatus::Failed, + "cancelled" => AgentActivityStatus::Cancelled, + _ => return None, + }; + let state = *self.tools.get(&key)?; + if occurred_at < state.started_at { + return None; + } + let terminal = matches!( + status, + AgentActivityStatus::Completed + | AgentActivityStatus::Failed + | AgentActivityStatus::Cancelled + ); + let duration_ms = terminal.then(|| { + u64::try_from( + occurred_at + .signed_duration_since(state.started_at) + .num_milliseconds(), + ) + .expect("non-negative duration checked") + .min(AGENT_ACTIVITY_MAX_DURATION_MS) + }); + if terminal { + self.tools.remove(&key); + self.tool_order.retain(|pending| pending != &key); + } + Some(ProjectedActivity { + channel_id, + activity: tool_activity( + state.activity_id, + occurred_at, + status, + state.tool_kind, + duration_ms, + ), + }) + } + _ => None, + } + } +} + +fn is_terminal_status(status: AgentActivityStatus) -> bool { + matches!( + status, + AgentActivityStatus::Completed + | AgentActivityStatus::Failed + | AgentActivityStatus::Cancelled + ) +} + +fn safe_tool_kind(value: &str) -> AgentActivityToolKind { + match value { + "read" => AgentActivityToolKind::Read, + "edit" => AgentActivityToolKind::Edit, + "delete" => AgentActivityToolKind::Delete, + "move" => AgentActivityToolKind::Move, + "search" => AgentActivityToolKind::Search, + "execute" => AgentActivityToolKind::Execute, + "think" => AgentActivityToolKind::Think, + "fetch" => AgentActivityToolKind::Fetch, + "switch_mode" => AgentActivityToolKind::SwitchMode, + "other" => AgentActivityToolKind::Other, + _ => AgentActivityToolKind::Other, + } +} + +fn tool_activity( + activity_id: Uuid, + occurred_at: DateTime, + status: AgentActivityStatus, + tool_kind: AgentActivityToolKind, + duration_ms: Option, +) -> AgentActivity { + AgentActivity { + activity_id, + occurred_at, + activity_class: AgentActivityClass::Tool, + status, + tool_kind: Some(tool_kind), + duration_ms, + usage: None, + } +} + +fn turn_activity( + activity_id: Uuid, + occurred_at: DateTime, + status: AgentActivityStatus, + duration_ms: Option, +) -> AgentActivity { + AgentActivity { + activity_id, + occurred_at, + activity_class: AgentActivityClass::Turn, + status, + tool_kind: None, + duration_ms, + usage: None, + } +} + +fn bounded_raw_id(value: &str) -> Option<&str> { + (!value.is_empty() && value.len() <= MAX_RAW_ID_BYTES).then_some(value) +} + +fn bounded_token_count(payload: &serde_json::Value, field: &str) -> Option> { + match payload.get(field) { + None | Some(serde_json::Value::Null) => Some(None), + Some(value) => value + .as_u64() + .filter(|count| *count <= AGENT_ACTIVITY_MAX_TOKEN_COUNT) + .map(Some), + } +} + +pub(crate) fn usage_observer_payload(usage: &crate::usage::TurnUsage) -> Option { + if !usage.delta_reliable { + return None; + } + let mut payload = serde_json::Map::new(); + payload.insert("deltaReliable".into(), serde_json::Value::Bool(true)); + for (field, count) in [ + ("inputTokens", usage.turn_input_tokens), + ("outputTokens", usage.turn_output_tokens), + ("totalTokens", usage.turn_total_tokens), + ("cacheReadTokens", usage.turn_cache_read_tokens), + ("cacheWriteTokens", usage.turn_cache_write_tokens), + ] { + if let Some(count) = count { + if count > AGENT_ACTIVITY_MAX_TOKEN_COUNT { + return None; + } + payload.insert(field.into(), serde_json::Value::from(count)); + } + } + (payload.len() > 1).then_some(serde_json::Value::Object(payload)) +} + +/// Aggregate bounds for activity waiting on the global publication pacer. +const ACTIVITY_PENDING_MAX_CHANNELS: usize = 128; +const ACTIVITY_PENDING_MAX_ITEMS: usize = 1_024; +const ACTIVITY_PENDING_MAX_BYTES: usize = 256 * 1_024; + +struct QueuedActivity { + sequence: u64, + bytes: usize, + activity: AgentActivity, +} + +/// Bounded, per-channel activity FIFO with fair frame rotation. +#[derive(Default)] +pub(crate) struct ActivityPublishQueue { + channels: HashMap>, + channel_order: VecDeque, + next_sequence: u64, + pending_items: usize, + pending_bytes: usize, + dropped_items: u64, + dropped_bytes: u64, +} + +impl ActivityPublishQueue { + pub(crate) fn ingest(&mut self, projected: ProjectedActivity) { + let bytes = match serde_json::to_vec(&projected.activity) { + Ok(serialized) => serialized.len(), + Err(error) => { + tracing::warn!("failed to size sanitized agent activity: {error}"); + return; + } + }; + let channel_id = projected.channel_id; + if let Some(activities) = self.channels.get_mut(&channel_id) { + let mut removed_items = 0usize; + let mut removed_bytes = 0usize; + activities.retain(|queued| { + if queued.activity.activity_id == projected.activity.activity_id { + removed_items += 1; + removed_bytes += queued.bytes; + false + } else { + true + } + }); + self.pending_items -= removed_items; + self.pending_bytes -= removed_bytes; + } else { + self.channels.insert(channel_id, VecDeque::new()); + self.channel_order.push_back(channel_id); + } + self.next_sequence = self.next_sequence.wrapping_add(1); + if let Some(activities) = self.channels.get_mut(&channel_id) { + activities.push_back(QueuedActivity { + sequence: self.next_sequence, + bytes, + activity: projected.activity, + }); + } else { + return; + } + self.pending_items += 1; + self.pending_bytes += bytes; + self.enforce_bounds(); + } + + fn enforce_bounds(&mut self) { + let mut dropped_items = 0u64; + let mut dropped_bytes = 0u64; + while self.channels.len() > ACTIVITY_PENDING_MAX_CHANNELS + || self.pending_items > ACTIVITY_PENDING_MAX_ITEMS + || self.pending_bytes > ACTIVITY_PENDING_MAX_BYTES + { + let Some((channel_id, index)) = self.oldest_item() else { + break; + }; + let Some(queued) = self + .channels + .get_mut(&channel_id) + .and_then(|activities| activities.remove(index)) + else { + break; + }; + self.pending_items -= 1; + self.pending_bytes -= queued.bytes; + dropped_items += 1; + dropped_bytes += queued.bytes as u64; + self.remove_empty_channel(channel_id); + } + if dropped_items > 0 { + self.dropped_items += dropped_items; + self.dropped_bytes += dropped_bytes; + tracing::warn!( + dropped_items, + dropped_bytes, + total_dropped_items = self.dropped_items, + pending_items = self.pending_items, + pending_bytes = self.pending_bytes, + "agent activity queue over bound; dropped oldest updates" + ); + } + } + + fn oldest_item(&self) -> Option<(Uuid, usize)> { + self.channels + .iter() + .flat_map(|(channel_id, activities)| { + activities.iter().enumerate().map(move |(index, queued)| { + ( + *channel_id, + index, + is_terminal_status(queued.activity.status), + queued.sequence, + ) + }) + }) + .min_by_key(|(_, _, terminal, sequence)| (*terminal, *sequence)) + .map(|(channel_id, index, _, _)| (channel_id, index)) + } + + fn remove_empty_channel(&mut self, channel_id: Uuid) { + if self + .channels + .get(&channel_id) + .is_some_and(VecDeque::is_empty) + { + self.channels.remove(&channel_id); + self.channel_order.retain(|pending| *pending != channel_id); + } + } + + pub(crate) fn next_frame(&mut self) -> Option<(Uuid, AgentActivityFrame)> { + let channel_id = self.channel_order.pop_front()?; + let mut queued = self.channels.remove(&channel_id)?; + let mut activities = Vec::new(); + + while activities.len() < AGENT_ACTIVITY_MAX_ITEMS { + let Some(next) = queued.pop_front() else { + break; + }; + let mut candidate = activities.clone(); + candidate.push(next.activity.clone()); + let frame = AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: candidate, + }; + if frame.to_json().is_err() { + queued.push_front(next); + break; + } + activities.push(next.activity); + self.pending_items -= 1; + self.pending_bytes -= next.bytes; + } + + if !queued.is_empty() { + self.channels.insert(channel_id, queued); + self.channel_order.push_back(channel_id); + } + if activities.is_empty() { + return None; + } + Some(( + channel_id, + AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities, + }, + )) + } + + pub(crate) fn is_empty(&self) -> bool { + self.pending_items == 0 + } + + #[cfg(test)] + fn channel_count(&self) -> usize { + self.channels.len() + } +} + +pub(crate) fn spawn_relay_activity_publisher( + observer: crate::observer::ObserverHandle, + publisher: crate::relay::RelayEventPublisher, + keys: nostr::Keys, + agent_pubkey_hex: String, + channel_info: crate::pool::ChannelInfoResolver, +) -> tokio::task::JoinHandle<()> { + // Subscribe synchronously so activity emitted immediately after this call is + // live input, while pre-existing snapshot entries remain intentionally absent. + let rx = observer.subscribe(); + tokio::spawn(async move { + run_relay_activity_publisher(rx, publisher, keys, agent_pubkey_hex, channel_info).await; + }) +} + +async fn run_relay_activity_publisher( + mut rx: tokio::sync::broadcast::Receiver, + publisher: crate::relay::RelayEventPublisher, + keys: nostr::Keys, + agent_pubkey_hex: String, + channel_info: crate::pool::ChannelInfoResolver, +) { + let mut projector = ActivityProjector::default(); + let mut queue = ActivityPublishQueue::default(); + let mut publish_tick = tokio::time::interval_at( + tokio::time::Instant::now() + ACTIVITY_PUBLISH_TICK, + ACTIVITY_PUBLISH_TICK, + ); + publish_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut closed = false; + + loop { + tokio::select! { + result = rx.recv(), if !closed => { + match result { + Ok(event) => { + if let Some(projected) = projector.project(&event) { + queue.ingest(projected); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + tracing::warn!(dropped = count, "agent activity publisher lagged"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + closed = true; + } + } + } + _ = publish_tick.tick() => { + if let Some((channel_id, frame)) = queue.next_frame() { + let channel_type = channel_info + .resolve(channel_id) + .await + .map(|info| info.channel_type); + if is_shared_activity_channel_type(channel_type.as_deref()) { + publish_activity_frame( + &publisher, + &keys, + &agent_pubkey_hex, + channel_id, + frame, + ) + .await; + } else { + tracing::debug!( + channel_id = %channel_id, + "sanitized agent activity suppressed for non-shared channel" + ); + } + } + if closed && queue.is_empty() { + break; + } + } + } + } +} + +fn is_shared_activity_channel_type(channel_type: Option<&str>) -> bool { + matches!(channel_type, Some("stream" | "forum")) +} + +async fn publish_activity_frame( + publisher: &crate::relay::RelayEventPublisher, + keys: &nostr::Keys, + agent_pubkey_hex: &str, + channel_id: Uuid, + frame: AgentActivityFrame, +) { + let builder = match buzz_sdk::build_agent_activity_summary(channel_id, agent_pubkey_hex, &frame) + { + Ok(builder) => builder, + Err(error) => { + tracing::warn!("failed to build sanitized agent activity: {error}"); + return; + } + }; + let signed = match builder.sign_with_keys(keys) { + Ok(event) => event, + Err(error) => { + tracing::warn!("failed to sign sanitized agent activity: {error}"); + return; + } + }; + if let Err(error) = publisher.publish_event(signed).await { + // Summary publication is telemetry: relay failure must never surface to + // or delay the prompt task that generated it. + tracing::warn!("sanitized agent activity dropped: {error}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use buzz_core::agent_activity::{ + AgentActivityClass, AgentActivityStatus, AgentActivityToolKind, + }; + use uuid::Uuid; + + fn observer_event( + kind: &str, + channel_id: Uuid, + turn_id: &str, + payload: serde_json::Value, + ) -> crate::observer::ObserverEvent { + observer_event_at(kind, channel_id, turn_id, "2026-08-12T10:00:00Z", payload) + } + + fn observer_event_at( + kind: &str, + channel_id: Uuid, + turn_id: &str, + timestamp: &str, + payload: serde_json::Value, + ) -> crate::observer::ObserverEvent { + crate::observer::ObserverEvent { + seq: 1, + timestamp: timestamp.to_string(), + kind: kind.to_string(), + agent_index: Some(0), + channel_id: Some(channel_id.to_string()), + session_id: Some("raw-session-secret".to_string()), + turn_id: Some(turn_id.to_string()), + started_at: Some("2026-08-12T09:59:59Z".to_string()), + payload, + } + } + + #[test] + fn turn_started_projects_only_closed_safe_fields_with_opaque_id() { + let channel_id = Uuid::new_v4(); + let raw_turn_id = "raw-turn-secret"; + let event = observer_event( + "turn_started", + channel_id, + raw_turn_id, + serde_json::json!({ + "prompt": "SECRET PROMPT", + "thought": "SECRET THOUGHT", + "plan": "SECRET PLAN", + "message": "SECRET MESSAGE", + "title": "SECRET TITLE", + "args": {"path": "/secret/path", "url": "https://secret.invalid"}, + "result": "SECRET RESULT", + "error": "SECRET ERROR", + "triggeringEventIds": ["raw-event-secret"] + }), + ); + + let mut projector = ActivityProjector::default(); + let projected = projector.project(&event).expect("safe turn update"); + assert_eq!(projected.channel_id, channel_id); + assert_eq!(projected.activity.activity_class, AgentActivityClass::Turn); + assert_eq!(projected.activity.status, AgentActivityStatus::Started); + assert_ne!(projected.activity.activity_id.to_string(), raw_turn_id); + + let serialized = serde_json::to_string(&projected.activity).unwrap(); + for secret in [ + "SECRET PROMPT", + "SECRET THOUGHT", + "SECRET PLAN", + "SECRET MESSAGE", + "SECRET TITLE", + "SECRET RESULT", + "SECRET ERROR", + "/secret/path", + "https://secret.invalid", + "raw-event-secret", + "raw-session-secret", + raw_turn_id, + ] { + assert!( + !serialized.contains(secret), + "leaked {secret}: {serialized}" + ); + } + } + + #[test] + fn turn_lifecycle_reuses_opaque_id_bounds_duration_and_removes_terminal_state() { + let cases = [ + ("completed", AgentActivityStatus::Completed), + ("failed", AgentActivityStatus::Failed), + ("cancelled", AgentActivityStatus::Cancelled), + ]; + + for (terminal, expected_status) in cases { + let channel_id = Uuid::new_v4(); + let raw_turn_id = format!("raw-turn-{terminal}"); + let mut projector = ActivityProjector::default(); + let started = projector + .project(&observer_event_at( + "turn_started", + channel_id, + &raw_turn_id, + "2026-08-12T10:00:00Z", + serde_json::json!({}), + )) + .expect("started"); + let running = projector + .project(&observer_event_at( + "turn_liveness", + channel_id, + &raw_turn_id, + "2026-08-12T10:00:01Z", + serde_json::json!({}), + )) + .expect("running"); + let terminal_update = projector + .project(&observer_event_at( + "agent_activity_turn_terminal", + channel_id, + &raw_turn_id, + "2026-08-20T10:00:00Z", + serde_json::json!({"status": terminal}), + )) + .expect("terminal"); + + assert_eq!(running.activity.status, AgentActivityStatus::Running); + assert_eq!(running.activity.activity_id, started.activity.activity_id); + assert_eq!(terminal_update.activity.status, expected_status); + assert_eq!( + terminal_update.activity.activity_id, + started.activity.activity_id + ); + assert_eq!( + terminal_update.activity.duration_ms, + Some(buzz_core::agent_activity::AGENT_ACTIVITY_MAX_DURATION_MS), + "eight-day duration is capped at the core seven-day bound" + ); + + assert!( + projector + .project(&observer_event_at( + "turn_liveness", + channel_id, + &raw_turn_id, + "2026-08-20T10:00:01Z", + serde_json::json!({}), + )) + .is_none(), + "terminal removes raw turn state" + ); + } + } + + #[test] + fn acp_tool_updates_project_only_closed_kind_status_and_opaque_id() { + let channel_id = Uuid::new_v4(); + let raw_turn_id = "raw-turn-secret"; + let raw_tool_id = "raw-tool-secret"; + let mut projector = ActivityProjector::default(); + assert!(projector + .project(&observer_event_at( + "turn_started", + channel_id, + raw_turn_id, + "2026-08-12T10:00:00Z", + serde_json::json!({}), + )) + .is_some()); + + let pending = projector + .project(&observer_event_at( + "acp_read", + channel_id, + raw_turn_id, + "2026-08-12T10:00:01Z", + serde_json::json!({ + "method": "session/update", + "params": {"update": { + "sessionUpdate": "tool_call", + "toolCallId": raw_tool_id, + "kind": "read", + "status": "pending", + "title": "SECRET TITLE", + "name": "SECRET NAME", + "rawInput": {"path": "/secret/path", "url": "https://secret.invalid"}, + "content": "SECRET CONTENT", + "result": "SECRET RESULT", + "error": "SECRET ERROR" + }} + }), + )) + .expect("pending tool"); + let running = projector + .project(&observer_event_at( + "acp_read", + channel_id, + raw_turn_id, + "2026-08-12T10:00:02Z", + serde_json::json!({ + "method": "session/update", + "params": {"update": { + "sessionUpdate": "tool_call_update", + "toolCallId": raw_tool_id, + "status": "in_progress", + "content": "SECRET UPDATE" + }} + }), + )) + .expect("running tool"); + let completed = projector + .project(&observer_event_at( + "acp_read", + channel_id, + raw_turn_id, + "2026-08-12T10:00:03.250Z", + serde_json::json!({ + "method": "session/update", + "params": {"update": { + "sessionUpdate": "tool_call_update", + "toolCallId": raw_tool_id, + "status": "completed", + "content": "SECRET OUTPUT", + "rawOutput": {"error": "SECRET RAW ERROR"} + }} + }), + )) + .expect("completed tool"); + + assert_eq!(pending.activity.activity_class, AgentActivityClass::Tool); + assert_eq!(pending.activity.status, AgentActivityStatus::Pending); + assert_eq!( + pending.activity.tool_kind, + Some(AgentActivityToolKind::Read) + ); + assert_eq!(running.activity.status, AgentActivityStatus::Running); + assert_eq!(running.activity.activity_id, pending.activity.activity_id); + assert_eq!(completed.activity.status, AgentActivityStatus::Completed); + assert_eq!(completed.activity.activity_id, pending.activity.activity_id); + assert_eq!(completed.activity.duration_ms, Some(2_250)); + + let serialized = + serde_json::to_string(&[pending.activity, running.activity, completed.activity]) + .unwrap(); + for secret in [ + raw_turn_id, + raw_tool_id, + "SECRET TITLE", + "SECRET NAME", + "/secret/path", + "https://secret.invalid", + "SECRET CONTENT", + "SECRET RESULT", + "SECRET ERROR", + "SECRET UPDATE", + "SECRET OUTPUT", + "SECRET RAW ERROR", + ] { + assert!( + !serialized.contains(secret), + "leaked {secret}: {serialized}" + ); + } + assert!( + projector + .project(&observer_event( + "acp_read", + channel_id, + raw_turn_id, + serde_json::json!({ + "method": "session/update", + "params": {"update": { + "sessionUpdate": "tool_call_update", + "toolCallId": raw_tool_id, + "status": "failed" + }} + }), + )) + .is_none(), + "terminal removes raw tool state" + ); + } + + #[test] + fn tool_kind_mapping_is_closed_and_unknown_kind_is_other() { + let cases = [ + ("read", AgentActivityToolKind::Read), + ("edit", AgentActivityToolKind::Edit), + ("delete", AgentActivityToolKind::Delete), + ("move", AgentActivityToolKind::Move), + ("search", AgentActivityToolKind::Search), + ("execute", AgentActivityToolKind::Execute), + ("think", AgentActivityToolKind::Think), + ("fetch", AgentActivityToolKind::Fetch), + ("switch_mode", AgentActivityToolKind::SwitchMode), + ("other", AgentActivityToolKind::Other), + ("shell-with-secret-name", AgentActivityToolKind::Other), + ]; + for (index, (kind, expected)) in cases.into_iter().enumerate() { + let channel_id = Uuid::new_v4(); + let mut projector = ActivityProjector::default(); + let event = observer_event( + "acp_read", + channel_id, + "turn-a", + serde_json::json!({ + "method": "session/update", + "params": {"update": { + "sessionUpdate": "tool_call", + "toolCallId": format!("tool-{index}"), + "kind": kind, + "status": "in_progress" + }} + }), + ); + assert_eq!( + projector + .project(&event) + .expect("trusted tool event") + .activity + .tool_kind, + Some(expected) + ); + } + } + + #[test] + fn unsafe_or_unknown_tool_status_event_and_ids_emit_nothing() { + let channel_id = Uuid::new_v4(); + let mut projector = ActivityProjector::default(); + let tool_call = |session_update: &str, status: &str, tool_id: &str| { + observer_event( + "acp_read", + channel_id, + "turn-a", + serde_json::json!({ + "method": "session/update", + "params": {"update": { + "sessionUpdate": session_update, + "toolCallId": tool_id, + "kind": "read", + "status": status + }} + }), + ) + }; + + for event in [ + tool_call("tool_call", "user-controlled-status", "tool-a"), + tool_call("untrusted_update", "pending", "tool-b"), + tool_call("tool_call", "pending", ""), + tool_call("tool_call", "pending", &"x".repeat(MAX_RAW_ID_BYTES + 1)), + ] { + assert!(projector.project(&event).is_none()); + } + let mut wrong_method = tool_call("tool_call", "pending", "tool-c"); + wrong_method.payload["method"] = serde_json::json!("evil/update"); + assert!(projector.project(&wrong_method).is_none()); + assert!(projector + .project(&tool_call("tool_call_update", "completed", "unknown-tool")) + .is_none()); + } + + #[test] + fn reliable_per_turn_usage_projects_counts_without_sensitive_or_cumulative_fields() { + let channel_id = Uuid::new_v4(); + let raw_turn_id = "raw-turn-secret"; + let mut projector = ActivityProjector::default(); + assert!(projector + .project(&observer_event( + "turn_started", + channel_id, + raw_turn_id, + serde_json::json!({}), + )) + .is_some()); + let projected = projector + .project(&observer_event( + "agent_activity_turn_usage", + channel_id, + raw_turn_id, + serde_json::json!({ + "deltaReliable": true, + "inputTokens": 11, + "outputTokens": 7, + "totalTokens": 18, + "cacheReadTokens": 3, + "cacheWriteTokens": 2, + "model": "SECRET MODEL", + "provider": "SECRET PROVIDER", + "costUsd": 99.0, + "cumulativeInputTokens": 999, + "sessionId": "SECRET SESSION" + }), + )) + .expect("reliable per-turn usage"); + + assert_eq!(projected.activity.activity_class, AgentActivityClass::Usage); + assert_eq!(projected.activity.status, AgentActivityStatus::Completed); + let usage = projected.activity.usage.as_ref().expect("usage counts"); + assert_eq!(usage.input_tokens, Some(11)); + assert_eq!(usage.output_tokens, Some(7)); + assert_eq!(usage.total_tokens, Some(18)); + assert_eq!(usage.cache_read_tokens, Some(3)); + assert_eq!(usage.cache_write_tokens, Some(2)); + let serialized = serde_json::to_string(&projected.activity).unwrap(); + for secret in [ + raw_turn_id, + "SECRET MODEL", + "SECRET PROVIDER", + "SECRET SESSION", + "costUsd", + "cumulativeInputTokens", + ] { + assert!( + !serialized.contains(secret), + "leaked {secret}: {serialized}" + ); + } + } + + #[test] + fn unreliable_empty_overflow_or_unknown_turn_usage_emits_nothing() { + let channel_id = Uuid::new_v4(); + let mut projector = ActivityProjector::default(); + assert!(projector + .project(&observer_event( + "turn_started", + channel_id, + "known-turn", + serde_json::json!({}), + )) + .is_some()); + for (turn_id, payload) in [ + ( + "known-turn", + serde_json::json!({"deltaReliable": false, "inputTokens": 1}), + ), + ("known-turn", serde_json::json!({"deltaReliable": true})), + ( + "known-turn", + serde_json::json!({ + "deltaReliable": true, + "inputTokens": buzz_core::agent_activity::AGENT_ACTIVITY_MAX_TOKEN_COUNT + 1 + }), + ), + ( + "unknown-turn", + serde_json::json!({"deltaReliable": true, "inputTokens": 1}), + ), + ] { + assert!(projector + .project(&observer_event( + "agent_activity_turn_usage", + channel_id, + turn_id, + payload, + )) + .is_none()); + } + } + + #[test] + fn usage_observer_payload_exposes_only_reliable_per_turn_counts() { + let reliable = crate::usage::TurnUsage { + session_id: "SECRET SESSION".into(), + turn_seq: 8, + delta_reliable: true, + turn_input_tokens: Some(11), + turn_output_tokens: Some(7), + turn_total_tokens: Some(18), + turn_cost_usd: Some(99.0), + turn_cache_read_tokens: Some(3), + turn_cache_write_tokens: Some(2), + cumulative_input_tokens: Some(1_111), + cumulative_output_tokens: Some(777), + cumulative_total_tokens: Some(1_888), + cumulative_cost_usd: Some(999.0), + cumulative_cache_read_tokens: Some(333), + cumulative_cache_write_tokens: Some(222), + model: Some("SECRET MODEL".into()), + pricing_identity: None, + }; + let payload = usage_observer_payload(&reliable).expect("reliable payload"); + assert_eq!(payload["inputTokens"], 11); + assert_eq!(payload["outputTokens"], 7); + assert_eq!(payload["totalTokens"], 18); + assert_eq!(payload["cacheReadTokens"], 3); + assert_eq!(payload["cacheWriteTokens"], 2); + let serialized = payload.to_string(); + for forbidden in [ + "SECRET SESSION", + "SECRET MODEL", + "cost", + "cumulative", + "provider", + "pricing", + "turnSeq", + ] { + assert!( + !serialized + .to_ascii_lowercase() + .contains(&forbidden.to_ascii_lowercase()), + "leaked {forbidden}: {serialized}" + ); + } + + let mut unreliable = reliable; + unreliable.delta_reliable = false; + assert!(usage_observer_payload(&unreliable).is_none()); + } + + #[test] + fn terminal_updates_replace_pending_lifecycle_and_survive_overflow() { + let channel_id = Uuid::from_u128(7); + let activity_id = Uuid::from_u128(77); + let mut queue = ActivityPublishQueue::default(); + for status in [ + AgentActivityStatus::Started, + AgentActivityStatus::Running, + AgentActivityStatus::Completed, + ] { + queue.ingest(ProjectedActivity { + channel_id, + activity: AgentActivity { + activity_id, + status, + duration_ms: (status == AgentActivityStatus::Completed).then_some(10), + ..test_turn_activity(77) + }, + }); + } + assert_eq!( + queue.pending_items, 1, + "terminal state supersedes queued lifecycle noise" + ); + + for index in 0..ACTIVITY_PENDING_MAX_ITEMS { + queue.ingest(ProjectedActivity { + channel_id, + activity: test_turn_activity(100_000 + index as u128), + }); + } + + assert_eq!(queue.pending_items, ACTIVITY_PENDING_MAX_ITEMS); + assert!( + queue.channels.values().flatten().any(|queued| { + queued.activity.activity_id == activity_id + && queued.activity.status == AgentActivityStatus::Completed + }), + "terminal state has eviction priority over non-terminal activity" + ); + } + + #[test] + fn activity_queue_enforces_channel_item_and_byte_bounds_with_oldest_drop_metrics() { + let mut queue = ActivityPublishQueue::default(); + let channels: Vec<_> = (0..=ACTIVITY_PENDING_MAX_CHANNELS) + .map(|index| Uuid::from_u128(10_000 + index as u128)) + .collect(); + for (index, channel_id) in channels.iter().copied().enumerate() { + queue.ingest(ProjectedActivity { + channel_id, + activity: test_turn_activity(index as u128 + 1), + }); + } + assert!(queue.channel_count() <= ACTIVITY_PENDING_MAX_CHANNELS); + assert!(queue.pending_items <= ACTIVITY_PENDING_MAX_ITEMS); + assert!(queue.pending_bytes <= ACTIVITY_PENDING_MAX_BYTES); + assert_eq!(queue.dropped_items, 1, "oldest channel item evicted"); + assert!(queue.dropped_bytes > 0); + assert!(!queue.channels.contains_key(&channels[0])); + assert!(queue.channels.contains_key(channels.last().unwrap())); + + let newest_id = Uuid::from_u128(99_999); + for index in 0..=ACTIVITY_PENDING_MAX_ITEMS { + queue.ingest(ProjectedActivity { + channel_id: channels[1], + activity: AgentActivity { + activity_id: if index == ACTIVITY_PENDING_MAX_ITEMS { + newest_id + } else { + Uuid::from_u128(20_000 + index as u128) + }, + ..test_turn_activity(500_000 + index as u128) + }, + }); + } + assert!(queue.channel_count() <= ACTIVITY_PENDING_MAX_CHANNELS); + assert!(queue.pending_items <= ACTIVITY_PENDING_MAX_ITEMS); + assert!(queue.pending_bytes <= ACTIVITY_PENDING_MAX_BYTES); + assert!(queue.dropped_items > 1); + let retained_newest = queue + .channels + .values() + .flatten() + .any(|queued| queued.activity.activity_id == newest_id); + assert!( + retained_newest, + "newest item survives oldest-first eviction" + ); + } + + #[test] + fn activity_frames_use_core_limits_and_rotate_channels_fairly() { + let channel_a = Uuid::from_u128(1); + let channel_b = Uuid::from_u128(2); + let channel_c = Uuid::from_u128(3); + let mut queue = ActivityPublishQueue::default(); + for index in 0..40 { + queue.ingest(ProjectedActivity { + channel_id: channel_a, + activity: test_turn_activity(100 + index), + }); + } + for (channel_id, index) in [(channel_b, 1_000), (channel_c, 2_000)] { + queue.ingest(ProjectedActivity { + channel_id, + activity: test_turn_activity(index), + }); + } + + let first = queue.next_frame().expect("channel a frame"); + let second = queue.next_frame().expect("channel b frame"); + let third = queue.next_frame().expect("channel c frame"); + let fourth = queue.next_frame().expect("remaining channel a frame"); + assert_eq!( + [first.0, second.0, third.0, fourth.0], + [channel_a, channel_b, channel_c, channel_a] + ); + for (_, frame) in [first, second, third, fourth] { + let json = frame.to_json().expect("core-valid frame"); + assert!(frame.activities.len() <= buzz_core::agent_activity::AGENT_ACTIVITY_MAX_ITEMS); + assert!(json.len() <= buzz_core::agent_activity::AGENT_ACTIVITY_MAX_FRAME_BYTES); + } + assert!(queue.is_empty()); + } + + #[tokio::test(start_paused = true)] + async fn live_only_publisher_skips_replay_dm_and_caps_one_frame_per_tick() { + let stream_id = Uuid::from_u128(101); + let forum_id = Uuid::from_u128(102); + let dm_id = Uuid::from_u128(103); + let observer = crate::observer::ObserverHandle::in_process(); + observer.emit( + "turn_started", + Some(0), + &crate::observer::context_for( + Some(stream_id), + Some("replay-secret-session".into()), + Some("replay-secret-turn".into()), + ), + serde_json::json!({"prompt": "REPLAY SECRET"}), + ); + + let resolver = crate::pool::ChannelInfoResolver::new( + HashMap::from([ + ( + stream_id, + crate::relay::ChannelInfo { + name: "stream".into(), + channel_type: "stream".into(), + }, + ), + ( + forum_id, + crate::relay::ChannelInfo { + name: "forum".into(), + channel_type: "forum".into(), + }, + ), + ( + dm_id, + crate::relay::ChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + }, + ), + ]), + crate::relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:9".into(), + keys: nostr::Keys::generate(), + auth_tag_json: None, + }, + ); + let (publisher, mut published) = crate::relay::RelayEventPublisher::test_pair(); + let keys = nostr::Keys::generate(); + let agent_pubkey = keys.public_key().to_hex(); + let handle = spawn_relay_activity_publisher( + observer.clone(), + publisher, + keys, + agent_pubkey.clone(), + resolver, + ); + + tokio::time::advance(ACTIVITY_PUBLISH_TICK).await; + tokio::task::yield_now().await; + assert!( + published.try_recv().is_err(), + "snapshot/replay must not publish" + ); + + for (channel_id, turn_id) in [ + (dm_id, "dm-secret-turn"), + (stream_id, "stream-secret-turn"), + (forum_id, "forum-secret-turn"), + ] { + observer.emit( + "turn_started", + Some(0), + &crate::observer::context_for( + Some(channel_id), + Some("live-secret-session".into()), + Some(turn_id.into()), + ), + serde_json::json!({"message": "LIVE SECRET MESSAGE"}), + ); + } + tokio::task::yield_now().await; + + tokio::time::advance(ACTIVITY_PUBLISH_TICK).await; + tokio::task::yield_now().await; + assert!(published.try_recv().is_err(), "DM frame must be discarded"); + + tokio::time::advance(ACTIVITY_PUBLISH_TICK).await; + tokio::task::yield_now().await; + let stream = published + .try_recv() + .expect("stream frame on second live tick"); + assert_eq!(stream.kind.as_u16(), 24_201); + let stream_json = stream.content.clone(); + assert!(!stream_json.contains("SECRET")); + assert_eq!( + AgentActivityFrame::parse(&stream_json) + .unwrap() + .activities + .len(), + 1 + ); + assert_eq!( + stream + .tags + .iter() + .map(|tag| tag.as_slice()) + .collect::>(), + vec![ + &["h".to_string(), stream_id.to_string()][..], + &["agent".to_string(), agent_pubkey.clone()][..], + ] + ); + assert!(published.try_recv().is_err(), "at most one frame per tick"); + + tokio::time::advance(ACTIVITY_PUBLISH_TICK).await; + tokio::task::yield_now().await; + let forum = published.try_recv().expect("forum frame on next tick"); + assert!(forum + .tags + .iter() + .any(|tag| { tag.as_slice() == ["h".to_string(), forum_id.to_string()] })); + assert!(published.try_recv().is_err()); + handle.abort(); + } + + #[test] + fn only_stream_and_forum_channel_types_are_shareable() { + assert!(is_shared_activity_channel_type(Some("stream"))); + assert!(is_shared_activity_channel_type(Some("forum"))); + for channel_type in [ + Some("dm"), + Some("private"), + Some("workflow"), + Some("unknown"), + None, + ] { + assert!(!is_shared_activity_channel_type(channel_type)); + } + } + + #[tokio::test] + async fn publication_failure_is_best_effort() { + let (publisher, published) = crate::relay::RelayEventPublisher::test_pair(); + drop(published); + let keys = nostr::Keys::generate(); + publish_activity_frame( + &publisher, + &keys, + &keys.public_key().to_hex(), + Uuid::new_v4(), + AgentActivityFrame { + version: AGENT_ACTIVITY_FRAME_VERSION, + activities: vec![test_turn_activity(1)], + }, + ) + .await; + } + + fn test_turn_activity(index: u128) -> AgentActivity { + AgentActivity { + activity_id: Uuid::from_u128(index), + occurred_at: "2026-08-12T10:00:00Z".parse().unwrap(), + activity_class: AgentActivityClass::Turn, + status: AgentActivityStatus::Running, + tool_kind: None, + duration_ms: None, + usage: None, + } + } + + #[test] + fn invalid_or_unknown_turn_inputs_emit_nothing() { + let channel_id = Uuid::new_v4(); + let mut projector = ActivityProjector::default(); + + let mut invalid_channel = + observer_event("turn_started", channel_id, "turn-a", serde_json::json!({})); + invalid_channel.channel_id = Some("not-a-uuid".into()); + assert!(projector.project(&invalid_channel).is_none()); + + let mut invalid_timestamp = + observer_event("turn_started", channel_id, "turn-b", serde_json::json!({})); + invalid_timestamp.timestamp = "not-a-timestamp".into(); + assert!(projector.project(&invalid_timestamp).is_none()); + + for raw_id in ["".to_string(), "x".repeat(MAX_RAW_ID_BYTES + 1)] { + assert!(projector + .project(&observer_event( + "turn_started", + channel_id, + &raw_id, + serde_json::json!({}), + )) + .is_none()); + } + + assert!(projector + .project(&observer_event( + "not_a_trusted_event", + channel_id, + "turn-c", + serde_json::json!({"status": "completed"}), + )) + .is_none()); + assert!(projector + .project(&observer_event( + "agent_activity_turn_terminal", + channel_id, + "unknown-turn", + serde_json::json!({"status": "completed"}), + )) + .is_none()); + assert!(projector + .project(&observer_event( + "turn_started", + channel_id, + "turn-negative-duration", + serde_json::json!({}), + )) + .is_some()); + assert!(projector + .project(&observer_event_at( + "agent_activity_turn_terminal", + channel_id, + "turn-negative-duration", + "2026-08-12T09:59:59Z", + serde_json::json!({"status": "failed"}), + )) + .is_none()); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 27b9000b7bb..97f4e43e798 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1,6 +1,7 @@ #![deny(unsafe_code)] mod acp; +mod agent_activity; mod config; mod engram_fetch; mod filter; @@ -1876,6 +1877,7 @@ async fn tokio_main() -> Result<()> { let mut relay_observer_control_rx = None; let mut relay_observer_publisher_task = None; + let mut relay_activity_publisher_task = None; let mut relay_observer_publisher = None; if config.relay_observer { if let (Some(observer), Some(owner_pubkey_hex)) = @@ -1969,6 +1971,23 @@ async fn tokio_main() -> Result<()> { } } + let channel_info = pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()); + + // Shared activity is derived from the same in-process observer as the + // owner-only encrypted feed, but is independently sanitized, signed, paced, + // and authorized by the relay. It starts after channel discovery so DM and + // non-shared channel traffic can be suppressed before publication. + if let Some(activity_observer) = observer.clone() { + relay_activity_publisher_task = Some(agent_activity::spawn_relay_activity_publisher( + activity_observer, + relay.event_publisher(), + config.keys.clone(), + pubkey_hex.clone(), + channel_info.clone(), + )); + tracing::info!("member-safe relay activity enabled"); + } + if let Some((observer, publisher, keys, agent_pubkey, owner_pubkey, owner)) = relay_observer_publisher.take() { @@ -2032,7 +2051,7 @@ async fn tokio_main() -> Result<()> { .to_string_lossy() .to_string(), rest_client: relay.rest_client(), - channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), + channel_info, context_message_limit: config.context_message_limit, max_turns_per_session: config.max_turns_per_session, permission_mode: config.permission_mode, @@ -3337,6 +3356,9 @@ async fn tokio_main() -> Result<()> { if let Some(handle) = relay_observer_publisher_task.take() { handle.abort(); } + if let Some(handle) = relay_activity_publisher_task.take() { + handle.abort(); + } // Graceful relay shutdown — sends WebSocket close frame and waits up to 5s // for the background task to finish, rather than aborting immediately (#40). @@ -3876,6 +3898,23 @@ fn handle_prompt_result( PromptSource::Heartbeat => None, }; let turn_id = result.turn_id.clone(); + if let (Some(observer), Some(channel_id)) = (observer.as_ref(), channel_id) { + let status = match &result.outcome { + PromptOutcome::Ok(crate::acp::StopReason::Cancelled) + | PromptOutcome::Cancelled + | PromptOutcome::CancelDrainTimeout(_) => "cancelled", + PromptOutcome::Ok(_) => "completed", + PromptOutcome::Error(_) | PromptOutcome::Timeout(_) | PromptOutcome::AgentExited => { + "failed" + } + }; + observer.emit( + "agent_activity_turn_terminal", + Some(agent_index), + &observer::context_for(Some(channel_id), None, Some(turn_id.clone())), + serde_json::json!({"status": status}), + ); + } let emit_turn_error = |error_msg: &str, error_code: Option| { if let Some(ref observer) = observer { let mut payload = serde_json::json!({ @@ -4114,10 +4153,17 @@ fn recover_panicked_agent( } if let Some(ref observer) = observer { + let context = observer::context_for(meta.channel_id, None, Some(meta.turn_id.clone())); + observer.emit( + "agent_activity_turn_terminal", + Some(i), + &context, + serde_json::json!({"status": "failed"}), + ); observer.emit( "agent_panic", Some(i), - &observer::context_for(meta.channel_id, None, Some(meta.turn_id)), + &context, serde_json::json!({ "outcome": "panic", "error": format!("Agent task panicked: {join_error}"), @@ -7094,6 +7140,10 @@ mod error_outcome_emission_tests { let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); let observer = ObserverHandle::in_process(); + let expected_terminal_status = match &outcome { + PromptOutcome::Cancelled | PromptOutcome::CancelDrainTimeout(_) => "cancelled", + _ => "failed", + }; let result = PromptResult { agent, @@ -7117,17 +7167,25 @@ mod error_outcome_emission_tests { None, ); - let turn_errors: Vec<_> = observer - .snapshot() - .into_iter() - .filter(|e| e.kind == "turn_error") - .collect(); + let snapshot = observer.snapshot(); + let turn_errors: Vec<_> = snapshot.iter().filter(|e| e.kind == "turn_error").collect(); assert!( turn_errors .iter() .all(|event| event.turn_id.as_deref() == Some("test-turn-id")), "turn_error must retain the completed turn id" ); + let terminals: Vec<_> = snapshot + .iter() + .filter(|event| event.kind == "agent_activity_turn_terminal") + .collect(); + assert_eq!( + terminals.len(), + 1, + "every non-success channel turn is terminal" + ); + assert_eq!(terminals[0].turn_id.as_deref(), Some("test-turn-id")); + assert_eq!(terminals[0].payload["status"], expected_terminal_status); turn_errors.len() } @@ -7200,6 +7258,17 @@ mod error_outcome_emission_tests { Some(channel_id.to_string().as_str()) ); assert_eq!(panic.turn_id.as_deref(), Some("panic-turn-id")); + let terminal = observer + .snapshot() + .into_iter() + .find(|event| event.kind == "agent_activity_turn_terminal") + .expect("panic recovery emits a safe terminal activity event"); + assert_eq!( + terminal.channel_id.as_deref(), + Some(channel_id.to_string().as_str()) + ); + assert_eq!(terminal.turn_id.as_deref(), Some("panic-turn-id")); + assert_eq!(terminal.payload["status"], "failed"); } #[tokio::test] From 7b86ad8c2da19a9163f60f3e91dfd7f792bab108 Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Wed, 12 Aug 2026 14:49:24 +0700 Subject: [PATCH 04/11] fix(mobile): unwrap batched agent observer frames Signed-off-by: lordfarquad --- .../agent_activity/observer_subscription.dart | 35 +++++--- .../observer_subscription_test.dart | 84 +++++++++++++++++++ 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index c15686c582e..b3c32889828 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -189,23 +189,23 @@ class ObserverRelayNotifier extends Notifier { return; } - final frame = _decryptFrame(event, normalizedAgent, privHex); - if (frame == null) return; + final decryptedFrames = _decryptFrames(event, normalizedAgent, privHex); + if (decryptedFrames == null) return; - final dedupeKey = '${frame.seq}:${frame.timestamp}'; final dedupeKeys = _dedupeKeysByAgent.putIfAbsent( normalizedAgent, () => {}, ); - if (!dedupeKeys.add(dedupeKey)) { - return; - } - final frames = _framesByAgent.putIfAbsent( normalizedAgent, () => [], ); - frames.add(frame); + for (final frame in decryptedFrames) { + final dedupeKey = '${frame.seq}:${frame.timestamp}'; + if (dedupeKeys.add(dedupeKey)) { + frames.add(frame); + } + } frames.sort(_compareObserverFrames); if (frames.length > _maxObserverEvents) { @@ -220,7 +220,7 @@ class ObserverRelayNotifier extends Notifier { _emit(connection: ObserverConnectionState.open); } - ObserverFrame? _decryptFrame( + List? _decryptFrames( NostrEvent event, String normalizedAgent, String privHex, @@ -232,7 +232,7 @@ class ObserverRelayNotifier extends Notifier { ); final plaintext = nip44Decrypt(conversationKey, event.content); final json = jsonDecode(plaintext) as Map; - return ObserverFrame.fromJson(json); + return _unwrapObserverBatch(ObserverFrame.fromJson(json)); } catch (error) { _errorMessage = 'Observer event decrypt failed: $error'; _emit(connection: ObserverConnectionState.error); @@ -240,6 +240,21 @@ class ObserverRelayNotifier extends Notifier { } } + static List _unwrapObserverBatch(ObserverFrame frame) { + if (frame.kind != 'batch') return [frame]; + + final payload = frame.payload; + if (payload is! Map || payload['events'] is! List) return [frame]; + + final events = []; + for (final value in payload['events'] as List) { + if (value is Map) { + events.add(ObserverFrame.fromJson(Map.from(value))); + } + } + return events.isEmpty ? [frame] : events; + } + void _emit({required ObserverConnectionState connection}) { if (_disposed) return; state = ObserverRelayState( diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart index e7cdb03801f..0cb071c8d3f 100644 --- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart +++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart @@ -289,6 +289,90 @@ void main() { expect(otherChannelState.transcript, isEmpty); }, ); + + test( + 'unwraps batched observer frames before building the transcript', + () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + const channelId = 'test-channel'; + const turnId = 'turn-batched'; + final key = (channelId: channelId, agentPubkey: agentKeychain.public); + container.read(observerSubscriptionProvider(key)); + await Future.delayed(Duration.zero); + + final conversationKey = getConversationKey( + agentKeychain.secret, + ownerKeychain.public, + ); + final encrypted = nip44Encrypt( + conversationKey, + jsonEncode({ + 'seq': 2, + 'timestamp': '2026-08-12T06:00:01.000Z', + 'kind': 'batch', + 'channelId': channelId, + 'turnId': turnId, + 'payload': { + 'events': [ + { + 'seq': 1, + 'timestamp': '2026-08-12T06:00:00.000Z', + 'kind': 'turn_started', + 'channelId': channelId, + 'turnId': turnId, + 'payload': { + 'triggeringEventIds': ['0123456789abcdef'], + }, + }, + { + 'seq': 2, + 'timestamp': '2026-08-12T06:00:01.000Z', + 'kind': 'session_resolved', + 'channelId': channelId, + 'turnId': turnId, + 'payload': { + 'sessionId': 'session-batched', + 'isNewSession': true, + }, + }, + ], + }, + }), + ); + final event = nostr.Event.from( + kind: EventKind.agentObserverFrame, + content: encrypted, + tags: [ + ['p', ownerKeychain.public], + ['agent', agentKeychain.public], + ['frame', 'telemetry'], + ], + secretKey: agentKeychain.secret, + verify: false, + ); + + relaySession.emit(NostrEvent.fromJson(event.toMap())); + + final state = container.read(observerSubscriptionProvider(key)); + expect(state.connection, ObserverConnectionState.open); + expect(state.transcript.map((item) => (item as LifecycleItem).title), [ + 'Turn started', + 'Session ready', + ]); + }, + ); } class _RecordingRelaySession extends RelaySessionNotifier { From 502709aacb62cd4285fd87823e1b95399ac07fa7 Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Wed, 12 Aug 2026 17:36:43 +0700 Subject: [PATCH 05/11] feat(mobile): show member-safe agent activity Signed-off-by: lordfarquad --- .../agent_activity/agent_activity_mode.dart | 28 ++ .../agent_activity/agent_activity_sheet.dart | 243 ++++++++++- .../shared_activity_models.dart | 316 ++++++++++++++ .../shared_activity_subscription.dart | 247 +++++++++++ .../shared_activity_summary.dart | 177 ++++++++ .../lib/features/channels/members_sheet.dart | 8 + mobile/lib/shared/relay/nostr_models.dart | 1 + mobile/lib/shared/relay/relay_session.dart | 54 ++- .../relay_session/live_subscription.dart | 22 + .../agent_activity_mode_test.dart | 62 +++ .../shared_activity_models_test.dart | 209 +++++++++ .../shared_activity_subscription_test.dart | 400 ++++++++++++++++++ .../shared_activity_summary_test.dart | 118 ++++++ .../test/shared/relay/nostr_models_test.dart | 4 + .../test/shared/relay/relay_session_test.dart | 38 ++ 15 files changed, 1908 insertions(+), 19 deletions(-) create mode 100644 mobile/lib/features/channels/agent_activity/agent_activity_mode.dart create mode 100644 mobile/lib/features/channels/agent_activity/shared_activity_models.dart create mode 100644 mobile/lib/features/channels/agent_activity/shared_activity_subscription.dart create mode 100644 mobile/lib/features/channels/agent_activity/shared_activity_summary.dart create mode 100644 mobile/lib/shared/relay/relay_session/live_subscription.dart create mode 100644 mobile/test/features/channels/agent_activity/agent_activity_mode_test.dart create mode 100644 mobile/test/features/channels/agent_activity/shared_activity_models_test.dart create mode 100644 mobile/test/features/channels/agent_activity/shared_activity_subscription_test.dart create mode 100644 mobile/test/features/channels/agent_activity/shared_activity_summary_test.dart diff --git a/mobile/lib/features/channels/agent_activity/agent_activity_mode.dart b/mobile/lib/features/channels/agent_activity/agent_activity_mode.dart new file mode 100644 index 00000000000..a656472542e --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/agent_activity_mode.dart @@ -0,0 +1,28 @@ +enum AgentActivityMode { owner, shared, unavailable } + +/// Selects the owner-only transcript only for an exact verified owner match. +/// +/// Everyone else receives the sanitized stream only when they are a current +/// member of a non-DM shared channel. Unresolved ownership therefore fails away +/// from the privileged path without widening shared eligibility. +AgentActivityMode selectAgentActivityMode({ + required String? ownerPubkey, + required String? myPubkey, + required String? channelType, + required bool isCurrentMember, +}) { + if (ownerPubkey != null && + myPubkey != null && + _lowercaseHexPubkey.hasMatch(ownerPubkey) && + _lowercaseHexPubkey.hasMatch(myPubkey) && + ownerPubkey == myPubkey) { + return AgentActivityMode.owner; + } + + if (!isCurrentMember || (channelType != 'stream' && channelType != 'forum')) { + return AgentActivityMode.unavailable; + } + return AgentActivityMode.shared; +} + +final _lowercaseHexPubkey = RegExp(r'^[0-9a-f]{64}$'); diff --git a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart index 26a53f89038..c97ab830374 100644 --- a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart +++ b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart @@ -7,19 +7,62 @@ import '../../../shared/theme/theme.dart'; import '../../../shared/widgets/buzz_loading_indicator.dart'; import '../../profile/user_cache_provider.dart'; import '../date_formatters.dart'; +import 'agent_activity_mode.dart'; import 'observer_models.dart'; import 'observer_subscription.dart'; +import 'shared_activity_subscription.dart'; +import 'shared_activity_summary.dart'; import 'transcript_item_widget.dart'; -/// Full-screen modal bottom sheet showing the live agent activity transcript. -class AgentActivitySheet extends HookConsumerWidget { +/// Selects the privileged owner transcript or member-safe shared stream. +class AgentActivitySheet extends StatelessWidget { final String channelId; final String agentPubkey; + final String? ownerPubkey; + final String? currentPubkey; + final String channelType; + final bool isCurrentMember; const AgentActivitySheet({ super.key, required this.channelId, required this.agentPubkey, + required this.ownerPubkey, + required this.currentPubkey, + required this.channelType, + required this.isCurrentMember, + }); + + @override + Widget build(BuildContext context) { + final mode = selectAgentActivityMode( + ownerPubkey: ownerPubkey?.toLowerCase(), + myPubkey: currentPubkey?.toLowerCase(), + channelType: channelType, + isCurrentMember: isCurrentMember, + ); + return switch (mode) { + AgentActivityMode.owner => _OwnerAgentActivitySheet( + channelId: channelId, + agentPubkey: agentPubkey, + ), + AgentActivityMode.shared => _SharedAgentActivitySheet( + channelId: channelId, + agentPubkey: agentPubkey, + ), + AgentActivityMode.unavailable => const _UnavailableAgentActivitySheet(), + }; + } +} + +/// Full-screen modal bottom sheet showing the owner-only live transcript. +class _OwnerAgentActivitySheet extends HookConsumerWidget { + final String channelId; + final String agentPubkey; + + const _OwnerAgentActivitySheet({ + required this.channelId, + required this.agentPubkey, }); @override @@ -147,6 +190,190 @@ class AgentActivitySheet extends HookConsumerWidget { } } +class _SharedAgentActivitySheet extends HookConsumerWidget { + final String channelId; + final String agentPubkey; + + const _SharedAgentActivitySheet({ + required this.channelId, + required this.agentPubkey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activityState = ref.watch( + sharedActivitySubscriptionProvider(( + channelId: channelId, + agentPubkey: agentPubkey.toLowerCase(), + )), + ); + final profile = ref.watch( + userCacheProvider.select((cache) => cache[agentPubkey.toLowerCase()]), + ); + final botName = profile?.label ?? shortPubkey(agentPubkey); + + useEffect(() { + ref.read(userCacheProvider.notifier).preload([agentPubkey]); + return null; + }, [agentPubkey]); + + return DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, sheetScrollController) { + final bottomPadding = + MediaQuery.viewPaddingOf(context).bottom + Grid.sm; + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + LucideIcons.bot, + size: 18, + color: context.colors.onSurface, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + botName, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + _SharedConnectionBadge( + connection: activityState.connection, + ), + ], + ), + const SizedBox(height: Grid.half), + Text( + 'Showing privacy-safe live activity from this point.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xxs), + Divider(color: context.colors.outlineVariant), + ], + ), + ), + Expanded( + child: activityState.activities.isEmpty + ? _SharedEmptyState(state: activityState) + : SharedActivitySummary( + activities: activityState.activities, + controller: sheetScrollController, + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + bottomPadding, + ), + ), + ), + ], + ); + }, + ); + } +} + +class _UnavailableAgentActivitySheet extends StatelessWidget { + const _UnavailableAgentActivitySheet(); + + @override + Widget build(BuildContext context) => const SizedBox( + height: 320, + child: Center( + child: Padding( + padding: EdgeInsets.all(Grid.gutter), + child: Text( + 'Live activity is available to current channel members.', + textAlign: TextAlign.center, + ), + ), + ), + ); +} + +class _SharedEmptyState extends StatelessWidget { + final SharedActivityState state; + + const _SharedEmptyState({required this.state}); + + @override + Widget build(BuildContext context) { + final failed = + state.connection == SharedActivityConnectionState.closed || + state.connection == SharedActivityConnectionState.error; + if (failed) { + return Center( + child: Padding( + padding: const EdgeInsets.all(Grid.gutter), + child: Text( + state.errorMessage ?? 'Shared activity is unavailable.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + ); + } + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BuzzLoadingIndicator( + size: 28, + color: context.colors.onSurfaceVariant, + semanticLabel: 'Waiting for privacy-safe agent activity', + ), + const SizedBox(height: Grid.xxs), + Text( + 'Waiting for activity\u2026', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _SharedConnectionBadge extends StatelessWidget { + final SharedActivityConnectionState connection; + + const _SharedConnectionBadge({required this.connection}); + + @override + Widget build(BuildContext context) { + final (color, label) = switch (connection) { + SharedActivityConnectionState.connecting => ( + context.appColors.warning, + 'Connecting', + ), + SharedActivityConnectionState.live => (context.appColors.success, 'Live'), + SharedActivityConnectionState.closed => ( + context.colors.onSurfaceVariant, + 'Closed', + ), + SharedActivityConnectionState.error => (context.colors.error, 'Error'), + }; + return _ActivityBadge(color: color, label: label); + } +} + class _EmptyState extends StatelessWidget { final ObserverConnectionState connection; final String? errorMessage; @@ -225,6 +452,18 @@ class _ConnectionBadge extends StatelessWidget { ObserverConnectionState.error => (context.colors.error, 'Error'), }; + return _ActivityBadge(color: color, label: label); + } +} + +class _ActivityBadge extends StatelessWidget { + final Color color; + final String label; + + const _ActivityBadge({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric( horizontal: Grid.xxs, diff --git a/mobile/lib/features/channels/agent_activity/shared_activity_models.dart b/mobile/lib/features/channels/agent_activity/shared_activity_models.dart new file mode 100644 index 00000000000..1ca44450c0d --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/shared_activity_models.dart @@ -0,0 +1,316 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +const sharedActivityFrameVersion = 1; +const sharedActivityMaxFrameBytes = 4096; +const sharedActivityMaxFrameItems = 32; +const sharedActivityMaxDurationMs = 604800000; +const sharedActivityMaxTokenCount = 1000000000000; + +enum SharedActivityClass { turn, tool, usage } + +enum SharedActivityStatus { + started, + pending, + running, + completed, + failed, + cancelled, +} + +enum SharedActivityToolKind { + read, + edit, + delete, + move, + search, + execute, + think, + fetch, + switchMode, + other, +} + +@immutable +class SharedActivityUsage { + final int? inputTokens; + final int? outputTokens; + final int? totalTokens; + final int? cacheReadTokens; + final int? cacheWriteTokens; + + const SharedActivityUsage({ + this.inputTokens, + this.outputTokens, + this.totalTokens, + this.cacheReadTokens, + this.cacheWriteTokens, + }); +} + +@immutable +class SharedActivity { + final String activityId; + final DateTime occurredAt; + final SharedActivityClass activityClass; + final SharedActivityStatus status; + final SharedActivityToolKind? toolKind; + final int? durationMs; + final SharedActivityUsage? usage; + + const SharedActivity({ + required this.activityId, + required this.occurredAt, + required this.activityClass, + required this.status, + this.toolKind, + this.durationMs, + this.usage, + }); +} + +/// Parses the privacy-sanitized kind:24201 payload. +/// +/// This parser intentionally has no dependency on the owner-only observer +/// payloads. Every object is a closed schema: unknown fields are rejected +/// rather than ignored. +List parseSharedActivityFrame(String content) { + if (utf8.encode(content).length > sharedActivityMaxFrameBytes) { + throw const FormatException('shared activity frame is too large'); + } + + final Object? decoded = jsonDecode(content); + final frame = _stringMap(decoded, 'frame'); + _requireExactKeys(frame, const {'version', 'activities'}, const {}, 'frame'); + if (frame['version'] != sharedActivityFrameVersion) { + throw const FormatException('unsupported shared activity version'); + } + + final activities = frame['activities']; + if (activities is! List || + activities.isEmpty || + activities.length > sharedActivityMaxFrameItems) { + throw const FormatException('activities must contain 1 to 32 items'); + } + + return List.unmodifiable( + activities.map((value) => _parseActivity(_stringMap(value, 'activity'))), + ); +} + +SharedActivity _parseActivity(Map value) { + _requireExactKeys( + value, + const {'activityId', 'occurredAt', 'activityClass', 'status'}, + const {'toolKind', 'durationMs', 'usage'}, + 'activity', + ); + + final activityId = _requiredString(value, 'activityId'); + if (!_uuidPattern.hasMatch(activityId)) { + throw const FormatException('activityId must be a UUID'); + } + + final occurredAtValue = _requiredString(value, 'occurredAt'); + if (!_rfc3339Pattern.hasMatch(occurredAtValue)) { + throw const FormatException('occurredAt must be RFC3339'); + } + final occurredAt = DateTime.tryParse(occurredAtValue); + if (occurredAt == null) { + throw const FormatException('occurredAt must be a valid timestamp'); + } + + final activityClass = _parseActivityClass( + _requiredString(value, 'activityClass'), + ); + final status = _parseStatus(_requiredString(value, 'status')); + final toolKind = value.containsKey('toolKind') + ? _parseToolKind(_requiredString(value, 'toolKind')) + : null; + final durationMs = value.containsKey('durationMs') + ? _boundedInt( + value['durationMs'], + 'durationMs', + sharedActivityMaxDurationMs, + ) + : null; + final usage = value.containsKey('usage') + ? _parseUsage(_stringMap(value['usage'], 'usage')) + : null; + + if (durationMs != null && !_terminalStatuses.contains(status)) { + throw const FormatException( + 'durationMs is allowed only for terminal statuses', + ); + } + + switch (activityClass) { + case SharedActivityClass.turn: + if (toolKind != null || + usage != null || + status == SharedActivityStatus.pending) { + throw const FormatException('invalid turn activity'); + } + case SharedActivityClass.tool: + if (toolKind == null || + usage != null || + status == SharedActivityStatus.started) { + throw const FormatException('invalid tool activity'); + } + case SharedActivityClass.usage: + if (status != SharedActivityStatus.completed || + toolKind != null || + durationMs != null || + usage == null) { + throw const FormatException('invalid usage activity'); + } + } + + return SharedActivity( + activityId: activityId, + occurredAt: occurredAt.toUtc(), + activityClass: activityClass, + status: status, + toolKind: toolKind, + durationMs: durationMs, + usage: usage, + ); +} + +SharedActivityUsage _parseUsage(Map value) { + const fields = { + 'inputTokens', + 'outputTokens', + 'totalTokens', + 'cacheReadTokens', + 'cacheWriteTokens', + }; + _requireExactKeys(value, const {}, fields, 'usage'); + if (value.isEmpty) { + throw const FormatException('usage requires a token count'); + } + + int? count(String field) => value.containsKey(field) + ? _boundedInt(value[field], field, sharedActivityMaxTokenCount) + : null; + + return SharedActivityUsage( + inputTokens: count('inputTokens'), + outputTokens: count('outputTokens'), + totalTokens: count('totalTokens'), + cacheReadTokens: count('cacheReadTokens'), + cacheWriteTokens: count('cacheWriteTokens'), + ); +} + +SharedActivityClass _parseActivityClass(String value) => switch (value) { + 'turn' => SharedActivityClass.turn, + 'tool' => SharedActivityClass.tool, + 'usage' => SharedActivityClass.usage, + _ => throw const FormatException('unknown activityClass'), +}; + +SharedActivityStatus _parseStatus(String value) => switch (value) { + 'started' => SharedActivityStatus.started, + 'pending' => SharedActivityStatus.pending, + 'running' => SharedActivityStatus.running, + 'completed' => SharedActivityStatus.completed, + 'failed' => SharedActivityStatus.failed, + 'cancelled' => SharedActivityStatus.cancelled, + _ => throw const FormatException('unknown status'), +}; + +SharedActivityToolKind _parseToolKind(String value) => switch (value) { + 'read' => SharedActivityToolKind.read, + 'edit' => SharedActivityToolKind.edit, + 'delete' => SharedActivityToolKind.delete, + 'move' => SharedActivityToolKind.move, + 'search' => SharedActivityToolKind.search, + 'execute' => SharedActivityToolKind.execute, + 'think' => SharedActivityToolKind.think, + 'fetch' => SharedActivityToolKind.fetch, + 'switch_mode' => SharedActivityToolKind.switchMode, + 'other' => SharedActivityToolKind.other, + _ => throw const FormatException('unknown toolKind'), +}; + +Map _stringMap(Object? value, String name) { + if (value is! Map) { + throw FormatException('$name must be an object'); + } + return value; +} + +void _requireExactKeys( + Map value, + Set required, + Set optional, + String name, +) { + if (!value.keys.toSet().containsAll(required) || + value.keys.any( + (key) => !required.contains(key) && !optional.contains(key), + )) { + throw FormatException('$name has missing or unknown fields'); + } +} + +String _requiredString(Map value, String field) { + final result = value[field]; + if (result is! String || result.isEmpty) { + throw FormatException('$field must be a non-empty string'); + } + return result; +} + +int _boundedInt(Object? value, String field, int max) { + if (value is! int || value < 0 || value > max) { + throw FormatException('$field is outside its allowed range'); + } + return value; +} + +final _uuidPattern = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', +); +final _rfc3339Pattern = RegExp( + r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$', +); +const _terminalStatuses = { + SharedActivityStatus.completed, + SharedActivityStatus.failed, + SharedActivityStatus.cancelled, +}; + +/// Bounded, activity-ID-deduplicating storage for shared activity updates. +class SharedActivityStore { + final int maxItems; + final LinkedHashMap _itemsById = LinkedHashMap(); + + SharedActivityStore({this.maxItems = 200}) + : assert(maxItems > 0, 'maxItems must be positive'); + + List get items => List.unmodifiable(_itemsById.values); + + void addAll(Iterable items) { + for (final item in items) { + _itemsById[item.activityId] = item; + } + + final ordered = _itemsById.values.toList() + ..sort((left, right) { + final time = left.occurredAt.compareTo(right.occurredAt); + return time != 0 ? time : left.activityId.compareTo(right.activityId); + }); + final retained = ordered.length > maxItems + ? ordered.sublist(ordered.length - maxItems) + : ordered; + _itemsById + ..clear() + ..addEntries(retained.map((item) => MapEntry(item.activityId, item))); + } + + void clear() => _itemsById.clear(); +} diff --git a/mobile/lib/features/channels/agent_activity/shared_activity_subscription.dart b/mobile/lib/features/channels/agent_activity/shared_activity_subscription.dart new file mode 100644 index 00000000000..a82b29ac3ea --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/shared_activity_subscription.dart @@ -0,0 +1,247 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../../shared/relay/relay.dart'; +import 'shared_activity_models.dart'; + +const _sharedActivityFreshness = Duration(minutes: 5); + +typedef SharedActivityKey = ({String channelId, String agentPubkey}); + +enum SharedActivityConnectionState { connecting, live, closed, error } + +@immutable +class SharedActivityState { + final SharedActivityConnectionState connection; + final List activities; + final String? errorMessage; + + const SharedActivityState({ + required this.connection, + required this.activities, + this.errorMessage, + }); +} + +final sharedActivityNowProvider = Provider( + (ref) => DateTime.now, +); + +/// A live-only, channel-and-agent-scoped safe activity subscription. +class SharedActivitySubscriptionNotifier extends Notifier { + final SharedActivityKey key; + final SharedActivityStore _store = SharedActivityStore(); + + SharedActivitySubscriptionNotifier(this.key); + + void Function()? _unsubscribe; + Future? _startFuture; + var _connection = SharedActivityConnectionState.connecting; + String? _errorMessage; + var _subscriptionEpoch = 0; + var _disposed = false; + var _disposeRegistered = false; + + @override + SharedActivityState build() { + final sessionState = ref.watch(relaySessionProvider); + _disposed = false; + + if (!_disposeRegistered) { + _disposeRegistered = true; + ref.onDispose(_dispose); + } + + if (!_hasCanonicalKey) { + _connection = SharedActivityConnectionState.error; + _errorMessage = 'Invalid shared activity channel or agent identity'; + return _snapshot(); + } + + if (sessionState.status == SessionStatus.connected) { + // A relay CLOSED is terminal for this authorization-scoped stream. + // Do not silently resubscribe after membership has been rejected. + if (_connection == SharedActivityConnectionState.closed) { + return _snapshot(); + } + if (_unsubscribe == null && _startFuture == null) { + _connection = SharedActivityConnectionState.connecting; + Future.microtask(_ensureSubscribed); + } else if (_unsubscribe != null) { + _connection = SharedActivityConnectionState.live; + _errorMessage = null; + } + } else if (_connection != SharedActivityConnectionState.closed && + _connection != SharedActivityConnectionState.error) { + _connection = SharedActivityConnectionState.connecting; + } + + return _snapshot(); + } + + bool get _hasCanonicalKey => + _canonicalUuidPattern.hasMatch(key.channelId) && + _lowercasePubkeyPattern.hasMatch(key.agentPubkey); + + Future _ensureSubscribed() { + if (_disposed || _unsubscribe != null) return Future.value(); + final pending = _startFuture; + if (pending != null) return pending; + + final epoch = _subscriptionEpoch; + final future = _subscribe(epoch); + _startFuture = future; + return future; + } + + Future _subscribe(int epoch) async { + try { + if (_disposed || epoch != _subscriptionEpoch) return; + _emit( + connection: SharedActivityConnectionState.connecting, + errorMessage: null, + ); + + final unsubscribe = await ref + .read(relaySessionProvider.notifier) + .subscribeValidatedLiveOnly( + NostrFilter( + kinds: const [EventKind.agentActivitySummary], + authors: [key.agentPubkey], + tags: { + '#h': [key.channelId], + }, + limit: 0, + ), + (event) => _verifiedActivities(event) != null, + _handleVerifiedEvent, + onClosed: (message) => _handleClosed(epoch, message), + ); + + if (_disposed || epoch != _subscriptionEpoch) { + unsubscribe(); + return; + } + + _unsubscribe = unsubscribe; + _emit(connection: SharedActivityConnectionState.live, errorMessage: null); + } catch (error) { + if (_disposed || epoch != _subscriptionEpoch) return; + _unsubscribe = null; + _emit( + connection: SharedActivityConnectionState.error, + errorMessage: 'Shared activity subscription failed: $error', + ); + } finally { + if (epoch == _subscriptionEpoch) _startFuture = null; + } + } + + void _handleVerifiedEvent(NostrEvent event) { + final activities = _verifiedActivities(event); + if (activities == null) return; + _store.addAll(activities); + _emit(connection: SharedActivityConnectionState.live, errorMessage: null); + } + + List? _verifiedActivities(NostrEvent event) { + if (!_hasValidSignatureAndId(event) || + event.kind != EventKind.agentActivitySummary || + event.pubkey != key.agentPubkey || + !_isFresh(event.createdAt) || + !_hasExactTags(event)) { + return null; + } + + try { + return parseSharedActivityFrame(event.content); + } on FormatException { + return null; + } + } + + bool _isFresh(int createdAt) { + final eventTime = DateTime.fromMillisecondsSinceEpoch( + createdAt * 1000, + isUtc: true, + ); + final difference = ref + .read(sharedActivityNowProvider)() + .toUtc() + .difference(eventTime); + return difference.abs() <= _sharedActivityFreshness; + } + + bool _hasExactTags(NostrEvent event) { + if (event.tags.length != 2) return false; + final channelTag = event.tags[0]; + final agentTag = event.tags[1]; + return channelTag.length == 2 && + channelTag[0] == 'h' && + channelTag[1] == key.channelId && + _canonicalUuidPattern.hasMatch(channelTag[1]) && + agentTag.length == 2 && + agentTag[0] == 'agent' && + agentTag[1] == event.pubkey.toLowerCase() && + _lowercasePubkeyPattern.hasMatch(agentTag[1]); + } + + static bool _hasValidSignatureAndId(NostrEvent event) { + try { + nostr.Event.fromMap(event.toJson(), verify: true); + return true; + } catch (_) { + return false; + } + } + + void _handleClosed(int epoch, String message) { + if (_disposed || epoch != _subscriptionEpoch) return; + _unsubscribe = null; + _store.clear(); + _emit( + connection: SharedActivityConnectionState.closed, + errorMessage: 'Shared activity subscription closed: $message', + ); + } + + void _emit({ + required SharedActivityConnectionState connection, + required String? errorMessage, + }) { + if (_disposed) return; + _connection = connection; + _errorMessage = errorMessage; + state = _snapshot(); + } + + SharedActivityState _snapshot() => SharedActivityState( + connection: _connection, + activities: _store.items, + errorMessage: _errorMessage, + ); + + void _dispose() { + _disposed = true; + _subscriptionEpoch += 1; + _unsubscribe?.call(); + _unsubscribe = null; + _startFuture = null; + _store.clear(); + } +} + +final sharedActivitySubscriptionProvider = NotifierProvider.autoDispose + .family< + SharedActivitySubscriptionNotifier, + SharedActivityState, + SharedActivityKey + >(SharedActivitySubscriptionNotifier.new); + +final _canonicalUuidPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', +); +final _lowercasePubkeyPattern = RegExp(r'^[0-9a-f]{64}$'); diff --git a/mobile/lib/features/channels/agent_activity/shared_activity_summary.dart b/mobile/lib/features/channels/agent_activity/shared_activity_summary.dart new file mode 100644 index 00000000000..48ff73cab24 --- /dev/null +++ b/mobile/lib/features/channels/agent_activity/shared_activity_summary.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../shared/theme/theme.dart'; +import 'shared_activity_models.dart'; + +/// Privacy-safe presentation of member-visible managed-agent activity. +class SharedActivitySummary extends StatelessWidget { + final List activities; + final ScrollController? controller; + final EdgeInsetsGeometry? padding; + + const SharedActivitySummary({ + super.key, + required this.activities, + this.controller, + this.padding, + }); + + @override + Widget build(BuildContext context) { + return ListView.separated( + controller: controller, + padding: padding ?? const EdgeInsets.all(Grid.gutter), + itemCount: activities.length, + separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs), + itemBuilder: (context, index) => + _ActivityRow(activity: activities[index]), + ); + } +} + +class _ActivityRow extends StatelessWidget { + final SharedActivity activity; + + const _ActivityRow({required this.activity}); + + @override + Widget build(BuildContext context) { + final title = _activityTitle(activity); + final details = _activityDetails(activity); + return Container( + padding: const EdgeInsets.all(Grid.sm), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: context.colors.outlineVariant), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _activityIcon(activity), + size: 18, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (details case final details?) ...[ + const SizedBox(height: Grid.quarter), + Text( + details, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + if (activity.usage case final usage?) ...[ + const SizedBox(height: Grid.half), + Text( + 'Per-turn token usage', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.quarter), + Wrap( + spacing: Grid.xxs, + runSpacing: Grid.quarter, + children: _usageLabels(usage) + .map( + (label) => Text( + label, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ) + .toList(), + ), + ], + ], + ), + ), + if (activity.activityClass != SharedActivityClass.usage) ...[ + const SizedBox(width: Grid.xxs), + Text( + _statusLabel(activity.status), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ); + } +} + +String _activityTitle(SharedActivity activity) => + switch (activity.activityClass) { + SharedActivityClass.turn => 'Working', + SharedActivityClass.tool => _toolLabel(activity.toolKind!), + SharedActivityClass.usage => 'Per-turn token usage', + }; + +String? _activityDetails(SharedActivity activity) { + final durationMs = activity.durationMs; + return durationMs == null ? null : 'Duration: ${_durationLabel(durationMs)}'; +} + +IconData _activityIcon(SharedActivity activity) => + switch (activity.activityClass) { + SharedActivityClass.turn => LucideIcons.activity, + SharedActivityClass.tool => LucideIcons.wrench, + SharedActivityClass.usage => LucideIcons.chartNoAxesColumn, + }; + +String _toolLabel(SharedActivityToolKind toolKind) => switch (toolKind) { + SharedActivityToolKind.read => 'Read', + SharedActivityToolKind.edit => 'Edit', + SharedActivityToolKind.delete => 'Delete', + SharedActivityToolKind.move => 'Move', + SharedActivityToolKind.search => 'Search', + SharedActivityToolKind.execute => 'Execute', + SharedActivityToolKind.think => 'Working', + SharedActivityToolKind.fetch => 'Fetch', + SharedActivityToolKind.switchMode => 'Switch mode', + SharedActivityToolKind.other => 'Tool', +}; + +String _statusLabel(SharedActivityStatus status) => switch (status) { + SharedActivityStatus.started || + SharedActivityStatus.pending || + SharedActivityStatus.running => 'Working', + SharedActivityStatus.completed => 'Completed', + SharedActivityStatus.failed => 'Failed', + SharedActivityStatus.cancelled => 'Cancelled', +}; + +String _durationLabel(int milliseconds) { + if (milliseconds < 1000) return '$milliseconds ms'; + final seconds = milliseconds / 1000; + final value = seconds == seconds.roundToDouble() + ? seconds.toStringAsFixed(0) + : seconds.toStringAsFixed(1); + return '$value s'; +} + +List _usageLabels(SharedActivityUsage usage) => [ + if (usage.inputTokens case final value?) 'Input: $value', + if (usage.outputTokens case final value?) 'Output: $value', + if (usage.totalTokens case final value?) 'Total: $value', + if (usage.cacheReadTokens case final value?) 'Cache read: $value', + if (usage.cacheWriteTokens case final value?) 'Cache write: $value', +]; diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 24c80daa5fb..d57b55b1274 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -35,6 +36,9 @@ class MembersSheet extends HookConsumerWidget { final userCache = ref.watch(userCacheProvider); final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id)); final statusCache = ref.watch(userStatusCacheProvider); + final agentOwners = + ref.watch(agentOwnersProvider).asData?.value ?? + const {}; // Determine if the current user can manage members. final currentMember = allMembers.cast().firstWhere( @@ -58,6 +62,10 @@ class MembersSheet extends HookConsumerWidget { builder: (_) => AgentActivitySheet( channelId: channel.id, agentPubkey: bot.pubkey, + ownerPubkey: agentOwners[bot.pubkey.toLowerCase()], + currentPubkey: currentPubkey, + channelType: channel.channelType, + isCurrentMember: currentMember != null, ), ); }); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 820fee4ed6a..ca0ce4f5b9f 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -16,6 +16,7 @@ abstract final class EventKind { static const typingIndicator = 20002; static const auth = 22242; static const agentObserverFrame = 24200; + static const agentActivitySummary = 24201; static const huddleReaction = 24810; static const readState = 30078; static const eventReminder = 30300; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 1c5c305b4cd..3f7e1314d64 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -18,6 +18,8 @@ import 'relay_provider.dart'; import 'relay_rate_limit_gate.dart'; import 'relay_socket.dart'; +part 'relay_session/live_subscription.dart'; + enum SessionStatus { disconnected, connecting, connected, reconnecting } @immutable @@ -36,23 +38,6 @@ class _HistorySubscription { _HistorySubscription({required this.completer, required this.timeout}); } -class _LiveSubscription { - final NostrFilter filter; - final void Function(NostrEvent) onEvent; - final void Function(String message)? onClosed; - Completer? readyCompleter; - int? lastSeenCreatedAt; - int closedRetryAttempt = 0; - Timer? closedRetryTimer; - - _LiveSubscription({ - required this.filter, - required this.onEvent, - this.onClosed, - this.readyCompleter, - }); -} - class _ClosedRetry { final _LiveSubscription subscription; final int generation; @@ -263,6 +248,29 @@ class RelaySessionNotifier extends Notifier { NostrFilter filter, void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, + }) => _subscribe(filter, onEvent, onClosed: onClosed); + + /// Subscribes without historical reconnect replay and validates each event + /// before it can affect deduplication or reconnect state. + Future subscribeValidatedLiveOnly( + NostrFilter filter, + bool Function(NostrEvent) admitEvent, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) => _subscribe( + filter, + onEvent, + onClosed: onClosed, + admitEvent: admitEvent, + replayFromWatermark: false, + ); + + Future _subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + bool Function(NostrEvent)? admitEvent, + bool replayFromWatermark = true, }) async { if (_disposed) throw StateError('Relay session is disposed'); final subId = _nextSubId('l'); @@ -272,6 +280,8 @@ class RelaySessionNotifier extends Notifier { filter: filter, onEvent: onEvent, onClosed: onClosed, + admitEvent: admitEvent, + replayFromWatermark: replayFromWatermark, readyCompleter: readyCompleter, ); @@ -578,6 +588,7 @@ class RelaySessionNotifier extends Notifier { !_disposed && generation == _connectionGeneration; NostrFilter _replayFilter(_LiveSubscription subscription) { + if (!subscription.replayFromWatermark) return subscription.filter; final since = subscription.lastSeenCreatedAt; return since == null ? subscription.filter @@ -618,6 +629,15 @@ class RelaySessionNotifier extends Notifier { // Live subscriptions get batched. final liveSub = _liveSubscriptions[subId]; if (liveSub != null) { + if (liveSub.admitEvent case final admitEvent?) { + bool admitted; + try { + admitted = admitEvent(event); + } catch (_) { + admitted = false; + } + if (!admitted) return; + } _resetClosedRetry(liveSub); // Track last seen timestamp for reconnect replay. if (liveSub.lastSeenCreatedAt == null || diff --git a/mobile/lib/shared/relay/relay_session/live_subscription.dart b/mobile/lib/shared/relay/relay_session/live_subscription.dart new file mode 100644 index 00000000000..f3db68754bf --- /dev/null +++ b/mobile/lib/shared/relay/relay_session/live_subscription.dart @@ -0,0 +1,22 @@ +part of '../relay_session.dart'; + +class _LiveSubscription { + final NostrFilter filter; + final void Function(NostrEvent) onEvent; + final void Function(String message)? onClosed; + final bool Function(NostrEvent)? admitEvent; + final bool replayFromWatermark; + Completer? readyCompleter; + int? lastSeenCreatedAt; + int closedRetryAttempt = 0; + Timer? closedRetryTimer; + + _LiveSubscription({ + required this.filter, + required this.onEvent, + this.onClosed, + this.admitEvent, + this.replayFromWatermark = true, + this.readyCompleter, + }); +} diff --git a/mobile/test/features/channels/agent_activity/agent_activity_mode_test.dart b/mobile/test/features/channels/agent_activity/agent_activity_mode_test.dart new file mode 100644 index 00000000000..f0d6c4dc36c --- /dev/null +++ b/mobile/test/features/channels/agent_activity/agent_activity_mode_test.dart @@ -0,0 +1,62 @@ +import 'package:buzz/features/channels/agent_activity/agent_activity_mode.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const me = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const other = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + AgentActivityMode select({ + String? ownerPubkey = other, + String? myPubkey = me, + String? channelType = 'stream', + bool isCurrentMember = true, + }) => selectAgentActivityMode( + ownerPubkey: ownerPubkey, + myPubkey: myPubkey, + channelType: channelType, + isCurrentMember: isCurrentMember, + ); + + test('unresolved ownership fails safely to eligible shared mode', () { + expect(select(ownerPubkey: null), AgentActivityMode.shared); + expect(select(myPubkey: null), AgentActivityMode.shared); + }); + + test('malformed or nonmatching ownership cannot select owner mode', () { + for (final owner in ['not-a-key', 'A' * 64, 'a' * 63, 'a' * 65]) { + expect( + select(ownerPubkey: owner, myPubkey: owner), + AgentActivityMode.shared, + reason: owner, + ); + } + expect(select(ownerPubkey: other), AgentActivityMode.shared); + }); + + test('only exact verified owner equality selects the full owner mode', () { + expect(select(ownerPubkey: me), AgentActivityMode.owner); + expect( + select(ownerPubkey: me, myPubkey: 'A' * 64), + AgentActivityMode.shared, + ); + expect( + select(ownerPubkey: me, channelType: 'dm', isCurrentMember: false), + AgentActivityMode.owner, + reason: 'the existing owner-only path is independent of shared access', + ); + }); + + test('shared mode is limited to current stream and forum members', () { + expect(select(channelType: 'stream'), AgentActivityMode.shared); + expect(select(channelType: 'forum'), AgentActivityMode.shared); + expect(select(channelType: 'dm'), AgentActivityMode.unavailable); + expect(select(channelType: 'unknown'), AgentActivityMode.unavailable); + expect(select(channelType: null), AgentActivityMode.unavailable); + expect(select(isCurrentMember: false), AgentActivityMode.unavailable); + expect( + select(ownerPubkey: null, isCurrentMember: false), + AgentActivityMode.unavailable, + ); + }); +} diff --git a/mobile/test/features/channels/agent_activity/shared_activity_models_test.dart b/mobile/test/features/channels/agent_activity/shared_activity_models_test.dart new file mode 100644 index 00000000000..53c7db0db5e --- /dev/null +++ b/mobile/test/features/channels/agent_activity/shared_activity_models_test.dart @@ -0,0 +1,209 @@ +import 'dart:convert'; + +import 'package:buzz/features/channels/agent_activity/shared_activity_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map activity({ + String activityId = '63ca9483-c457-4b24-88de-1f14fa97c499', + String occurredAt = '2026-08-12T08:39:49Z', + String activityClass = 'turn', + String status = 'started', + String? toolKind, + int? durationMs, + Map? usage, +}) => { + 'activityId': activityId, + 'occurredAt': occurredAt, + 'activityClass': activityClass, + 'status': status, + 'toolKind': ?toolKind, + 'durationMs': ?durationMs, + 'usage': ?usage, +}; + +String frame(List> activities) => + jsonEncode({'version': 1, 'activities': activities}); + +void main() { + group('parseSharedActivityFrame', () { + test('accepts the closed safe schema', () { + final items = parseSharedActivityFrame( + frame([ + activity(), + activity( + activityId: 'dd55208d-05a9-41d1-8199-d57664885212', + activityClass: 'tool', + status: 'completed', + toolKind: 'search', + durationMs: 325, + ), + activity( + activityId: '684d0d5f-aacc-4670-9b63-72ecf805fa0d', + activityClass: 'usage', + status: 'completed', + usage: {'inputTokens': 100, 'outputTokens': 25, 'totalTokens': 125}, + ), + ]), + ); + + expect(items, hasLength(3)); + expect(items[0].activityClass, SharedActivityClass.turn); + expect(items[1].toolKind, SharedActivityToolKind.search); + expect(items[1].durationMs, 325); + expect(items[2].usage?.totalTokens, 125); + }); + + test('rejects unknown and sensitive fields instead of ignoring them', () { + for (final sensitiveField in [ + 'prompt', + 'message', + 'title', + 'arguments', + 'result', + 'error', + 'model', + 'provider', + 'cost', + 'sessionId', + ]) { + final hostile = activity( + activityClass: 'tool', + status: 'running', + toolKind: 'execute', + )..[sensitiveField] = 'PRIVATE_VALUE'; + expect( + () => parseSharedActivityFrame(frame([hostile])), + throwsFormatException, + reason: sensitiveField, + ); + } + + expect( + () => parseSharedActivityFrame( + jsonEncode({ + 'version': 1, + 'activities': [activity()], + 'unknown': true, + }), + ), + throwsFormatException, + ); + }); + + test('rejects unknown variants and class-incompatible fields', () { + final invalid = >[ + activity(activityClass: 'message'), + activity(status: 'thinking'), + activity(activityClass: 'tool', status: 'running'), + activity(activityClass: 'turn', status: 'running', toolKind: 'search'), + activity( + activityClass: 'usage', + status: 'running', + usage: {'totalTokens': 1}, + ), + activity( + activityClass: 'usage', + status: 'completed', + usage: {}, + ), + ]; + + for (final candidate in invalid) { + expect( + () => parseSharedActivityFrame(frame([candidate])), + throwsFormatException, + reason: jsonEncode(candidate), + ); + } + }); + + test('enforces version, byte, count, duration, and usage bounds', () { + expect( + () => parseSharedActivityFrame( + jsonEncode({ + 'version': 2, + 'activities': [activity()], + }), + ), + throwsFormatException, + ); + expect(() => parseSharedActivityFrame(frame([])), throwsFormatException); + expect( + () => parseSharedActivityFrame( + frame( + List.generate( + 33, + (index) => activity( + activityId: + '00000000-0000-4000-8000-${index.toString().padLeft(12, '0')}', + ), + ), + ), + ), + throwsFormatException, + ); + expect( + () => parseSharedActivityFrame( + frame([activity(status: 'completed', durationMs: 604800001)]), + ), + throwsFormatException, + ); + expect( + () => parseSharedActivityFrame( + frame([ + activity( + activityClass: 'usage', + status: 'completed', + usage: {'totalTokens': 1000000000001}, + ), + ]), + ), + throwsFormatException, + ); + expect( + () => parseSharedActivityFrame( + jsonEncode({ + 'version': 1, + 'activities': [activity()], + 'padding': List.filled(4096, 'x').join(), + }), + ), + throwsFormatException, + ); + }); + }); + + test('store replaces duplicate activity ids and retains a bounded tail', () { + final store = SharedActivityStore(maxItems: 3); + final first = parseSharedActivityFrame(frame([activity()])).single; + final updated = parseSharedActivityFrame( + frame([activity(status: 'completed', durationMs: 10)]), + ).single; + + store.addAll([first]); + store.addAll([updated]); + expect(store.items, hasLength(1)); + expect(store.items.single.status, SharedActivityStatus.completed); + + store.addAll([ + for (var index = 1; index <= 4; index++) + parseSharedActivityFrame( + frame([ + activity( + activityId: + '00000000-0000-4000-8000-${index.toString().padLeft(12, '0')}', + occurredAt: + '2026-08-12T08:39:${(49 + index).toString().padLeft(2, '0')}Z', + ), + ]), + ).single, + ]); + + expect(store.items, hasLength(3)); + expect(store.items.map((item) => item.activityId), [ + '00000000-0000-4000-8000-000000000002', + '00000000-0000-4000-8000-000000000003', + '00000000-0000-4000-8000-000000000004', + ]); + }); +} diff --git a/mobile/test/features/channels/agent_activity/shared_activity_subscription_test.dart b/mobile/test/features/channels/agent_activity/shared_activity_subscription_test.dart new file mode 100644 index 00000000000..ff083749721 --- /dev/null +++ b/mobile/test/features/channels/agent_activity/shared_activity_subscription_test.dart @@ -0,0 +1,400 @@ +import 'dart:convert'; + +import 'package:buzz/features/channels/agent_activity/shared_activity_subscription.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +const _channelId = 'ab7351d0-59fd-4f30-b1d0-3e2754b66a50'; +final _now = DateTime.utc(2026, 8, 12, 12); + +void main() { + test('uses a separate live-only channel and agent subscription', () async { + final firstAgent = nostr.Keys.generate(); + final secondAgent = nostr.Keys.generate(); + final session = _RecordingRelaySession(); + final container = _container(session); + addTearDown(container.dispose); + + final firstKey = (channelId: _channelId, agentPubkey: firstAgent.public); + const secondChannel = '3caf753b-8e2b-4e59-81c6-6e9b962c459c'; + final secondKey = ( + channelId: secondChannel, + agentPubkey: secondAgent.public, + ); + final firstKeepAlive = container.listen( + sharedActivitySubscriptionProvider(firstKey), + (_, _) {}, + fireImmediately: true, + ); + final secondKeepAlive = container.listen( + sharedActivitySubscriptionProvider(secondKey), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(firstKeepAlive.close); + addTearDown(secondKeepAlive.close); + + await Future.delayed(Duration.zero); + + expect(session.filters, hasLength(2)); + expect(session.filters[0].kinds, [EventKind.agentActivitySummary]); + expect(session.filters[0].authors, [firstAgent.public]); + expect(session.filters[0].tags, { + '#h': [_channelId], + }); + expect(session.filters[0].limit, 0); + expect(session.filters[0].since, isNull); + expect(session.filters[0].until, isNull); + expect(session.filters[1].authors, [secondAgent.public]); + expect(session.filters[1].tags, { + '#h': [secondChannel], + }); + expect(session.historyCalls, 0); + }); + + test('accepts a fresh fully verified exact summary event', () async { + final agent = nostr.Keys.generate(); + final session = _RecordingRelaySession(); + final container = _container(session); + addTearDown(container.dispose); + final key = (channelId: _channelId, agentPubkey: agent.public); + final keepAlive = container.listen( + sharedActivitySubscriptionProvider(key), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + await Future.delayed(Duration.zero); + + expect( + container.read(sharedActivitySubscriptionProvider(key)).connection, + SharedActivityConnectionState.live, + ); + + session.emit(_signedEvent(agent, activities: [_activity()])); + + final state = container.read(sharedActivitySubscriptionProvider(key)); + expect(state.connection, SharedActivityConnectionState.live); + expect(state.activities, hasLength(1)); + expect(state.activities.single.activityId, _activityId(0)); + }); + + test('rejects unverified, mis-scoped, malformed, and stale events', () async { + final agent = nostr.Keys.generate(); + final other = nostr.Keys.generate(); + final session = _RecordingRelaySession(); + final container = _container(session); + addTearDown(container.dispose); + final key = (channelId: _channelId, agentPubkey: agent.public); + final keepAlive = container.listen( + sharedActivitySubscriptionProvider(key), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + await Future.delayed(Duration.zero); + + final valid = _signedEvent(agent, activities: [_activity()]); + final malformedEvents = [ + NostrEvent( + id: '0' * 64, + pubkey: valid.pubkey, + createdAt: valid.createdAt, + kind: valid.kind, + tags: valid.tags, + content: valid.content, + sig: valid.sig, + ), + NostrEvent( + id: valid.id, + pubkey: valid.pubkey, + createdAt: valid.createdAt, + kind: valid.kind, + tags: valid.tags, + content: valid.content, + sig: '0' * 128, + ), + _signedEvent(other, activities: [_activity()]), + _signedEvent( + agent, + activities: [_activity()], + tags: [ + ['h', _channelId], + ['agent', other.public], + ], + ), + _signedEvent( + agent, + activities: [_activity()], + tags: [ + ['h', '3caf753b-8e2b-4e59-81c6-6e9b962c459c'], + ['agent', agent.public], + ], + ), + _signedEvent( + agent, + activities: [_activity()], + tags: [ + ['h', _channelId], + ['h', _channelId], + ['agent', agent.public], + ], + ), + _signedEvent( + agent, + activities: [_activity()], + tags: [ + ['h', _channelId, 'extended'], + ['agent', agent.public], + ], + ), + _signedEvent( + agent, + activities: [_activity()], + tags: [ + ['h', _channelId], + ['agent', agent.public, 'extended'], + ], + ), + _signedEvent( + agent, + activities: [_activity()], + tags: [ + ['h', _channelId], + ['agent', agent.public], + ['p', other.public], + ], + ), + _signedEvent( + agent, + activities: [_activity()], + createdAt: _seconds( + _now.subtract(const Duration(minutes: 5, seconds: 1)), + ), + ), + _signedEvent( + agent, + activities: [_activity()], + createdAt: _seconds(_now.add(const Duration(minutes: 5, seconds: 1))), + ), + _signedEvent( + agent, + activities: [_activity()]..single['prompt'] = 'PRIVATE_PROMPT', + ), + ]; + + for (final event in malformedEvents) { + session.emit(event); + } + + expect( + container.read(sharedActivitySubscriptionProvider(key)).activities, + isEmpty, + ); + }); + + test( + 'deduplicates replays and retains only the newest 200 activities', + () async { + final agent = nostr.Keys.generate(); + final session = _RecordingRelaySession(); + final container = _container(session); + addTearDown(container.dispose); + final key = (channelId: _channelId, agentPubkey: agent.public); + final keepAlive = container.listen( + sharedActivitySubscriptionProvider(key), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + await Future.delayed(Duration.zero); + + final replay = _signedEvent(agent, activities: [_activity()]); + session.emit(replay); + session.emit(replay); + + var next = 1; + while (next <= 200) { + final end = (next + 15).clamp(0, 200); + session.emit( + _signedEvent( + agent, + activities: [ + for (var index = next; index <= end; index++) _activity(index), + ], + ), + ); + next = end + 1; + } + + final activities = container + .read(sharedActivitySubscriptionProvider(key)) + .activities; + expect(activities, hasLength(200)); + expect( + activities.map((item) => item.activityId), + isNot(contains(_activityId(0))), + ); + expect(activities.map((item) => item.activityId).toSet(), hasLength(200)); + }, + ); + + test('surfaces terminal CLOSED and subscribe errors', () async { + final agent = nostr.Keys.generate(); + final session = _RecordingRelaySession(); + final container = _container(session); + addTearDown(container.dispose); + final key = (channelId: _channelId, agentPubkey: agent.public); + final keepAlive = container.listen( + sharedActivitySubscriptionProvider(key), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + await Future.delayed(Duration.zero); + + session.emit(_signedEvent(agent, activities: [_activity()])); + expect( + container.read(sharedActivitySubscriptionProvider(key)).activities, + hasLength(1), + ); + + session.closeAll('restricted: channel membership required'); + var state = container.read(sharedActivitySubscriptionProvider(key)); + expect(state.connection, SharedActivityConnectionState.closed); + expect(state.errorMessage, contains('channel membership required')); + expect(state.activities, isEmpty); + + final failingSession = _RecordingRelaySession() + ..subscribeError = StateError('socket failed'); + final failingContainer = _container(failingSession); + addTearDown(failingContainer.dispose); + final failingKey = (channelId: _channelId, agentPubkey: agent.public); + final failingKeepAlive = failingContainer.listen( + sharedActivitySubscriptionProvider(failingKey), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(failingKeepAlive.close); + await Future.delayed(Duration.zero); + + state = failingContainer.read( + sharedActivitySubscriptionProvider(failingKey), + ); + expect(state.connection, SharedActivityConnectionState.error); + expect(state.errorMessage, contains('socket failed')); + }); +} + +ProviderContainer _container(_RecordingRelaySession session) => + ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + sharedActivityNowProvider.overrideWithValue(() => _now), + ], + ); + +Map _activity([int index = 0]) => { + 'activityId': _activityId(index), + 'occurredAt': _now.add(Duration(seconds: index)).toIso8601String(), + 'activityClass': 'turn', + 'status': 'started', +}; + +String _activityId(int index) => + '00000000-0000-4000-8000-${index.toString().padLeft(12, '0')}'; + +NostrEvent _signedEvent( + nostr.Keys signer, { + required List> activities, + List>? tags, + int? createdAt, +}) { + final event = nostr.Event.from( + kind: EventKind.agentActivitySummary, + content: jsonEncode({'version': 1, 'activities': activities}), + tags: + tags ?? + [ + ['h', _channelId], + ['agent', signer.public], + ], + secretKey: signer.secret, + createdAt: createdAt ?? _seconds(_now), + verify: true, + ); + return NostrEvent.fromJson(event.toMap()); +} + +int _seconds(DateTime value) => value.millisecondsSinceEpoch ~/ 1000; + +class _RecordingRelaySession extends RelaySessionNotifier { + final List filters = []; + final List _listeners = []; + final List _closedListeners = []; + Object? subscribeError; + int historyCalls = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + historyCalls += 1; + return const []; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) => _recordSubscription(filter, onEvent, onClosed: onClosed); + + @override + Future subscribeValidatedLiveOnly( + NostrFilter filter, + bool Function(NostrEvent) admitEvent, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) => _recordSubscription(filter, (event) { + if (admitEvent(event)) onEvent(event); + }, onClosed: onClosed); + + Future _recordSubscription( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + final error = subscribeError; + if (error != null) throw error; + filters.add(filter); + _listeners.add(onEvent); + if (onClosed != null) _closedListeners.add(onClosed); + return () { + filters.remove(filter); + _listeners.remove(onEvent); + if (onClosed != null) _closedListeners.remove(onClosed); + }; + } + + void emit(NostrEvent event) { + for (final listener in List.of(_listeners)) { + listener(event); + } + } + + void closeAll(String message) { + for (final listener in List.of(_closedListeners)) { + listener(message); + } + filters.clear(); + _listeners.clear(); + _closedListeners.clear(); + } +} diff --git a/mobile/test/features/channels/agent_activity/shared_activity_summary_test.dart b/mobile/test/features/channels/agent_activity/shared_activity_summary_test.dart new file mode 100644 index 00000000000..e5a12d6a17f --- /dev/null +++ b/mobile/test/features/channels/agent_activity/shared_activity_summary_test.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:buzz/features/channels/agent_activity/shared_activity_models.dart'; +import 'package:buzz/features/channels/agent_activity/shared_activity_summary.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('renders only neutral safe activity labels', (tester) async { + final activities = parseSharedActivityFrame( + jsonEncode({ + 'version': 1, + 'activities': [ + _activity(), + _activity( + id: 1, + activityClass: 'tool', + status: 'completed', + toolKind: 'search', + durationMs: 325, + ), + _activity( + id: 2, + activityClass: 'tool', + status: 'running', + toolKind: 'think', + ), + _activity( + id: 3, + activityClass: 'tool', + status: 'failed', + toolKind: 'execute', + durationMs: 1200, + ), + _activity( + id: 4, + activityClass: 'turn', + status: 'cancelled', + durationMs: 50, + ), + _activity( + id: 5, + activityClass: 'usage', + status: 'completed', + usage: { + 'inputTokens': 100, + 'outputTokens': 25, + 'totalTokens': 125, + 'cacheReadTokens': 20, + 'cacheWriteTokens': 4, + }, + ), + ], + }), + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + child: SizedBox( + height: 600, + child: SharedActivitySummary(activities: activities), + ), + ), + ); + + expect(find.text('Working'), findsWidgets); + expect(find.text('Search'), findsOneWidget); + expect(find.text('Execute'), findsOneWidget); + expect(find.text('Completed'), findsOneWidget); + expect(find.text('Failed'), findsOneWidget); + expect(find.text('Cancelled'), findsOneWidget); + expect(find.text('Duration: 325 ms'), findsOneWidget); + expect(find.text('Duration: 1.2 s'), findsOneWidget); + expect(find.text('Per-turn token usage'), findsNWidgets(2)); + expect(find.text('Input: 100'), findsOneWidget); + expect(find.text('Output: 25'), findsOneWidget); + expect(find.text('Total: 125'), findsOneWidget); + expect(find.text('Cache read: 20'), findsOneWidget); + expect(find.text('Cache write: 4'), findsOneWidget); + + final renderedText = tester + .widgetList(find.byType(Text)) + .map((widget) => widget.data ?? '') + .join(' ') + .toLowerCase(); + for (final forbidden in [ + 'think', + 'thinking', + 'reasoning', + 'chain-of-thought', + 'prompt', + 'arguments', + 'result', + 'error details', + ]) { + expect(renderedText, isNot(contains(forbidden)), reason: forbidden); + } + }); +} + +Map _activity({ + int id = 0, + String activityClass = 'turn', + String status = 'started', + String? toolKind, + int? durationMs, + Map? usage, +}) => { + 'activityId': '00000000-0000-4000-8000-${id.toString().padLeft(12, '0')}', + 'occurredAt': '2026-08-12T12:00:${id.toString().padLeft(2, '0')}Z', + 'activityClass': activityClass, + 'status': status, + 'toolKind': ?toolKind, + 'durationMs': ?durationMs, + 'usage': ?usage, +}; diff --git a/mobile/test/shared/relay/nostr_models_test.dart b/mobile/test/shared/relay/nostr_models_test.dart index 3fdd745c327..234aa9a7e9d 100644 --- a/mobile/test/shared/relay/nostr_models_test.dart +++ b/mobile/test/shared/relay/nostr_models_test.dart @@ -2,6 +2,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/relay/nostr_models.dart'; void main() { + test('declares the shared agent activity summary kind', () { + expect(EventKind.agentActivitySummary, 24201); + }); + test('NostrFilter serializes and preserves authors', () { const filter = NostrFilter( kinds: [EventKind.readState], diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 826e2344000..d59caabe596 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -546,6 +546,44 @@ void main() { }, ); + test( + 'validated live-only admission precedes dedup and reconnect watermark', + () async { + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier(); + session.debugAttachSocketForTest(socket); + final delivered = []; + var admit = false; + + final subscribe = session.subscribeValidatedLiveOnly( + _channelFilter, + (_) => admit, + delivered.add, + ); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + final event = _event(createdAt: 30); + + // A rejected copy must not poison this subscription's event-ID dedup. + session.debugHandleMessage(['EVENT', 'l-1', event.toJson()]); + session.debugFlushEventBuffer(); + expect(delivered, isEmpty); + + admit = true; + session.debugHandleMessage(['EVENT', 'l-1', event.toJson()]); + session.debugFlushEventBuffer(); + expect(delivered.map((item) => item.id), [event.id]); + + socket.messages.clear(); + await session.debugReplayLiveSubscriptions(); + final replayFilter = _reqs(socket).single[2] as Map; + expect(replayFilter, isNot(contains('since'))); + expect(replayFilter, _channelFilter.toJson()); + + unsubscribe(); + }, + ); + test('delivers the same live event to each matching subscription', () async { final session = RelaySessionNotifier(); final firstEvents = []; From 54394cef5efe3be1133069fa9d86504909503f24 Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Wed, 12 Aug 2026 17:07:56 +0700 Subject: [PATCH 06/11] feat(desktop): show member-safe agent activity Signed-off-by: lordfarquad --- .../agents/SharedAgentActivityPanel.tsx | 211 ++++++++++ .../agents/sharedAgentActivity.test.mjs | 257 ++++++++++++ .../features/agents/sharedAgentActivity.ts | 393 ++++++++++++++++++ .../features/agents/useSharedAgentActivity.ts | 79 ++++ .../channels/ui/AgentSessionThreadPanel.tsx | 54 +++ ...sharedAgentActivityPanel.contract.test.mjs | 47 +++ .../shared/api/agentActivitySummaryRelay.ts | 45 ++ desktop/src/shared/api/relayClientSession.ts | 13 +- desktop/src/shared/api/relayClientShared.ts | 12 + .../shared/api/relayClosedRecovery.test.mjs | 48 +++ desktop/src/shared/api/relayClosedRecovery.ts | 7 + .../shared/api/relayReconnectReplay.test.mjs | 26 ++ .../src/shared/api/relayReconnectReplay.ts | 8 +- desktop/src/shared/constants/kinds.ts | 1 + 14 files changed, 1191 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/agents/SharedAgentActivityPanel.tsx create mode 100644 desktop/src/features/agents/sharedAgentActivity.test.mjs create mode 100644 desktop/src/features/agents/sharedAgentActivity.ts create mode 100644 desktop/src/features/agents/useSharedAgentActivity.ts create mode 100644 desktop/src/features/channels/ui/sharedAgentActivityPanel.contract.test.mjs create mode 100644 desktop/src/shared/api/agentActivitySummaryRelay.ts diff --git a/desktop/src/features/agents/SharedAgentActivityPanel.tsx b/desktop/src/features/agents/SharedAgentActivityPanel.tsx new file mode 100644 index 00000000000..e57ffffb3ed --- /dev/null +++ b/desktop/src/features/agents/SharedAgentActivityPanel.tsx @@ -0,0 +1,211 @@ +import { Activity, Circle } from "lucide-react"; + +import { + describeSharedAgentActivity, + type AgentActivityMode, +} from "@/features/agents/sharedAgentActivity"; +import { useSharedAgentActivity } from "@/features/agents/useSharedAgentActivity"; +import { formatDurationMs } from "@/features/agents/ui/agentSessionUtils"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { Channel } from "@/shared/api/types"; +import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; +import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; +import { + AuxiliaryPanel, + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderActions, + AuxiliaryPanelHeaderGroup, +} from "@/shared/layout/AuxiliaryPanel"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Spinner } from "@/shared/ui/spinner"; + +export function SharedAgentActivityPanel({ + agent, + channel, + isSinglePanelView = false, + layout = "standalone", + mode, + onBack, + onClose, + profiles, + transparentChrome = false, + widthPx, +}: { + agent: { pubkey: string; name: string }; + channel: Channel | null; + isSinglePanelView?: boolean; + layout?: "standalone" | "split"; + mode: Exclude; + onBack?: () => void; + onClose: () => void; + profiles?: UserProfileLookup; + transparentChrome?: boolean; + widthPx: number; +}) { + const isOverlay = useIsThreadPanelOverlay(); + useEscapeKey(onClose, isOverlay || isSinglePanelView); + const profile = profiles?.[normalizePubkey(agent.pubkey)] ?? null; + const label = resolveUserLabel({ + pubkey: agent.pubkey, + fallbackName: agent.name, + profiles, + preferResolvedSelfLabel: true, + }); + const { activities, connection } = useSharedAgentActivity({ + enabled: mode === "shared", + agentPubkey: agent.pubkey, + channelId: channel?.id ?? null, + }); + const scope = channel + ? `Shared activity · #${channel.name}` + : "Shared activity"; + + return ( + + + +
+

+ {label} +

+

+ {scope} +

+
+
+ + {mode === "shared" ? ( + + + Live + + ) : null} + + + } + > + + {mode === "unavailable" ? ( + + ) : connection === "closed" ? ( + + ) : connection === "error" ? ( + + ) : activities.length === 0 ? ( +
+ {connection === "connecting" ? ( + + ) : ( + + )} +
+

+ Waiting for activity… +

+

+ New activity appears here live. Earlier activity is not loaded. +

+
+
+ ) : ( +
    + {activities.map((item) => { + const description = describeSharedAgentActivity(item); + const duration = + item.durationMs == null + ? null + : formatDurationMs(item.durationMs); + return ( +
  1. +
    +
    +

    + {description.label} +

    +

    + {description.detail} +

    +
    + {duration ? ( + + {duration} + + ) : null} +
    +
  2. + ); + })} +
+ )} +
+
+ ); +} + +function EmptyState({ + description, + title, +}: { + description: string; + title: string; +}) { + return ( +
+ +
+

{title}

+

{description}

+
+
+ ); +} diff --git a/desktop/src/features/agents/sharedAgentActivity.test.mjs b/desktop/src/features/agents/sharedAgentActivity.test.mjs new file mode 100644 index 00000000000..91854920fd9 --- /dev/null +++ b/desktop/src/features/agents/sharedAgentActivity.test.mjs @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; + +import { + buildAgentActivitySummaryFilter, + describeSharedAgentActivity, + mergeSharedAgentActivities, + parseAgentActivityEvent, + resolveAgentActivityMode, +} from "./sharedAgentActivity.ts"; + +const SECRET = new Uint8Array(32).fill(7); +const AGENT = getPublicKey(SECRET); +const OTHER_AGENT = getPublicKey(new Uint8Array(32).fill(8)); +const VIEWER = "33".repeat(32); +const OWNER = "44".repeat(32); +const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const OTHER_CHANNEL = "fba766b8-ecb5-4b04-8ec4-6d82fbd644ac"; +const NOW = 1_800_000_000; + +function activity(overrides = {}) { + return { + activityId: "15ee77b4-92f8-4cb7-9851-80c3d828b62c", + occurredAt: "2027-01-15T08:00:00Z", + activityClass: "tool", + status: "running", + toolKind: "search", + ...overrides, + }; +} + +function signedEvent({ + content = JSON.stringify({ version: 1, activities: [activity()] }), + createdAt = NOW, + tags = [ + ["h", CHANNEL], + ["agent", AGENT], + ], +} = {}) { + return finalizeEvent( + { kind: 24_201, created_at: createdAt, tags, content }, + SECRET, + ); +} + +function parse(event, overrides = {}) { + return parseAgentActivityEvent(event, { + expectedAgentPubkey: AGENT, + expectedChannelId: CHANNEL, + nowSeconds: NOW, + ...overrides, + }); +} + +test("accepts an exact signed, fresh, channel-bound activity frame", () => { + assert.deepEqual(parse(signedEvent()), { + version: 1, + activities: [activity()], + }); +}); + +test("rejects tampering, another signer, another channel, and stale frames", () => { + const tampered = signedEvent(); + tampered.content = JSON.stringify({ + version: 1, + activities: [activity({ toolKind: "edit" })], + }); + assert.equal(parse(tampered), null); + assert.equal( + parse(signedEvent(), { expectedAgentPubkey: OTHER_AGENT }), + null, + ); + assert.equal( + parse( + signedEvent({ + tags: [ + ["h", OTHER_CHANNEL], + ["agent", AGENT], + ], + }), + ), + null, + ); + assert.equal(parse(signedEvent({ createdAt: NOW - 301 })), null); + assert.equal(parse(signedEvent({ createdAt: NOW + 301 })), null); +}); + +test("rejects duplicate, malformed, extended, and unexpected tags", () => { + for (const tags of [ + [ + ["h", CHANNEL], + ["h", CHANNEL], + ["agent", AGENT], + ], + [["h", CHANNEL], ["agent"]], + [ + ["h", CHANNEL, "extra"], + ["agent", AGENT], + ], + [ + ["h", CHANNEL], + ["agent", AGENT], + ["p", VIEWER], + ], + ]) + assert.equal(parse(signedEvent({ tags })), null); +}); + +test("rejects oversized and open-schema content before admission", () => { + assert.equal(parse(signedEvent({ content: "x".repeat(4_097) })), null); + assert.equal( + parse( + signedEvent({ + content: JSON.stringify({ + version: 1, + activities: [activity({ detail: "private path" })], + }), + }), + ), + null, + ); + assert.equal( + parse( + signedEvent({ + content: JSON.stringify({ + version: 1, + activities: [activity({ toolKind: "unknown-tool" })], + }), + }), + ), + null, + ); +}); + +test("enforces closed class/status/field combinations", () => { + const invalid = [ + activity({ activityClass: "turn", status: "pending", toolKind: undefined }), + activity({ activityClass: "turn", status: "running" }), + activity({ activityClass: "tool", status: "started" }), + activity({ + activityClass: "usage", + status: "running", + toolKind: undefined, + usage: { totalTokens: 5 }, + }), + activity({ + activityClass: "usage", + status: "completed", + toolKind: undefined, + usage: {}, + }), + activity({ status: "running", durationMs: 10 }), + ]; + for (const item of invalid) { + assert.equal( + parse( + signedEvent({ + content: JSON.stringify({ version: 1, activities: [item] }), + }), + ), + null, + ); + } +}); + +test("merges lifecycle updates by opaque id and keeps a bounded newest window", () => { + const first = activity({ occurredAt: "2027-01-15T08:00:00Z" }); + const completed = activity({ + occurredAt: "2027-01-15T08:00:03Z", + status: "completed", + durationMs: 3_000, + }); + const second = activity({ + activityId: "deabf692-9cb3-4d41-ad71-b915d2477fea", + occurredAt: "2027-01-15T08:00:02Z", + }); + assert.deepEqual( + mergeSharedAgentActivities([first], [second, completed], 2), + [second, completed], + ); + assert.deepEqual(mergeSharedAgentActivities([first], [second], 1), [second]); +}); + +test("owner mode requires exact verified profile ownership", () => { + assert.equal( + resolveAgentActivityMode({ + agentOwnerPubkey: OWNER, + currentPubkey: OWNER.toUpperCase(), + channel: null, + }), + "owner", + ); + assert.equal( + resolveAgentActivityMode({ + agentOwnerPubkey: null, + currentPubkey: OWNER, + channel: { id: CHANNEL, channelType: "stream", isMember: true }, + }), + "shared", + ); + assert.equal( + resolveAgentActivityMode({ + agentOwnerPubkey: VIEWER, + currentPubkey: OWNER, + channel: { id: CHANNEL, channelType: "forum", isMember: true }, + }), + "shared", + ); +}); + +test("shared mode is limited to current stream/forum members", () => { + for (const channelType of ["stream", "forum"]) { + assert.equal( + resolveAgentActivityMode({ + agentOwnerPubkey: OWNER, + currentPubkey: VIEWER, + channel: { id: CHANNEL, channelType, isMember: true }, + }), + "shared", + ); + } + for (const channel of [ + { id: CHANNEL, channelType: "dm", isMember: true }, + { id: CHANNEL, channelType: "stream", isMember: false }, + null, + ]) { + assert.equal( + resolveAgentActivityMode({ + agentOwnerPubkey: OWNER, + currentPubkey: VIEWER, + channel, + }), + "unavailable", + ); + } +}); + +test("summary subscription uses one exact channel and author with no history", () => { + assert.deepEqual(buildAgentActivitySummaryFilter(AGENT, CHANNEL), { + kinds: [24_201], + authors: [AGENT], + "#h": [CHANNEL], + limit: 0, + }); +}); + +test("internal planning is rendered neutrally and never exposed as reasoning", () => { + const description = describeSharedAgentActivity( + activity({ toolKind: "think", status: "running" }), + ); + assert.deepEqual(description, { label: "Working", detail: "In progress" }); + assert.doesNotMatch( + `${description.label} ${description.detail}`, + /think|thought|reason|chain/i, + ); +}); diff --git a/desktop/src/features/agents/sharedAgentActivity.ts b/desktop/src/features/agents/sharedAgentActivity.ts new file mode 100644 index 00000000000..3ed877758d3 --- /dev/null +++ b/desktop/src/features/agents/sharedAgentActivity.ts @@ -0,0 +1,393 @@ +import { verifyEvent } from "nostr-tools/pure"; + +import type { RelayEvent } from "@/shared/api/types"; +import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; +import { KIND_AGENT_ACTIVITY_FRAME } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export const AGENT_ACTIVITY_MAX_ITEMS = 32; +export const AGENT_ACTIVITY_MAX_FRAME_BYTES = 4_096; +export const AGENT_ACTIVITY_MAX_DURATION_MS = 7 * 24 * 60 * 60 * 1_000; +export const AGENT_ACTIVITY_MAX_TOKEN_COUNT = 1_000_000_000_000; +export const AGENT_ACTIVITY_FRESHNESS_SECONDS = 300; +export const SHARED_AGENT_ACTIVITY_RETENTION = 200; + +const PUBKEY_RE = /^[0-9a-f]{64}$/; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const RFC3339_RE = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; +const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); +const ACTIVITY_CLASSES = new Set(["turn", "tool", "usage"]); +const ACTIVITY_STATUSES = new Set([ + "started", + "pending", + "running", + "completed", + "failed", + "cancelled", +]); +const TOOL_KINDS = new Set([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", +]); +const ACTIVITY_KEYS = new Set([ + "activityId", + "occurredAt", + "activityClass", + "status", + "toolKind", + "durationMs", + "usage", +]); +const USAGE_KEYS = new Set([ + "inputTokens", + "outputTokens", + "totalTokens", + "cacheReadTokens", + "cacheWriteTokens", +]); + +export type SharedAgentActivityClass = "turn" | "tool" | "usage"; +export type SharedAgentActivityStatus = + | "started" + | "pending" + | "running" + | "completed" + | "failed" + | "cancelled"; +export type SharedAgentActivityToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + +export type SharedAgentActivityUsage = { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +}; + +export type SharedAgentActivity = { + activityId: string; + occurredAt: string; + activityClass: SharedAgentActivityClass; + status: SharedAgentActivityStatus; + toolKind?: SharedAgentActivityToolKind; + durationMs?: number; + usage?: SharedAgentActivityUsage; +}; + +export type SharedAgentActivityFrame = { + version: 1; + activities: SharedAgentActivity[]; +}; + +export type AgentActivityMode = "owner" | "shared" | "unavailable"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys( + value: Record, + allowed: ReadonlySet, +) { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function isBoundedInteger(value: unknown, max: number): value is number { + return ( + Number.isSafeInteger(value) && + (value as number) >= 0 && + (value as number) <= max + ); +} + +function isCanonicalUuid(value: unknown): value is string { + return typeof value === "string" && UUID_RE.test(value); +} + +function isRfc3339(value: unknown): value is string { + return ( + typeof value === "string" && + RFC3339_RE.test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +function parseUsage(value: unknown): SharedAgentActivityUsage | null { + if (!isRecord(value) || !hasOnlyKeys(value, USAGE_KEYS)) return null; + const entries = Object.entries(value); + if (entries.length === 0) return null; + for (const [, count] of entries) { + if (!isBoundedInteger(count, AGENT_ACTIVITY_MAX_TOKEN_COUNT)) return null; + } + return value as SharedAgentActivityUsage; +} + +function parseActivity(value: unknown): SharedAgentActivity | null { + if (!isRecord(value) || !hasOnlyKeys(value, ACTIVITY_KEYS)) return null; + if (!isCanonicalUuid(value.activityId) || !isRfc3339(value.occurredAt)) + return null; + if ( + typeof value.activityClass !== "string" || + !ACTIVITY_CLASSES.has(value.activityClass) + ) + return null; + if (typeof value.status !== "string" || !ACTIVITY_STATUSES.has(value.status)) + return null; + + const hasToolKind = Object.hasOwn(value, "toolKind"); + const hasDuration = Object.hasOwn(value, "durationMs"); + const hasUsage = Object.hasOwn(value, "usage"); + if ( + hasToolKind && + (typeof value.toolKind !== "string" || !TOOL_KINDS.has(value.toolKind)) + ) + return null; + if ( + hasDuration && + !isBoundedInteger(value.durationMs, AGENT_ACTIVITY_MAX_DURATION_MS) + ) + return null; + if (hasDuration && !TERMINAL_STATUSES.has(value.status)) return null; + + let usage: SharedAgentActivityUsage | undefined; + if (hasUsage) { + const parsed = parseUsage(value.usage); + if (!parsed) return null; + usage = parsed; + } + + if (value.activityClass === "turn") { + if (hasToolKind || hasUsage || value.status === "pending") return null; + } else if (value.activityClass === "tool") { + if (!hasToolKind || hasUsage || value.status === "started") return null; + } else { + if (value.status !== "completed" || hasToolKind || hasDuration || !usage) + return null; + } + + return { + activityId: value.activityId, + occurredAt: value.occurredAt, + activityClass: value.activityClass as SharedAgentActivityClass, + status: value.status as SharedAgentActivityStatus, + ...(hasToolKind + ? { toolKind: value.toolKind as SharedAgentActivityToolKind } + : {}), + ...(hasDuration ? { durationMs: value.durationMs as number } : {}), + ...(usage ? { usage } : {}), + }; +} + +function parseFrame(content: string): SharedAgentActivityFrame | null { + if ( + new TextEncoder().encode(content).byteLength > + AGENT_ACTIVITY_MAX_FRAME_BYTES + ) + return null; + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return null; + } + if ( + !isRecord(value) || + !hasOnlyKeys(value, new Set(["version", "activities"])) + ) + return null; + if (value.version !== 1 || !Array.isArray(value.activities)) return null; + if ( + value.activities.length < 1 || + value.activities.length > AGENT_ACTIVITY_MAX_ITEMS + ) + return null; + const activities: SharedAgentActivity[] = []; + for (const item of value.activities) { + const parsed = parseActivity(item); + if (!parsed) return null; + activities.push(parsed); + } + return { version: 1, activities }; +} + +export function buildAgentActivitySummaryFilter( + agentPubkey: string, + channelId: string, +): RelaySubscriptionFilter { + return { + kinds: [KIND_AGENT_ACTIVITY_FRAME], + authors: [normalizePubkey(agentPubkey)], + "#h": [channelId.toLowerCase()], + limit: 0, + }; +} + +export function parseAgentActivityEvent( + event: RelayEvent, + input: { + expectedAgentPubkey: string; + expectedChannelId: string; + nowSeconds?: number; + }, +): SharedAgentActivityFrame | null { + const expectedAgent = normalizePubkey(input.expectedAgentPubkey); + const expectedChannel = input.expectedChannelId.toLowerCase(); + const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1_000); + + if (!PUBKEY_RE.test(expectedAgent) || !UUID_RE.test(expectedChannel)) + return null; + if ( + event.kind !== KIND_AGENT_ACTIVITY_FRAME || + event.pubkey !== expectedAgent || + !Number.isSafeInteger(event.created_at) || + Math.abs(event.created_at - nowSeconds) > + AGENT_ACTIVITY_FRESHNESS_SECONDS || + !Array.isArray(event.tags) || + event.tags.length !== 2 + ) + return null; + + const hTags = event.tags.filter( + (tag) => Array.isArray(tag) && tag[0] === "h", + ); + const agentTags = event.tags.filter( + (tag) => Array.isArray(tag) && tag[0] === "agent", + ); + if ( + hTags.length !== 1 || + agentTags.length !== 1 || + hTags[0].length !== 2 || + agentTags[0].length !== 2 || + hTags[0][1] !== expectedChannel || + agentTags[0][1] !== expectedAgent || + agentTags[0][1] !== event.pubkey + ) + return null; + + const frame = parseFrame(event.content); + if (!frame) return null; + try { + // nostr-tools memoizes verification on the object. Verify a fresh canonical + // envelope so an object mutated after an earlier successful check cannot + // inherit that cached result. + const canonicalEvent = { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags: event.tags.map((tag) => [...tag]), + content: event.content, + sig: event.sig, + }; + return verifyEvent(canonicalEvent) ? frame : null; + } catch { + return null; + } +} + +export function mergeSharedAgentActivities( + current: readonly SharedAgentActivity[], + incoming: readonly SharedAgentActivity[], + maxItems = SHARED_AGENT_ACTIVITY_RETENTION, +): SharedAgentActivity[] { + const byId = new Map(current.map((item) => [item.activityId, item])); + for (const item of incoming) byId.set(item.activityId, item); + return [...byId.values()] + .sort((left, right) => { + const time = Date.parse(left.occurredAt) - Date.parse(right.occurredAt); + return time || left.activityId.localeCompare(right.activityId); + }) + .slice(-Math.max(0, maxItems)); +} + +export function resolveAgentActivityMode(input: { + agentOwnerPubkey: string | null | undefined; + currentPubkey: string | null | undefined; + channel: Pick< + import("@/shared/api/types").Channel, + "id" | "channelType" | "isMember" + > | null; +}): AgentActivityMode { + if ( + input.agentOwnerPubkey && + input.currentPubkey && + normalizePubkey(input.agentOwnerPubkey) === + normalizePubkey(input.currentPubkey) + ) + return "owner"; + if ( + input.channel?.isMember && + (input.channel.channelType === "stream" || + input.channel.channelType === "forum") + ) + return "shared"; + return "unavailable"; +} + +export function describeSharedAgentActivity(activity: SharedAgentActivity): { + label: string; + detail: string; +} { + const status = + activity.status === "pending" + ? "Pending" + : activity.status === "started" || activity.status === "running" + ? "In progress" + : activity.status === "completed" + ? "Completed" + : activity.status === "failed" + ? "Failed" + : "Cancelled"; + + if (activity.activityClass === "usage") { + const total = activity.usage?.totalTokens; + return { + label: "Usage updated", + detail: total === undefined ? status : `${total.toLocaleString()} tokens`, + }; + } + if (activity.activityClass === "turn") { + const labels: Record = { + started: "Working", + pending: "Working", + running: "Working", + completed: "Turn completed", + failed: "Turn failed", + cancelled: "Turn cancelled", + }; + return { label: labels[activity.status], detail: status }; + } + const labels: Record = { + read: "Reading", + edit: "Editing", + delete: "Deleting", + move: "Moving", + search: "Searching", + execute: "Running a tool", + think: "Working", + fetch: "Fetching", + switch_mode: "Switching mode", + other: "Running a tool", + }; + return { label: labels[activity.toolKind ?? "other"], detail: status }; +} diff --git a/desktop/src/features/agents/useSharedAgentActivity.ts b/desktop/src/features/agents/useSharedAgentActivity.ts new file mode 100644 index 00000000000..2edf3c1575b --- /dev/null +++ b/desktop/src/features/agents/useSharedAgentActivity.ts @@ -0,0 +1,79 @@ +import * as React from "react"; + +import { + mergeSharedAgentActivities, + type SharedAgentActivity, +} from "@/features/agents/sharedAgentActivity"; +import { subscribeToAgentActivitySummaries } from "@/shared/api/agentActivitySummaryRelay"; + +export type SharedAgentActivityConnection = + | "idle" + | "connecting" + | "live" + | "closed" + | "error"; + +export function useSharedAgentActivity(input: { + enabled: boolean; + agentPubkey: string; + channelId: string | null; +}) { + const [activities, setActivities] = React.useState([]); + const [connection, setConnection] = + React.useState("idle"); + + React.useEffect(() => { + setActivities([]); + if (!input.enabled || !input.channelId) { + setConnection("idle"); + return; + } + + let disposed = false; + let unsubscribe: (() => Promise) | null = null; + const seenEventIds = new Set(); + const seenOrder: string[] = []; + setConnection("connecting"); + + void subscribeToAgentActivitySummaries({ + agentPubkey: input.agentPubkey, + channelId: input.channelId, + onReady: () => { + if (!disposed) setConnection("live"); + }, + onTerminalClosed: () => { + if (disposed) return; + seenEventIds.clear(); + seenOrder.length = 0; + setActivities([]); + setConnection("closed"); + }, + onEvent: ({ eventId, frame }) => { + if (disposed || seenEventIds.has(eventId)) return; + seenEventIds.add(eventId); + seenOrder.push(eventId); + while (seenOrder.length > 500) { + const oldest = seenOrder.shift(); + if (oldest) seenEventIds.delete(oldest); + } + setActivities((current) => + mergeSharedAgentActivities(current, frame.activities), + ); + }, + }) + .then((cleanup) => { + if (disposed) void cleanup(); + else unsubscribe = cleanup; + }) + .catch(() => { + if (!disposed) setConnection("error"); + }); + + return () => { + disposed = true; + if (unsubscribe) void unsubscribe(); + }; + }, [input.agentPubkey, input.channelId, input.enabled]); + + return { activities, connection }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 641b81490bc..a9655b576ef 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -9,6 +9,8 @@ import { import { toast } from "sonner"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; +import { resolveAgentActivityMode } from "@/features/agents/sharedAgentActivity"; +import { SharedAgentActivityPanel } from "@/features/agents/SharedAgentActivityPanel"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { mergeObserverEventWindows, @@ -61,6 +63,7 @@ import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverE import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScroll"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; type AgentSessionThreadPanelProps = { agent: ChannelAgentSessionAgent; @@ -84,6 +87,57 @@ type AgentSessionThreadPanelProps = { }; export function AgentSessionThreadPanel({ + agent, + canInterruptTurn, + channel, + channelId = null, + profiles, + ...props +}: AgentSessionThreadPanelProps) { + const sessionChannelId = channelId ?? channel?.id ?? null; + const channelsQuery = useChannelsQuery({ + enabled: Boolean(sessionChannelId), + }); + const identityQuery = useIdentityQuery(); + const resolvedChannel = React.useMemo(() => { + if (!sessionChannelId) return null; + if (channel?.id === sessionChannelId) return channel; + return ( + channelsQuery.data?.find((entry) => entry.id === sessionChannelId) ?? null + ); + }, [channel, channelsQuery.data, sessionChannelId]); + const profile = profiles?.[normalizePubkey(agent.pubkey)] ?? null; + const mode = resolveAgentActivityMode({ + agentOwnerPubkey: profile?.ownerPubkey, + currentPubkey: identityQuery.data?.pubkey, + channel: resolvedChannel, + }); + + if (mode !== "owner") { + return ( + + ); + } + + return ( + + ); +} + +function OwnerAgentSessionThreadPanel({ agent, canInterruptTurn, channel, diff --git a/desktop/src/features/channels/ui/sharedAgentActivityPanel.contract.test.mjs b/desktop/src/features/channels/ui/sharedAgentActivityPanel.contract.test.mjs new file mode 100644 index 00000000000..fca78b8678e --- /dev/null +++ b/desktop/src/features/channels/ui/sharedAgentActivityPanel.contract.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const panelSource = readFileSync( + new URL("./AgentSessionThreadPanel.tsx", import.meta.url), + "utf8", +); +const sharedPanelSource = readFileSync( + new URL("../../agents/SharedAgentActivityPanel.tsx", import.meta.url), + "utf8", +); + +test("non-owners branch to the member-safe panel before owner telemetry hooks mount", () => { + const wrapper = panelSource.indexOf( + "export function AgentSessionThreadPanel", + ); + const ownerPanel = panelSource.indexOf( + "function OwnerAgentSessionThreadPanel", + ); + const ownerHook = panelSource.indexOf("useObserverEvents("); + assert.ok(wrapper >= 0, "public activity panel wrapper is present"); + assert.ok( + ownerPanel > wrapper, + "owner-only component is nested behind the wrapper", + ); + assert.ok( + ownerHook > ownerPanel, + "raw observer hook mounts only inside owner component", + ); + assert.match( + panelSource.slice(wrapper, ownerPanel), + /resolveAgentActivityMode/, + ); + assert.match( + panelSource.slice(wrapper, ownerPanel), + /SharedAgentActivityPanel/, + ); +}); + +test("member-safe panel cannot import owner observer, archive, control, or raw-feed APIs", () => { + assert.doesNotMatch( + sharedPanelSource, + /useObserverEvents|useArchivedChannelEvents|useLoadArchivedObserverEvents|cancelManagedAgentTurn|RawEvent|agentControl/, + ); + assert.match(sharedPanelSource, /useSharedAgentActivity/); +}); diff --git a/desktop/src/shared/api/agentActivitySummaryRelay.ts b/desktop/src/shared/api/agentActivitySummaryRelay.ts new file mode 100644 index 00000000000..e6b873cb85e --- /dev/null +++ b/desktop/src/shared/api/agentActivitySummaryRelay.ts @@ -0,0 +1,45 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { + buildAgentActivitySummaryFilter, + parseAgentActivityEvent, + type SharedAgentActivityFrame, +} from "@/features/agents/sharedAgentActivity"; + +export type AgentActivitySummaryEvent = { + eventId: string; + frame: SharedAgentActivityFrame; +}; + +export async function subscribeToAgentActivitySummaries(input: { + agentPubkey: string; + channelId: string; + onEvent: (event: AgentActivitySummaryEvent) => void; + onReady?: () => void; + onTerminalClosed?: () => void; +}) { + const parse = (event: RelayEvent) => + parseAgentActivityEvent(event, { + expectedAgentPubkey: input.agentPubkey, + expectedChannelId: input.channelId, + }); + + return relayClient.subscribeLive( + buildAgentActivitySummaryFilter(input.agentPubkey, input.channelId), + (event) => { + const frame = parse(event); + if (frame) input.onEvent({ eventId: event.id, frame }); + }, + (readiness) => { + if (readiness !== "closed") input.onReady?.(); + }, + { + reconnectMode: "live-only", + // Admission happens before replay cursors/watermarks are mutated. + admitEvent: (event) => parse(event) !== null, + onClosed: (_message, terminal) => { + if (terminal) input.onTerminalClosed?.(); + }, + }, + ); +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 9ffe84b939c..88e56825a76 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -15,6 +15,7 @@ import { import { getTextPayload, type ConnectionState, + type LiveSubscriptionOptions, type LiveSubscriptionReadiness, type PendingEvent, type RelaySubscription, @@ -414,8 +415,9 @@ export class RelayClient { filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, + options?: LiveSubscriptionOptions, ) { - return this.subscribe(filter, onEvent, onReady); + return this.subscribe(filter, onEvent, onReady, options); } async subscribeToChannelMentionEvents( channelId: string, @@ -436,12 +438,7 @@ export class RelayClient { await this.connectBypassingBackoff(); } - /** - * Environment-driven resume (online/focus/visibility): bypasses a pending - * backoff timer but preserves the terminal latch and AUTH rejection streak - * — only `preconnect()` clears those, so resume events during repeated - * AUTH rejection cannot defeat the consecutive-rejection cap. - */ + /** Resume without clearing the terminal latch or AUTH rejection streak. */ async resumeReconnect() { if (this.terminal) return; await this.connectBypassingBackoff(); @@ -600,6 +597,7 @@ export class RelayClient { filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, + options?: LiveSubscriptionOptions, ) { await this.ensureConnected(); @@ -622,6 +620,7 @@ export class RelayClient { filter, onEvent, resolveReady, + ...options, }); try { diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index efb311ac9d2..26f38110a1d 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -56,11 +56,23 @@ type FirstEventSubscription = { export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout"; +export type LiveSubscriptionOptions = { + /** Reconnect without a `since` cursor or history replay. */ + reconnectMode?: "cursor" | "live-only"; + /** Fail-closed admission gate evaluated before replay/watermark mutation. */ + admitEvent?: (event: RelayEvent) => boolean; + /** Persistent CLOSED notification, including post-readiness revocation. */ + onClosed?: (message: string, terminal: boolean) => void; +}; + type LiveSubscription = { mode: "live"; filter: RelaySubscriptionFilter; onEvent: (event: RelayEvent) => void; resolveReady?: (readiness: LiveSubscriptionReadiness) => void; + reconnectMode?: LiveSubscriptionOptions["reconnectMode"]; + admitEvent?: LiveSubscriptionOptions["admitEvent"]; + onClosed?: LiveSubscriptionOptions["onClosed"]; lastSeenCreatedAt?: number; /** * Lower bound of a reconnect backfill window that has not yet completed. diff --git a/desktop/src/shared/api/relayClosedRecovery.test.mjs b/desktop/src/shared/api/relayClosedRecovery.test.mjs index 0c1674e77e4..636ee4cca3e 100644 --- a/desktop/src/shared/api/relayClosedRecovery.test.mjs +++ b/desktop/src/shared/api/relayClosedRecovery.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { handleRelayClosed, handleSubscriptionEose, + prepareSubscriptionEvent, } from "./relayClosedRecovery.ts"; import { requestFirstEventGated, @@ -323,6 +324,53 @@ test("production CLOSED handler removes terminal live subscriptions", () => { assert.equal(readyCalls, 1); }); +test("terminal CLOSED notifies the persistent revocation callback", () => { + const closed = []; + const subscriptions = new Map([ + [ + "activity", + { + mode: "live", + filter: { kinds: [24_201], limit: 0 }, + onEvent: () => {}, + onClosed: (message, terminal) => closed.push({ message, terminal }), + }, + ], + ]); + + handleRelayClosed({ + subscriptions, + subId: "activity", + message: "restricted: access revoked", + sendReq: () => Promise.resolve(), + }); + + assert.deepEqual(closed, [ + { message: "restricted: access revoked", terminal: true }, + ]); + assert.equal(subscriptions.has("activity"), false); +}); + +test("rejected live events cannot advance replay state", () => { + const subscription = { + mode: "live", + filter: { kinds: [24_201], limit: 0 }, + onEvent: () => {}, + admitEvent: () => false, + }; + const admitted = prepareSubscriptionEvent(subscription, { + id: "bad", + pubkey: "aa".repeat(32), + created_at: 9_999_999, + kind: 24_201, + tags: [], + content: "{}", + sig: "bad", + }); + assert.equal(admitted, false); + assert.equal(subscription.lastSeenCreatedAt, undefined); +}); + // ── Rate-limited CLOSED core behaviour (F5) ─────────────────────────────────── test("rate-limited CLOSED keeps live subscription in the map", () => { diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index e1ff86e08da..f64a3535bc7 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -77,6 +77,7 @@ function recoverLiveSubscriptionFromClosed({ subscription.resolveReady = undefined; const closedClass = classifyRelayClosed(message); + subscription.onClosed?.(message, closedClass === "terminal"); if (closedClass === "terminal") { // Auth/access/filter failure — permanently remove the subscription so it @@ -136,6 +137,12 @@ export function prepareSubscriptionEvent( if (subscription.mode === "first") { return false; } + try { + if (subscription.admitEvent && !subscription.admitEvent(event)) + return false; + } catch { + return false; + } subscription.closedRetryAttempt = 0; clearClosedRetry(subscription); subscription.lastSeenCreatedAt = Math.max( diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index c1752728855..1c7c339d801 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -141,6 +141,32 @@ test("live-only subscriptions do not page reconnect history", () => { assert.equal(shouldPageReconnectReplay(filter), false); }); +test("strict live-only subscriptions reconnect without adding a cursor", async () => { + resetGate(); + const originalFilter = { + kinds: [24_201], + authors: ["aa".repeat(32)], + "#h": ["36411e44-0e2d-4cfe-bd6e-567eb169db9f"], + limit: 0, + }; + const subscription = { + mode: "live", + filter: originalFilter, + onEvent: () => {}, + reconnectMode: "live-only", + lastSeenCreatedAt: 123, + }; + const sent = []; + + await replayLiveSubscriptions({ + subscriptions: new Map([["activity", subscription]]), + sendRaw: async (payload) => sent.push(payload), + requestHistory: async () => assert.fail("strict live-only must not fetch"), + }); + + assert.deepEqual(sent, [["REQ", "activity", originalFilter]]); +}); + test("reconnect replay keeps the stricter existing since window", () => { const filter = { kinds: [9], diff --git a/desktop/src/shared/api/relayReconnectReplay.ts b/desktop/src/shared/api/relayReconnectReplay.ts index cb962ce8469..38f891fe03b 100644 --- a/desktop/src/shared/api/relayReconnectReplay.ts +++ b/desktop/src/shared/api/relayReconnectReplay.ts @@ -194,8 +194,9 @@ export async function replayLiveSubscriptions({ entry[1].mode === "live", ) .map(([subId, subscription]) => { + const strictLiveOnly = subscription.reconnectMode === "live-only"; const cursorSince = - subscription.lastSeenCreatedAt === undefined + strictLiveOnly || subscription.lastSeenCreatedAt === undefined ? undefined : Math.max( 0, @@ -205,8 +206,9 @@ export async function replayLiveSubscriptions({ // over the cursor: live events kept advancing `lastSeenCreatedAt` // while the older window stayed unresolved, and starting from the // cursor would skip it permanently. - const replaySince = - cursorSince === undefined + const replaySince = strictLiveOnly + ? undefined + : cursorSince === undefined ? subscription.pendingReplaySince : Math.min(cursorSince, subscription.pendingReplaySince ?? Infinity); const shouldPageReplay = diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f995a635968..1c1b439dbb1 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -55,6 +55,7 @@ export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; +export const KIND_AGENT_ACTIVITY_FRAME = 24201; export const KIND_AGENT_TURN_METRIC = 44200; export const KIND_EVENT_REMINDER = 30300; export const KIND_REPO_ANNOUNCEMENT = 30617; From 1d22a23a76c7df9a42bc27e87047c099ba300b3f Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Wed, 12 Aug 2026 18:34:09 +0700 Subject: [PATCH 07/11] fix(acp): accept legacy steer-thread mode Signed-off-by: lordfarquad --- crates/buzz-acp/src/config.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index f9e7bf1ed8a..cd9d588f1ef 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -72,6 +72,7 @@ pub enum MultipleEventHandling { /// treated as a replacement. Fires for any author the inbound author gate /// admits (owner ∪ allowlist ∪ siblings). This is the default mid-turn /// delivery path. Requires DedupMode::Queue. + #[value(alias = "steer-thread")] Steer, /// Cancel the in-flight turn and re-dispatch a merged prompt combining /// the original events with the new ones, framed as a **supersede** (the @@ -2592,6 +2593,18 @@ channels = "ALL" assert!(matches!(args.dedup, DedupMode::Queue)); } + #[test] + fn test_multiple_event_handling_accepts_legacy_steer_thread_alias() { + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &"0".repeat(64), + "--multiple-event-handling", + "steer-thread", + ]); + assert_eq!(args.multiple_event_handling, MultipleEventHandling::Steer); + } + #[test] fn test_validate_steer_requires_queue_dedup() { // Steer + Drop is rejected (drain window would drop events). From 6b36ddb7634476b80777feecf537522e390d3a3a Mon Sep 17 00:00:00 2001 From: Mark Farquad Date: Wed, 12 Aug 2026 23:55:57 +0700 Subject: [PATCH 08/11] fix(acp): reliably publish shared activity Signed-off-by: Mark Farquad --- crates/buzz-acp/src/agent_activity.rs | 3 + crates/buzz-acp/src/relay.rs | 1098 +++++++++++++++++++++++-- 2 files changed, 1053 insertions(+), 48 deletions(-) diff --git a/crates/buzz-acp/src/agent_activity.rs b/crates/buzz-acp/src/agent_activity.rs index da42ab19835..a9e09bd60c6 100644 --- a/crates/buzz-acp/src/agent_activity.rs +++ b/crates/buzz-acp/src/agent_activity.rs @@ -1354,6 +1354,7 @@ mod tests { crate::relay::ChannelInfo { name: "stream".into(), channel_type: "stream".into(), + description: None, }, ), ( @@ -1361,6 +1362,7 @@ mod tests { crate::relay::ChannelInfo { name: "forum".into(), channel_type: "forum".into(), + description: None, }, ), ( @@ -1368,6 +1370,7 @@ mod tests { crate::relay::ChannelInfo { name: "dm".into(), channel_type: "dm".into(), + description: None, }, ), ]), diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 17a818867dd..87e31477b10 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -113,12 +113,17 @@ const DRAIN_BUDGET_PER_ITER: usize = 1; /// (`gated_observer_dropped`). Note each dropped frame may carry a whole batch /// of events, so event-level loss is larger than the frame count. const GATED_OBSERVER_QUEUE_CAP: usize = 256; +/// Shared activity is live-only. Keep at most four minutes of the producer's +/// global 2-second cadence, and expire it before the relay's ±5-minute fence. +const GATED_ACTIVITY_QUEUE_CAP: usize = 120; +const ACTIVITY_EVENT_MAX_AGE_SECS: u64 = 240; +const ACTIVITY_SEEN_ID_LIMIT: usize = GATED_ACTIVITY_QUEUE_CAP * 2; use std::time::Instant; use buzz_core::kind::{ - KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, - KIND_TYPING_INDICATOR, + KIND_AGENT_ACTIVITY_SUMMARY, KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_TYPING_INDICATOR, }; use futures_util::{SinkExt, StreamExt}; use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag}; @@ -1077,6 +1082,26 @@ struct BgState { /// Frames evicted from the bounded pending/in-flight observer buffers since /// summary log. Makes overflow loss visible instead of silent. gated_observer_dropped: u64, + /// Fresh kind-24201 frames parked while disconnected or rate-gated. This is + /// memory-only and age-bounded: shared activity must never become history. + gated_activity_pending: VecDeque>, + /// Kind-24201 frames sent on the socket and awaiting the relay OK. + activity_in_flight: VecDeque>, + /// Visible loss accounting for the bounded live-only activity transport. + activity_dropped: u64, + activity_expired: u64, + activity_rejected: u64, + activity_retried: u64, + /// Bounded terminal-ID tombstones. A signed activity event that has already + /// been admitted cannot regain retry eligibility after a terminal ACK. + activity_seen_ids: TwoGenDedup, + /// Event IDs that have consumed their single transient negative-OK retry. + activity_retry_attempted: HashSet, + /// Stable first-admission order for every retained activity ID. This map is + /// bounded by the aggregate pending + in-flight cap and lets transitions + /// preserve FIFO independently of ACK arrival order. + activity_sequence: HashMap, + activity_next_sequence: u64, /// Channels whose REQ failed during `resubscribe_after_reconnect`. /// /// A single failed channel REQ is parked here instead of aborting the whole @@ -1112,6 +1137,16 @@ impl BgState { gated_observer_pending: VecDeque::new(), observer_in_flight: VecDeque::new(), gated_observer_dropped: 0, + gated_activity_pending: VecDeque::new(), + activity_in_flight: VecDeque::new(), + activity_dropped: 0, + activity_expired: 0, + activity_rejected: 0, + activity_retried: 0, + activity_seen_ids: TwoGenDedup::new(ACTIVITY_SEEN_ID_LIMIT), + activity_retry_attempted: HashSet::new(), + activity_sequence: HashMap::new(), + activity_next_sequence: 0, resubscribe_retry: HashSet::new(), backoff_step: 0, } @@ -1186,8 +1221,7 @@ impl BgState { /// Returns the gate deadline that was set. fn set_rate_limit_gate(&mut self, retry_secs: u64) -> tokio::time::Instant { let secs = if retry_secs < 2 { 5 } else { retry_secs }; - let base = Duration::from_secs(secs); - let deadline = tokio::time::Instant::now() + jittered_duration(base); + let deadline = tokio::time::Instant::now() + Duration::from_secs(secs); let gate = match self.rate_limit_gate { Some(existing) if existing > deadline => existing, _ => deadline, @@ -1261,6 +1295,261 @@ impl BgState { self.observer_in_flight.remove(index); } } + + fn activity_event_is_fresh(event: &Event) -> bool { + unix_now_secs().abs_diff(event.created_at.as_secs()) <= ACTIVITY_EVENT_MAX_AGE_SECS + } + + fn activity_is_retained(&self, event_id: &str) -> bool { + self.activity_sequence.contains_key(event_id) + } + + fn register_activity_id(&mut self, event_id: &str) { + if self.activity_sequence.contains_key(event_id) { + return; + } + let sequence = self.activity_next_sequence; + self.activity_next_sequence = self + .activity_next_sequence + .checked_add(1) + .expect("shared activity admission sequence exhausted"); + self.activity_sequence.insert(event_id.to_owned(), sequence); + } + + fn admit_activity_id(&mut self, event_id: &str) -> bool { + if self.activity_is_retained(event_id) + || !self.activity_seen_ids.insert(event_id.to_owned()) + { + return false; + } + self.enforce_activity_bound(); + self.register_activity_id(event_id); + true + } + + fn cleanup_activity_id(&mut self, event_id: &str) { + self.activity_retry_attempted.remove(event_id); + self.activity_sequence.remove(event_id); + } + + fn remove_activity_copies(&mut self, event_id: &str) -> Option> { + let mut retained = self + .gated_activity_pending + .iter() + .position(|event| event.id.to_hex() == event_id) + .and_then(|index| self.gated_activity_pending.remove(index)); + if retained.is_none() { + retained = self + .activity_in_flight + .iter() + .position(|event| event.id.to_hex() == event_id) + .and_then(|index| self.activity_in_flight.remove(index)); + } + self.gated_activity_pending + .retain(|event| event.id.to_hex() != event_id); + self.activity_in_flight + .retain(|event| event.id.to_hex() != event_id); + retained + } + + fn insert_activity_pending_in_order(&mut self, event: Box) { + let event_id = event.id.to_hex(); + let sequence = self + .activity_sequence + .get(&event_id) + .copied() + .expect("retained activity has an admission sequence"); + let index = self + .gated_activity_pending + .iter() + .position(|pending| { + self.activity_sequence + .get(&pending.id.to_hex()) + .copied() + .unwrap_or(u64::MAX) + > sequence + }) + .unwrap_or(self.gated_activity_pending.len()); + self.gated_activity_pending.insert(index, event); + } + + fn expire_stale_activity(&mut self) { + let expired_ids: HashSet = self + .gated_activity_pending + .iter() + .chain(self.activity_in_flight.iter()) + .filter(|event| !Self::activity_event_is_fresh(event)) + .map(|event| event.id.to_hex()) + .collect(); + let expired = expired_ids.len(); + for event_id in expired_ids { + self.remove_activity_copies(&event_id); + self.cleanup_activity_id(&event_id); + } + if expired > 0 { + self.activity_expired += expired as u64; + warn!( + expired, + expired_total = self.activity_expired, + "stale shared activity expired before relay delivery" + ); + } + } + + fn enforce_activity_bound(&mut self) { + while self.gated_activity_pending.len() + self.activity_in_flight.len() + >= GATED_ACTIVITY_QUEUE_CAP + { + let oldest_id = self + .gated_activity_pending + .iter() + .chain(self.activity_in_flight.iter()) + .min_by_key(|event| { + self.activity_sequence + .get(&event.id.to_hex()) + .copied() + .unwrap_or(0) + }) + .map(|event| event.id.to_hex()); + let Some(oldest_id) = oldest_id else { + break; + }; + self.remove_activity_copies(&oldest_id); + self.cleanup_activity_id(&oldest_id); + self.activity_dropped += 1; + warn!( + dropped_total = self.activity_dropped, + "shared activity transport queue full — dropped oldest frame" + ); + } + } + + fn park_gated_activity_frame(&mut self, event: Box) -> bool { + self.expire_stale_activity(); + let event_id = event.id.to_hex(); + if !Self::activity_event_is_fresh(&event) { + self.cleanup_activity_id(&event_id); + self.activity_expired += 1; + warn!( + expired_total = self.activity_expired, + "stale shared activity discarded before queueing" + ); + return false; + } + if !self.admit_activity_id(&event_id) { + return false; + } + self.gated_activity_pending.push_back(event); + true + } + + fn queue_activity_retry(&mut self, event: Box) -> bool { + let event_id = event.id.to_hex(); + if !Self::activity_event_is_fresh(&event) { + self.cleanup_activity_id(&event_id); + self.activity_expired += 1; + warn!( + expired_total = self.activity_expired, + "stale shared activity discarded instead of retrying" + ); + return false; + } + if !self.activity_retry_attempted.insert(event_id.clone()) { + self.remove_activity_copies(&event_id); + self.cleanup_activity_id(&event_id); + self.activity_dropped += 1; + warn!( + dropped_total = self.activity_dropped, + "shared activity exhausted its bounded delivery retry" + ); + return false; + } + if !self.activity_is_retained(&event_id) { + return false; + } + self.insert_activity_pending_in_order(event); + self.activity_retried += 1; + warn!( + retried_total = self.activity_retried, + "shared activity queued for its single bounded fresh retry" + ); + true + } + + fn track_activity_in_flight(&mut self, event: Box) -> bool { + self.expire_stale_activity(); + let event_id = event.id.to_hex(); + if !Self::activity_event_is_fresh(&event) { + self.cleanup_activity_id(&event_id); + self.activity_expired += 1; + warn!( + expired_total = self.activity_expired, + "shared activity expired immediately after socket write" + ); + return false; + } + if self + .gated_activity_pending + .iter() + .chain(self.activity_in_flight.iter()) + .any(|retained| retained.id.to_hex() == event_id) + { + return false; + } + if !self.activity_is_retained(&event_id) && !self.admit_activity_id(&event_id) { + return false; + } + self.activity_in_flight.push_back(event); + true + } + + fn requeue_activity_in_flight(&mut self) { + self.expire_stale_activity(); + let unresolved: Vec> = self.activity_in_flight.drain(..).collect(); + for event in unresolved { + let _ = self.queue_activity_retry(event); + } + } + + fn acknowledge_activity_frame( + &mut self, + event_id: &str, + accepted: bool, + retryable: bool, + ) -> bool { + let pending_retry = self.activity_retry_attempted.contains(event_id) + && self + .gated_activity_pending + .iter() + .any(|event| event.id.to_hex() == event_id && Self::activity_event_is_fresh(event)); + if !accepted && retryable && pending_retry { + self.activity_rejected += 1; + warn!( + rejected_total = self.activity_rejected, + "relay transiently rejected shared activity; scheduled retry retained" + ); + return true; + } + + let Some(event) = self.remove_activity_copies(event_id) else { + return false; + }; + if accepted { + self.cleanup_activity_id(event_id); + } else { + self.activity_rejected += 1; + warn!( + rejected_total = self.activity_rejected, + "relay rejected sanitized shared activity" + ); + if retryable && Self::activity_event_is_fresh(&event) { + let _ = self.queue_activity_retry(event); + } else { + self.cleanup_activity_id(event_id); + } + } + true + } } /// Record a command's intent in state while disconnected (no WebSocket). @@ -1308,15 +1597,16 @@ fn apply_command_to_state(state: &mut BgState, cmd: RelayCommand) { state.membership_last_seen = Some(ts); } } - // Observer telemetry frames are durable: park them (bounded, visible - // overflow) so they are delivered by the post-reconnect drain. Other - // ephemeral publishes (typing indicators) are meaningless while - // disconnected and are dropped. - RelayCommand::PublishEvent { event } => { - if event.kind.as_u16() as u32 == KIND_AGENT_OBSERVER_FRAME { - state.park_gated_observer_frame(event); + // Observer telemetry is durable. Shared activity is live-only but gets + // a short, memory-only reconnect window. Typing indicators remain + // disposable while disconnected. + RelayCommand::PublishEvent { event } => match event.kind.as_u16() as u32 { + KIND_AGENT_OBSERVER_FRAME => state.park_gated_observer_frame(event), + KIND_AGENT_ACTIVITY_SUMMARY => { + let _ = state.park_gated_activity_frame(event); } - } + _ => {} + }, // Already reconnecting — redundant. RelayCommand::Reconnect => {} // Callers MUST handle Shutdown before calling this function. @@ -1337,12 +1627,13 @@ fn apply_command_to_state(state: &mut BgState, cmd: RelayCommand) { /// `Shutdown` and `Reconnect` are handled by the caller. fn retain_failed_command_intent(state: &mut BgState, cmd: RelayCommand) { match cmd { - RelayCommand::PublishEvent { event } - if event.kind.as_u16() as u32 == KIND_AGENT_OBSERVER_FRAME => - { - state.park_gated_observer_frame(event); - } - RelayCommand::PublishEvent { .. } => {} + RelayCommand::PublishEvent { event } => match event.kind.as_u16() as u32 { + KIND_AGENT_OBSERVER_FRAME => state.park_gated_observer_frame(event), + KIND_AGENT_ACTIVITY_SUMMARY => { + let _ = state.park_gated_activity_frame(event); + } + _ => {} + }, cmd => apply_command_to_state(state, cmd), } } @@ -1497,44 +1788,72 @@ async fn execute_connected_command( } } RelayCommand::PublishEvent { event } => { - // Observer telemetry frames (kind 24200) are durable telemetry, not - // droppable ephemera: park them while the rate-limit gate is armed — - // and while earlier parked frames are still draining, so relative - // order is preserved — then let the main-loop drain deliver them - // one per pacing tick once the gate clears. - if event.kind.as_u16() as u32 == KIND_AGENT_OBSERVER_FRAME - && (state.check_rate_gate().is_some() || !state.gated_observer_pending.is_empty()) + let kind = event.kind.as_u16() as u32; + let is_observer = kind == KIND_AGENT_OBSERVER_FRAME; + let is_activity = kind == KIND_AGENT_ACTIVITY_SUMMARY; + + // Observer telemetry is durable. Shared activity is short-lived but + // must survive its own transient quota gate or disconnect while fresh. + // Each telemetry class preserves order only within its own stream. + let has_same_kind_backlog = (is_observer && !state.gated_observer_pending.is_empty()) + || (is_activity && !state.gated_activity_pending.is_empty()); + if (is_observer || is_activity) + && (state.check_rate_gate().is_some() || has_same_kind_backlog) { - debug!( - pending = state.gated_observer_pending.len(), - "rate-gated: parking observer frame for paced drain" - ); - state.park_gated_observer_frame(event); + if is_observer { + debug!( + pending = state.gated_observer_pending.len(), + "rate-gated: parking observer frame for paced drain" + ); + state.park_gated_observer_frame(event); + } else { + debug!( + pending = state.gated_activity_pending.len(), + "rate-gated: parking shared activity for paced drain" + ); + let _ = state.park_gated_activity_frame(event); + } return true; } // Drop remaining ephemeral publishes while rate-gated. Stale typing // indicators are worthless and sending them would consume admission // budget the relay already rejected us on. - // - // INVARIANT: apart from observer frames (parked above), the WS publish - // path carries only ephemeral kinds (typing indicators). The silent - // drop-while-gated relies on that invariant. If a future caller - // publishes durable events through this path, it must extend the - // kind guard above to avoid silently discarding user data. if state.check_rate_gate().is_some() { debug!("rate-gated: dropping ephemeral PublishEvent (typing indicator)"); return true; } - // Best-effort: log a send failure but don't trigger reconnect — the - // next ping or read will detect the dead socket. A failed observer - // frame is parked so the post-reconnect drain redelivers it. - let is_observer = event.kind.as_u16() as u32 == KIND_AGENT_OBSERVER_FRAME; + + // Enforce the local live-only cutoff at the final write boundary. A + // post-write check is too late because the relay may already have + // received a stale activity frame. + if is_activity && !BgState::activity_event_is_fresh(&event) { + state.cleanup_activity_id(&event.id.to_hex()); + state.activity_expired += 1; + warn!( + expired_total = state.activity_expired, + "stale shared activity discarded before socket write" + ); + return true; + } + if is_activity && !state.admit_activity_id(&event.id.to_hex()) { + debug!("duplicate sanitized shared activity suppressed before socket write"); + return true; + } + + // Track both telemetry classes until the relay's OK. A failed socket + // write is queued in-memory for a paced retry; shared activity expires. if send_publish_event_frame(ws, &event).await { if is_observer { state.track_observer_in_flight(event); + } else if is_activity { + state.track_activity_in_flight(event); + info!("sanitized shared activity sent; awaiting relay acknowledgment"); } } else if is_observer { state.park_gated_observer_frame(event); + } else if is_activity { + let _ = state.queue_activity_retry(event); + return false; } true } @@ -1656,6 +1975,10 @@ async fn run_background_task( let mut drain_pacing_next: Option = None; loop { + // The ping/select loop wakes periodically even when no turns arrive, so + // unacknowledged live-only activity cannot remain resident indefinitely. + state.expire_stale_activity(); + if state.proactive_resubscribe_needed { state.proactive_resubscribe_needed = false; info!("proactive resubscribe triggered by backpressure event loss"); @@ -1735,6 +2058,7 @@ async fn run_background_task( // Drain pending subs, one REQ per pacing tick within the relay's // admission window. let drain_window_open = drain_pacing_next.is_none_or(|t| tokio::time::Instant::now() >= t); + let mut activity_drain_failed = false; if drain_window_open { let mut budget = DRAIN_BUDGET_PER_ITER; let mut any_sent = false; @@ -1793,16 +2117,38 @@ async fn run_background_task( } } + // Preserve the established owner-observer drain priority. Shared + // activity is separately freshness-bounded and drains from any + // remaining budget. if budget > 0 && !state.gated_observer_pending.is_empty() { let sent = drain_gated_observer_pending(&mut ws, &mut state, budget).await; + budget = budget.saturating_sub(sent); if sent > 0 { any_sent = true; } } + if budget > 0 && !state.gated_activity_pending.is_empty() { + match drain_gated_activity_pending(&mut ws, &mut state, budget).await { + Ok(sent) => { + if sent > 0 { + any_sent = true; + } + } + Err(sent) => { + if sent > 0 { + any_sent = true; + } + activity_drain_failed = true; + } + } + } + if any_sent { drain_pacing_next = Some(tokio::time::Instant::now() + REQ_PACING_INTERVAL); - } else if !state.gated_observer_pending.is_empty() { + } else if !state.gated_observer_pending.is_empty() + || !state.gated_activity_pending.is_empty() + { // Nothing sent because the gate is still armed. Arm the pacing // timer to the gate deadline so parked observer frames drain // promptly even when no other traffic wakes the select loop. @@ -1812,6 +2158,60 @@ async fn run_background_task( } } + if activity_drain_failed { + warn!("shared activity drain write failed — reconnecting before retry"); + let _ = event_tx.try_send(None); + match try_autonomous_reconnect( + &mut ws, + &mut cmd_rx, + &mut state, + &keys, + &relay_url, + &agent_pubkey_hex, + &event_tx, + &observer_control_tx, + auth_tag.as_ref(), + ) + .await + { + ReconnectOutcome::Shutdown => return, + ReconnectOutcome::Ok => { + if matches!( + drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex,) + .await, + ReconnectOutcome::Shutdown + ) { + return; + } + } + ReconnectOutcome::Failed => { + if matches!( + wait_for_reconnect( + &mut ws, + &mut cmd_rx, + &mut state, + &keys, + &relay_url, + &agent_pubkey_hex, + &event_tx, + &observer_control_tx, + true, + auth_tag.as_ref(), + ) + .await, + ReconnectOutcome::Shutdown + ) { + return; + } + } + } + ping_sent = false; + last_pong = Instant::now(); + connected_since = Instant::now(); + stable_logged = false; + continue; + } + tokio::select! { raw = ws.next() => { // Determine if the socket is lost. @@ -2229,6 +2629,7 @@ async fn handle_ws_message( let secs = parse_rate_limit_retry_secs(&message).unwrap_or(0); let deadline = state.set_rate_limit_gate(secs); state.requeue_observer_in_flight(); + state.requeue_activity_in_flight(); warn!( "rate-limit gate armed via NOTICE until ~{:.1}s from now", deadline @@ -2389,11 +2790,24 @@ async fn handle_ws_message( } => { if !accepted && message.starts_with("auth") { // AUTH OK with accepted=false means auth was rejected. - warn!("mid-session AUTH rejected (event {event_id}): {message} — triggering reconnect"); + warn!("mid-session AUTH rejected — triggering reconnect"); return false; } + let retryable_activity = !accepted && message.starts_with("rate-limited:"); + let was_activity = + state.acknowledge_activity_frame(&event_id, accepted, retryable_activity); + if was_activity && retryable_activity { + let secs = parse_rate_limit_retry_secs(&message).unwrap_or(0); + state.set_rate_limit_gate(secs); + } state.acknowledge_observer_frame(&event_id); - debug!("OK for event {event_id}: accepted={accepted} message={message}"); + if was_activity { + if accepted { + info!("relay accepted sanitized shared activity"); + } + } else { + debug!(accepted, "relay OK received for non-activity event"); + } } } true @@ -2637,8 +3051,7 @@ async fn resubscribe_after_reconnect( /// Send a signed EVENT frame on the live socket. Returns `false` on send failure. /// -/// Best-effort at the socket level: a failure is logged but does not trigger -/// reconnect — the next ping or read will detect the dead socket. +/// Callers decide whether a failed write is droppable or requires reconnect. async fn send_publish_event_frame(ws: &mut WsStream, event: &Event) -> bool { let msg = json!(["EVENT", event]); if let Ok(text) = serde_json::to_string(&msg) { @@ -2651,6 +3064,37 @@ async fn send_publish_event_frame(ws: &mut WsStream, event: &Event) -> bool { true } +/// Drain fresh shared activity after a transient disconnect or quota gate. +/// Frames are memory-only and expire before the relay's freshness boundary. +async fn drain_gated_activity_pending( + ws: &mut WsStream, + state: &mut BgState, + budget: usize, +) -> Result { + state.expire_stale_activity(); + let mut sent = 0; + while sent < budget { + if state.check_rate_gate().is_some() { + break; + } + let Some(event) = state.gated_activity_pending.pop_front() else { + break; + }; + if !BgState::activity_event_is_fresh(&event) { + state.cleanup_activity_id(&event.id.to_hex()); + state.activity_expired += 1; + continue; + } + if !send_publish_event_frame(ws, &event).await { + let _ = state.queue_activity_retry(event); + return Err(sent); + } + state.track_activity_in_flight(event); + sent += 1; + } + Ok(sent) +} + /// Drain parked observer telemetry frames once the rate-limit gate clears. /// /// Called by the main loop pacing timer. Sends at most `budget` frames without @@ -2933,6 +3377,7 @@ async fn try_autonomous_reconnect( auth_tag: Option<&nostr::Tag>, ) -> ReconnectOutcome { state.requeue_observer_in_flight(); + state.requeue_activity_in_flight(); // 5 attempts, up to 16s base backoff. Shares delay values with the // initial-connect retry in `HarnessRelay::connect()` (STARTUP_CONNECT_BACKOFFS) — // see its doc comment for how the two loops consume the array differently. @@ -3063,6 +3508,7 @@ async fn wait_for_reconnect( auth_tag: Option<&nostr::Tag>, ) -> ReconnectOutcome { state.requeue_observer_in_flight(); + state.requeue_activity_in_flight(); if !skip_drain { // Drain commands until we get Reconnect (or Shutdown). // Other commands update state so reconnect reflects latest intent. @@ -5806,7 +6252,23 @@ mod tests { ); } - /// set_rate_limit_gate arms the gate with jittered expiry from the hint. + /// A relay retry hint is a strict not-before deadline. The gate must not + /// reopen early through negative jitter. + #[tokio::test(start_paused = true)] + async fn rate_limit_gate_never_reopens_before_relay_hint() { + let mut state = BgState::new(); + let now = tokio::time::Instant::now(); + + let deadline = state.set_rate_limit_gate(5); + assert_eq!(deadline, now + Duration::from_secs(5)); + + tokio::time::advance(Duration::from_millis(4_999)).await; + assert!(state.check_rate_gate().is_some()); + tokio::time::advance(Duration::from_millis(1)).await; + assert!(state.check_rate_gate().is_none()); + } + + /// set_rate_limit_gate arms the gate with expiry from the relay hint. /// check_rate_gate returns Some while active and lazily clears on expiry. #[tokio::test(start_paused = true)] async fn rate_limit_gate_set_and_expiry() { @@ -5823,7 +6285,7 @@ mod tests { "gate must be active immediately after arming" ); - // Advance virtual time past the max jitter (1.2 × 5 s = 6 s). + // Advance virtual time past the exact five-second deadline. tokio::time::advance(Duration::from_secs(7)).await; assert!( @@ -5875,6 +6337,292 @@ mod tests { .expect("sign test observer frame") } + fn make_shared_activity_frame(keys: &Keys, created_at_secs: u64) -> Event { + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as u16), + r#"{"version":1,"activities":[]}"#, + ) + .tags([ + Tag::parse(["h", &Uuid::new_v4().to_string()]).expect("channel tag"), + Tag::parse(["agent", &keys.public_key().to_hex()]).expect("agent tag"), + ]) + .custom_created_at(nostr::Timestamp::from(created_at_secs)) + .sign_with_keys(keys) + .expect("sign shared activity frame") + } + + /// Shared activity is live-only but not droppable like a typing indicator: + /// a rate gate must park a fresh frame, then pace it onto the wire. + #[tokio::test] + async fn gated_shared_activity_is_parked_then_drained_while_typing_is_dropped() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_millis(150)); + + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + assert!( + execute_connected_command( + &mut client, + &mut state, + "agent-pubkey", + RelayCommand::PublishEvent { + event: Box::new(activity.clone()), + }, + ) + .await + ); + assert_eq!(state.gated_activity_pending.len(), 1); + + let typing = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "") + .tags([Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign typing indicator"); + assert!( + execute_connected_command( + &mut client, + &mut state, + "agent-pubkey", + RelayCommand::PublishEvent { + event: Box::new(typing), + }, + ) + .await + ); + assert_eq!(state.gated_activity_pending.len(), 1); + assert!(timeout(Duration::from_millis(50), server.next()) + .await + .is_err()); + + tokio::time::sleep(Duration::from_millis(160)).await; + assert_eq!( + drain_gated_activity_pending(&mut client, &mut state, 1).await, + Ok(1) + ); + let frame = next_test_frame(&mut server).await; + assert_eq!(frame[0], "EVENT"); + assert_eq!(frame[1]["id"], activity.id.to_hex()); + assert_eq!( + frame[1]["kind"], + u64::from(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY) + ); + } + + /// Commands consumed while disconnected must retain fresh activity intent, + /// but must continue dropping typing indicators. + #[test] + fn disconnected_shared_activity_is_bounded_and_typing_remains_droppable() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + apply_command_to_state( + &mut state, + RelayCommand::PublishEvent { + event: Box::new(activity), + }, + ); + let typing = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "") + .tags([Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign typing indicator"); + apply_command_to_state( + &mut state, + RelayCommand::PublishEvent { + event: Box::new(typing), + }, + ); + assert_eq!(state.gated_activity_pending.len(), 1); + } + + /// A live-only activity frame must expire locally before the relay's strict + /// freshness boundary rather than being replayed as stale history. + #[test] + fn stale_shared_activity_expires_instead_of_entering_reconnect_queue() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let stale = make_shared_activity_frame( + &keys, + unix_now_secs().saturating_sub(ACTIVITY_EVENT_MAX_AGE_SECS + 1), + ); + apply_command_to_state( + &mut state, + RelayCommand::PublishEvent { + event: Box::new(stale), + }, + ); + assert!(state.gated_activity_pending.is_empty()); + assert_eq!(state.activity_expired, 1); + } + + /// Freshness is enforced at the final write boundary, including the direct + /// no-backlog fast path. A stale frame must never reach the relay socket. + #[tokio::test] + async fn stale_shared_activity_is_rejected_before_immediate_socket_write() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + let stale = make_shared_activity_frame( + &keys, + unix_now_secs().saturating_sub(ACTIVITY_EVENT_MAX_AGE_SECS + 1), + ); + + assert!( + execute_connected_command( + &mut client, + &mut state, + "agent-pubkey", + RelayCommand::PublishEvent { + event: Box::new(stale), + }, + ) + .await + ); + + assert!( + timeout(Duration::from_millis(50), server.next()) + .await + .is_err(), + "stale shared activity must be rejected before the socket write" + ); + assert!(state.gated_activity_pending.is_empty()); + assert!(state.activity_in_flight.is_empty()); + assert_eq!(state.activity_expired, 1); + } + + /// A failed write consumes the activity frame's single ambiguity retry, but + /// that retry must wait for a replacement connection. Returning success here + /// would let the main-loop drain resend on the same suspect socket. + #[tokio::test] + async fn activity_socket_write_failure_queues_one_retry_and_requires_reconnect() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + + client.close(None).await.expect("close client websocket"); + let _ = timeout(Duration::from_secs(1), server.next()).await; + + assert!( + !execute_connected_command( + &mut client, + &mut state, + "agent-pubkey", + RelayCommand::PublishEvent { + event: Box::new(activity), + }, + ) + .await, + "a failed activity write must force a new transport generation" + ); + assert_eq!(state.gated_activity_pending.len(), 1); + assert!(state.activity_in_flight.is_empty()); + assert_eq!(state.activity_retried, 1); + } + + #[tokio::test] + async fn duplicate_activity_id_is_suppressed_before_a_second_socket_write() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + + assert!( + execute_connected_command( + &mut client, + &mut state, + "agent-pubkey", + RelayCommand::PublishEvent { + event: Box::new(activity.clone()), + }, + ) + .await + ); + let first = next_test_frame(&mut server).await; + assert_eq!(first[1]["id"], activity.id.to_hex()); + + assert!( + execute_connected_command( + &mut client, + &mut state, + "agent-pubkey", + RelayCommand::PublishEvent { + event: Box::new(activity), + }, + ) + .await + ); + assert!( + timeout(Duration::from_millis(50), server.next()) + .await + .is_err(), + "a retained event ID must not be written twice" + ); + assert_eq!(state.activity_in_flight.len(), 1); + } + + #[tokio::test] + async fn acknowledged_duplicate_activity_id_is_suppressed_before_socket_write() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + + assert!( + execute_connected_command( + &mut client, + &mut state, + "agent-pubkey", + RelayCommand::PublishEvent { + event: Box::new(activity.clone()), + }, + ) + .await + ); + let _ = next_test_frame(&mut server).await; + assert!(state.acknowledge_activity_frame(&activity.id.to_hex(), true, false)); + + assert!( + execute_connected_command( + &mut client, + &mut state, + "agent-pubkey", + RelayCommand::PublishEvent { + event: Box::new(activity), + }, + ) + .await + ); + assert!( + timeout(Duration::from_millis(50), server.next()) + .await + .is_err(), + "an acknowledged activity ID must be suppressed before a new socket write" + ); + } + + #[tokio::test] + async fn failed_activity_drain_reports_transport_failure_for_reconnect() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + assert!( + state.park_gated_activity_frame(Box::new(make_shared_activity_frame( + &keys, + unix_now_secs() + ))) + ); + + client.close(None).await.expect("close client websocket"); + let _ = timeout(Duration::from_secs(1), server.next()).await; + + assert_eq!( + drain_gated_activity_pending(&mut client, &mut state, 1).await, + Err(0) + ); + assert_eq!(state.gated_activity_pending.len(), 1); + assert_eq!(state.activity_retried, 1); + } + /// While the rate-limit gate is armed, an observer frame (kind 24200) is /// parked — not silently dropped — and delivered by the drain once the /// gate clears. A typing indicator in the same window stays dropped. @@ -6027,6 +6775,260 @@ mod tests { assert!(state.observer_in_flight.is_empty()); } + #[test] + fn activity_ok_retires_and_negative_ok_is_visible_without_retry_storm() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let accepted = make_shared_activity_frame(&keys, unix_now_secs()); + let rejected = make_shared_activity_frame(&keys, unix_now_secs()); + + state.track_activity_in_flight(Box::new(accepted.clone())); + state.track_activity_in_flight(Box::new(rejected.clone())); + assert!(state.acknowledge_activity_frame(&accepted.id.to_hex(), true, false)); + assert!(state.acknowledge_activity_frame(&rejected.id.to_hex(), false, false)); + + assert!(state.activity_in_flight.is_empty()); + assert!(state.gated_activity_pending.is_empty()); + assert_eq!(state.activity_rejected, 1); + assert_eq!(state.activity_retried, 0); + assert!(!state.acknowledge_activity_frame("unknown", false, true)); + assert_eq!(state.activity_rejected, 1); + } + + #[test] + fn rate_limited_activity_negative_ok_gets_one_bounded_fresh_retry() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + state.track_activity_in_flight(Box::new(activity.clone())); + + assert!(state.acknowledge_activity_frame(&activity.id.to_hex(), false, true)); + assert!(state.activity_in_flight.is_empty()); + assert_eq!(state.gated_activity_pending.len(), 1); + assert_eq!(state.activity_rejected, 1); + assert_eq!(state.activity_retried, 1); + + // A second transient rejection is retired, not retried forever. + let retry = state.gated_activity_pending.pop_front().unwrap(); + state.track_activity_in_flight(retry); + assert!(state.acknowledge_activity_frame(&activity.id.to_hex(), false, true)); + assert!(state.gated_activity_pending.is_empty()); + assert!(state.activity_in_flight.is_empty()); + assert_eq!(state.activity_rejected, 2); + assert_eq!(state.activity_retried, 1); + } + + #[test] + fn activity_retry_stays_ahead_of_newer_pending_frames() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let older = make_shared_activity_frame(&keys, unix_now_secs()); + let newer = make_shared_activity_frame(&keys, unix_now_secs()); + + state.track_activity_in_flight(Box::new(older.clone())); + assert!(state.park_gated_activity_frame(Box::new(newer.clone()))); + assert!(state.acknowledge_activity_frame(&older.id.to_hex(), false, true)); + + let ids: Vec<_> = state + .gated_activity_pending + .iter() + .map(|event| event.id) + .collect(); + assert_eq!(ids, [older.id, newer.id]); + } + + #[test] + fn activity_retries_preserve_original_order_when_acks_arrive_in_send_order() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let first = make_shared_activity_frame(&keys, unix_now_secs()); + let second = make_shared_activity_frame(&keys, unix_now_secs()); + + state.track_activity_in_flight(Box::new(first.clone())); + state.track_activity_in_flight(Box::new(second.clone())); + assert!(state.acknowledge_activity_frame(&first.id.to_hex(), false, true)); + assert!(state.acknowledge_activity_frame(&second.id.to_hex(), false, true)); + + let ids: Vec<_> = state + .gated_activity_pending + .iter() + .map(|event| event.id) + .collect(); + assert_eq!(ids, [first.id, second.id]); + } + + #[test] + fn duplicate_activity_id_has_only_one_retained_copy_and_one_ack_retires_it() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + + assert!(state.park_gated_activity_frame(Box::new(activity.clone()))); + assert!(!state.park_gated_activity_frame(Box::new(activity.clone()))); + assert_eq!(state.gated_activity_pending.len(), 1); + assert!(state.acknowledge_activity_frame(&activity.id.to_hex(), true, false)); + assert!(state.gated_activity_pending.is_empty()); + assert!(state.activity_in_flight.is_empty()); + assert!(!state + .activity_retry_attempted + .contains(&activity.id.to_hex())); + assert!( + !state.park_gated_activity_frame(Box::new(activity)), + "a terminally acknowledged event ID must not regain admission or retry eligibility" + ); + } + + #[test] + fn activity_capacity_evicts_global_oldest_across_pending_and_in_flight() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let oldest = make_shared_activity_frame(&keys, unix_now_secs()); + state.track_activity_in_flight(Box::new(oldest.clone())); + + for _ in 1..GATED_ACTIVITY_QUEUE_CAP { + assert!( + state.park_gated_activity_frame(Box::new(make_shared_activity_frame( + &keys, + unix_now_secs(), + ))) + ); + } + assert_eq!( + state.gated_activity_pending.len() + state.activity_in_flight.len(), + GATED_ACTIVITY_QUEUE_CAP + ); + + assert!( + state.park_gated_activity_frame(Box::new(make_shared_activity_frame( + &keys, + unix_now_secs() + ))) + ); + assert!(!state + .activity_in_flight + .iter() + .any(|event| event.id == oldest.id)); + assert_eq!( + state.gated_activity_pending.len() + state.activity_in_flight.len(), + GATED_ACTIVITY_QUEUE_CAP + ); + assert_eq!(state.activity_dropped, 1); + } + + #[test] + fn activity_reconnect_requeues_only_fresh_frames_in_original_order() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let first = make_shared_activity_frame(&keys, unix_now_secs()); + let stale = make_shared_activity_frame( + &keys, + unix_now_secs().saturating_sub(ACTIVITY_EVENT_MAX_AGE_SECS + 1), + ); + let second = make_shared_activity_frame(&keys, unix_now_secs()); + + state.track_activity_in_flight(Box::new(first.clone())); + // Insert directly to model an event that aged while awaiting OK. + state.activity_in_flight.push_back(Box::new(stale)); + state.track_activity_in_flight(Box::new(second.clone())); + state.requeue_activity_in_flight(); + + let ids: Vec<_> = state + .gated_activity_pending + .iter() + .map(|event| event.id) + .collect(); + assert_eq!(ids, [first.id, second.id]); + assert!(state.activity_in_flight.is_empty()); + assert_eq!(state.activity_expired, 1); + assert_eq!(state.activity_retried, 2); + } + + #[test] + fn activity_transport_ambiguity_allows_only_one_retry_across_reconnects() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + + state.track_activity_in_flight(Box::new(activity)); + state.requeue_activity_in_flight(); + assert_eq!(state.gated_activity_pending.len(), 1); + assert_eq!(state.activity_retried, 1); + + let retry = state.gated_activity_pending.pop_front().unwrap(); + state.track_activity_in_flight(retry); + state.requeue_activity_in_flight(); + + assert!(state.gated_activity_pending.is_empty()); + assert!(state.activity_in_flight.is_empty()); + assert_eq!(state.activity_retried, 1); + assert_eq!(state.activity_dropped, 1); + } + + #[test] + fn late_positive_ok_retires_activity_parked_after_transport_ambiguity() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + + state.track_activity_in_flight(Box::new(activity.clone())); + state.requeue_activity_in_flight(); + assert_eq!(state.gated_activity_pending.len(), 1); + + assert!(state.acknowledge_activity_frame(&activity.id.to_hex(), true, false)); + assert!(state.gated_activity_pending.is_empty()); + assert!(state.activity_in_flight.is_empty()); + assert!(!state + .activity_retry_attempted + .contains(&activity.id.to_hex())); + } + + #[test] + fn late_retryable_rejection_preserves_the_single_scheduled_retry() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + + state.track_activity_in_flight(Box::new(activity.clone())); + state.requeue_activity_in_flight(); + assert_eq!(state.activity_retried, 1); + + assert!(state.acknowledge_activity_frame(&activity.id.to_hex(), false, true)); + assert_eq!(state.gated_activity_pending.len(), 1); + assert_eq!(state.activity_retried, 1); + assert_eq!(state.activity_rejected, 1); + } + + /// The oldest activity frame is dropped first when the memory-only transport + /// bound is reached; loss is counted rather than silently expanding state. + #[test] + fn shared_activity_transport_queue_is_bounded_with_visible_oldest_drop() { + let mut state = BgState::new(); + let keys = Keys::generate(); + let first = make_shared_activity_frame(&keys, unix_now_secs()); + assert!(state.park_gated_activity_frame(Box::new(first.clone()))); + for _ in 1..GATED_ACTIVITY_QUEUE_CAP { + assert!( + state.park_gated_activity_frame(Box::new(make_shared_activity_frame( + &keys, + unix_now_secs(), + ))) + ); + } + let overflow = make_shared_activity_frame(&keys, unix_now_secs()); + assert!(state.park_gated_activity_frame(Box::new(overflow.clone()))); + + assert_eq!(state.gated_activity_pending.len(), GATED_ACTIVITY_QUEUE_CAP); + assert_eq!(state.activity_dropped, 1); + assert!(!state + .gated_activity_pending + .iter() + .any(|event| event.id == first.id)); + assert_eq!( + state.gated_activity_pending.back().map(|event| event.id), + Some(overflow.id) + ); + } + /// The parked-frame queue is bounded: overflow evicts the oldest frame and /// counts it; the drain resets the counter after logging the summary. #[tokio::test] @@ -6158,7 +7160,7 @@ mod tests { "gate must be active while membership sub is pending" ); - // Advance past the gate (max jitter: 1.2 × 5s = 6s). + // Advance past the exact five-second gate. tokio::time::advance(Duration::from_secs(7)).await; assert!( From 11f759644c5d3312c64cbec7979ed08cfe69ed4e Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Thu, 13 Aug 2026 00:30:34 +0700 Subject: [PATCH 09/11] fix(activity): harden live delivery trust Signed-off-by: lordfarquad --- crates/buzz-acp/src/relay.rs | 49 +++++++- crates/buzz-relay/src/handlers/event.rs | 151 ++++++++++++++++++----- crates/buzz-relay/src/handlers/ingest.rs | 63 ++++++---- crates/buzz-relay/src/handlers/req.rs | 22 ++++ 4 files changed, 224 insertions(+), 61 deletions(-) diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 87e31477b10..7fba33a022e 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -3049,17 +3049,27 @@ async fn resubscribe_after_reconnect( } } -/// Send a signed EVENT frame on the live socket. Returns `false` on send failure. +/// Serialize a signed EVENT frame without hiding serialization failure. +fn serialize_publish_event_frame(event: &T) -> serde_json::Result { + serde_json::to_string(&("EVENT", event)) +} + +/// Send a signed EVENT frame on the live socket. Returns `false` on serialization +/// or send failure. /// /// Callers decide whether a failed write is droppable or requires reconnect. async fn send_publish_event_frame(ws: &mut WsStream, event: &Event) -> bool { - let msg = json!(["EVENT", event]); - if let Ok(text) = serde_json::to_string(&msg) { - if let Err(e) = ws_send_timeout(ws, Message::Text(text.into()), WS_SEND_TIMEOUT_SECS).await - { - warn!("failed to publish event: {e}"); + let text = match serialize_publish_event_frame(event) { + Ok(text) => text, + Err(error) => { + warn!("failed to serialize publish event frame: {error}"); return false; } + }; + if let Err(error) = ws_send_timeout(ws, Message::Text(text.into()), WS_SEND_TIMEOUT_SECS).await + { + warn!("failed to publish event: {error}"); + return false; } true } @@ -4472,6 +4482,33 @@ async fn wait_for_any_ok( mod tests { use super::*; + struct SerializationFailure; + + impl serde::Serialize for SerializationFailure { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom("intentional test failure")) + } + } + + #[test] + fn publish_event_frame_serialization_propagates_failure() { + assert!(serialize_publish_event_frame(&SerializationFailure).is_err()); + } + + #[test] + fn publish_event_frame_serialization_preserves_event_wire_shape() { + let event = EventBuilder::new(Kind::TextNote, "safe") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + let text = serialize_publish_event_frame(&event).expect("serialize EVENT frame"); + let value: serde_json::Value = serde_json::from_str(&text).expect("parse EVENT frame"); + assert_eq!(value[0], "EVENT"); + assert_eq!(value[1]["id"], event.id.to_hex()); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index b336fbdefce..3890b9ce9e8 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -200,38 +200,28 @@ pub async fn filter_fanout_by_access( }; // Authoritative kind-24201 fence: run before channel-less/open-channel - // short-circuits and before every cache-backed membership path. + // short-circuits and before every cache-backed membership path. The full + // signed envelope, Redis/local route, managed-agent status, producer + // membership, and channel state are revalidated here at the shared delivery + // chokepoint; admission at publication time is not sufficient authority. if event_kind_u32(&stored_event.event) == KIND_AGENT_ACTIVITY_SUMMARY { - let Some(channel_id) = stored_event.channel_id else { + let Some(routed_channel_id) = stored_event.channel_id else { return Vec::new(); }; - let matches = - filter_agent_activity_subscription_matches(&state.sub_registry, channel_id, matches); + let matches = filter_agent_activity_subscription_matches( + &state.sub_registry, + routed_channel_id, + matches, + ); if matches.is_empty() { return Vec::new(); } - let channel = match state.db.get_channel(community_id, channel_id).await { - Ok(channel) - if agent_activity_channel_allowed( - &channel.channel_type, - channel.archived_at.is_some(), - ) => - { - channel - } - Ok(_) => return Vec::new(), - Err(error) => { - warn!( - %channel_id, - "agent activity fan-out fence: channel lookup failed: {error}" - ); - return Vec::new(); - } + let Some((channel_id, channel_type, channel_visibility)) = + authorize_agent_activity_delivery(state, community_id, stored_event).await + else { + return Vec::new(); }; - debug_assert!(agent_activity_channel_allowed( - &channel.channel_type, - channel.archived_at.is_some() - )); + debug_assert_eq!(channel_id, routed_channel_id); let mut recipient_pubkeys: Vec> = matches .iter() @@ -268,8 +258,8 @@ pub async fn filter_fanout_by_access( let pubkey = state.conn_manager.pubkey_for_conn(*conn_id); agent_activity_delivery_allowed( Some(channel_id), - Some(&channel.channel_type), - Some(&channel.visibility), + Some(&channel_type), + Some(&channel_visibility), pubkey.as_deref(), &active_pubkeys, ) @@ -1176,6 +1166,71 @@ fn validate_agent_activity_envelope(event: &Event, now: i64) -> Result, + now: i64, +) -> Result { + let event_channel_id = validate_agent_activity_envelope(event, now)?; + if routed_channel_id != Some(event_channel_id) { + return Err("invalid: agent activity route does not match canonical h tag".into()); + } + Ok(event_channel_id) +} + +/// Re-authorize a kind-24201 producer at the final delivery seam shared by +/// relay-local and Redis fan-out. All lookups are fresh and fail closed. +async fn authorize_agent_activity_delivery( + state: &AppState, + community_id: CommunityId, + stored_event: &StoredEvent, +) -> Option<(uuid::Uuid, String, String)> { + let routed_channel_id = stored_event.channel_id; + let event = stored_event.event.clone(); + let now = chrono::Utc::now().timestamp(); + let channel_id = match tokio::task::spawn_blocking(move || { + validate_agent_activity_delivery_envelope(&event, routed_channel_id, now) + }) + .await + { + Ok(Ok(channel_id)) => channel_id, + Ok(Err(message)) => { + warn!(reason = %message, "agent activity delivery envelope rejected"); + return None; + } + Err(error) => { + warn!("agent activity delivery verification task failed: {error}"); + return None; + } + }; + + let agent_bytes = stored_event.event.pubkey.to_bytes().to_vec(); + let (policy, member, channel) = tokio::join!( + state + .db + .get_agent_channel_policy(community_id, &agent_bytes), + state.db.is_member(community_id, channel_id, &agent_bytes), + state.db.get_channel(community_id, channel_id), + ); + let (policy, member, channel) = match (policy, member, channel) { + (Ok(policy), Ok(member), Ok(channel)) => (policy, member, channel), + _ => { + warn!( + %channel_id, + "agent activity delivery authorization lookup failed" + ); + return None; + } + }; + if !agent_activity_principal_allowed(policy.as_ref(), member) + || !agent_activity_channel_allowed(&channel.channel_type, channel.archived_at.is_some()) + { + return None; + } + + Some((channel_id, channel.channel_type, channel.visibility)) +} + async fn handle_agent_activity_event( event: Event, conn_id: uuid::Uuid, @@ -1796,6 +1851,27 @@ mod tests { .is_err()); } + #[test] + fn agent_activity_delivery_envelope_rejects_missing_or_mismatched_topic() { + let agent = Keys::generate(); + let channel_id = Uuid::new_v4(); + let event = agent_activity_event(&agent, channel_id, valid_agent_activity_content(), None); + let now = event.created_at.as_secs() as i64; + + assert_eq!( + super::validate_agent_activity_delivery_envelope(&event, Some(channel_id), now) + .expect("canonical channel topic should be accepted"), + channel_id + ); + assert!(super::validate_agent_activity_delivery_envelope(&event, None, now).is_err()); + assert!(super::validate_agent_activity_delivery_envelope( + &event, + Some(Uuid::new_v4()), + now + ) + .is_err()); + } + #[test] fn agent_activity_timestamp_accepts_exact_five_minute_boundary_only() { let now = 10_000_i64; @@ -2684,7 +2760,7 @@ mod tests { use std::sync::Arc; use buzz_core::StoredEvent; - use nostr::{EventBuilder, Keys, Kind}; + use nostr::{EventBuilder, Filter, Keys, Kind}; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -2875,12 +2951,25 @@ mod tests { #[tokio::test] async fn agent_activity_db_or_unknown_channel_error_fails_closed() { let state = test_state().await; + let community_id = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let channel_id = Uuid::new_v4(); let conn = register_conn(&state, Some(vec![1u8; 32])); + let activity_kind = Kind::Custom(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as u16); + let h = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + state.sub_registry.register_scoped( + community_id, + conn, + "activity".to_string(), + vec![Filter::new() + .kind(activity_kind) + .custom_tags(h, [channel_id.to_string()])], + Some(channel_id), + ); let out = filter_fanout_by_access( &state, - buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), - &agent_activity_event(Some(Uuid::new_v4())), - vec![(conn, "generic".to_string())], + community_id, + &agent_activity_event(Some(channel_id)), + vec![(conn, "activity".to_string())], None, ) .await; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5ba9650e91e..88901b01f6d 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -12,29 +12,29 @@ use uuid::Uuid; use buzz_auth::Scope; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, - is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, - KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, - KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, - KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, - KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, - RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, - RELAY_ADMIN_SET_WORKSPACE_PROFILE, + is_relay_admin_kind, KIND_AGENT_ACTIVITY_SUMMARY, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, + KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, + KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, + KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, + KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, + KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, + KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -99,6 +99,13 @@ fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError Ok(()) } +fn kind_requires_websocket(kind: u32) -> bool { + matches!( + kind, + KIND_GIFT_WRAP | KIND_PRESENCE_UPDATE | KIND_AGENT_ACTIVITY_SUMMARY + ) +} + /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { @@ -1971,7 +1978,7 @@ async fn ingest_event_inner( )); } - if auth.is_http() && (kind_u32 == KIND_GIFT_WRAP || kind_u32 == KIND_PRESENCE_UPDATE) { + if auth.is_http() && kind_requires_websocket(kind_u32) { return Err(IngestError::Rejected(format!( "invalid: kind {kind_u32} is only accepted via WebSocket" ))); @@ -3748,6 +3755,14 @@ mod tests { ); } + #[test] + fn agent_activity_http_ingest_is_explicitly_websocket_only() { + assert!(kind_requires_websocket(KIND_AGENT_ACTIVITY_SUMMARY)); + assert!(kind_requires_websocket(KIND_GIFT_WRAP)); + assert!(kind_requires_websocket(KIND_PRESENCE_UPDATE)); + assert!(!kind_requires_websocket(KIND_STREAM_MESSAGE)); + } + #[test] fn accounting_uses_authenticated_principal_pubkey() { let principal = nostr::Keys::generate(); diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index e490a65f60d..824e1e66992 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -135,6 +135,12 @@ fn agent_activity_req_access_allowed( && token_channel_ids.is_none_or(|allowed| allowed.contains(&channel_id)) } +/// Kind 24201 is an ephemeral live stream. It is registered for fan-out and +/// receives an immediate EOSE, but must never reach a historical-event query. +fn should_query_historical_events(agent_activity_channel: Option) -> bool { + agent_activity_channel.is_none() +} + /// Handle a REQ message: register the subscription, deliver historical events, then send EOSE. pub async fn handle_req( sub_id: String, @@ -416,6 +422,16 @@ pub async fn handle_req( debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription registered"); + if !should_query_historical_events(agent_activity_channel) { + conn.send(RelayMessage::eose(&sub_id)); + debug!( + conn_id = %conn_id, + sub_id = %sub_id, + "Live-only agent activity subscription ready" + ); + return; + } + // NIP-01 OR semantics: execute one DB query per filter and deduplicate results // by event ID. Collapsing all filters into a single query would merge their // time windows and limits, causing under-fetching when filters have different @@ -1485,6 +1501,12 @@ mod tests { } } + #[test] + fn agent_activity_req_is_registered_live_only_without_history_query() { + assert!(!should_query_historical_events(Some(uuid::Uuid::new_v4()))); + assert!(should_query_historical_events(None)); + } + #[test] fn agent_activity_req_rejects_kindless_mixed_and_malformed_channel_shapes() { let channel = uuid::Uuid::new_v4(); From d2f8bafe61f6386c0d699b40544eb5bf6abe016f Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Thu, 13 Aug 2026 00:44:39 +0700 Subject: [PATCH 10/11] fix(activity): retry cross-node fanout failures Signed-off-by: lordfarquad --- crates/buzz-acp/src/relay.rs | 24 +++++++++++++++++++++++- crates/buzz-relay/src/handlers/event.rs | 25 ++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 7fba33a022e..76a97f1ba61 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -2793,7 +2793,8 @@ async fn handle_ws_message( warn!("mid-session AUTH rejected — triggering reconnect"); return false; } - let retryable_activity = !accepted && message.starts_with("rate-limited:"); + let retryable_activity = + !accepted && shared_activity_negative_ok_is_retryable(&message); let was_activity = state.acknowledge_activity_frame(&event_id, accepted, retryable_activity); if was_activity && retryable_activity { @@ -4407,6 +4408,11 @@ struct OkResponse { message: String, } +fn shared_activity_negative_ok_is_retryable(message: &str) -> bool { + message.starts_with("rate-limited:") + || message == "error: temporary agent activity fan-out failure" +} + /// Wait for the first `OK` message from the relay (used after sending AUTH). async fn wait_for_any_ok( ws: &mut WsStream, @@ -6855,6 +6861,22 @@ mod tests { assert_eq!(state.activity_retried, 1); } + #[test] + fn temporary_fanout_negative_ok_is_retryable() { + assert!(shared_activity_negative_ok_is_retryable( + "error: temporary agent activity fan-out failure" + )); + assert!(shared_activity_negative_ok_is_retryable( + "rate-limited: agent activity rate exceeded" + )); + assert!(!shared_activity_negative_ok_is_retryable( + "restricted: not a channel member" + )); + assert!(!shared_activity_negative_ok_is_retryable( + "invalid: malformed activity" + )); + } + #[test] fn activity_retry_stays_ahead_of_newer_pending_frames() { let mut state = BgState::new(); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 3890b9ce9e8..4055f6877c1 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1178,6 +1178,12 @@ fn validate_agent_activity_delivery_envelope( Ok(event_channel_id) } +fn agent_activity_publish_result(published: Result) -> Result<(), &'static str> { + published + .map(|_| ()) + .map_err(|_| "error: temporary agent activity fan-out failure") +} + /// Re-authorize a kind-24201 producer at the final delivery seam shared by /// relay-local and Redis fan-out. All lookups are fresh and fail closed. async fn authorize_agent_activity_delivery( @@ -1328,11 +1334,11 @@ async fn handle_agent_activity_event( } state.mark_local_event(community_id, &event.id); - if let Err(error) = state + let published = state .pubsub .publish_event(&conn.tenant, EventTopic::Channel(channel_id), &event) - .await - { + .await; + if let Err(error) = &published { state .local_event_ids .invalidate(&(community_id, event.id.to_bytes())); @@ -1343,6 +1349,10 @@ async fn handle_agent_activity_event( "agent activity publish failed: {error}" ); } + if let Err(message) = agent_activity_publish_result(published) { + conn.send(RelayMessage::ok(event_id_hex, false, message)); + return; + } let stored = StoredEvent::new(event, Some(channel_id)); fan_out_event_to_local_subscribers(&state, community_id, &stored).await; @@ -1872,6 +1882,15 @@ mod tests { .is_err()); } + #[test] + fn agent_activity_redis_failure_is_negative_and_retryable() { + assert_eq!( + super::agent_activity_publish_result::<()>(Err(())), + Err("error: temporary agent activity fan-out failure") + ); + assert_eq!(super::agent_activity_publish_result::<()>(Ok(1)), Ok(())); + } + #[test] fn agent_activity_timestamp_accepts_exact_five_minute_boundary_only() { let now = 10_000_i64; From e0758e20e26fab31a4f49e1cbefd21c2c4dc438a Mon Sep 17 00:00:00 2001 From: lordfarquad Date: Thu, 13 Aug 2026 04:38:52 +0700 Subject: [PATCH 11/11] fix(activity): preserve live-only delivery guarantees Signed-off-by: lordfarquad --- crates/buzz-acp/src/agent_activity.rs | 350 ++++++++++++++++-- crates/buzz-acp/src/config.rs | 22 ++ crates/buzz-acp/src/lib.rs | 60 ++- crates/buzz-acp/src/relay.rs | 349 ++++++++++++++++- .../deploy-full-launch.request.json | 1 + crates/buzz-core/src/agent_activity.rs | 58 ++- crates/buzz-core/tests/agent_activity.rs | 17 + crates/buzz-db/src/admin_moderation.rs | 54 ++- crates/buzz-db/src/event.rs | 229 +++++++++++- crates/buzz-db/src/thread.rs | 113 +++++- crates/buzz-db/src/usage.rs | 40 +- crates/buzz-relay/src/handlers/event.rs | 118 ++++++ crates/buzz-search/src/query.rs | 7 +- crates/buzz-search/tests/fts_integration.rs | 73 +++- .../src-tauri/src/commands/agents_deploy.rs | 2 + .../src-tauri/src/managed_agents/runtime.rs | 1 + desktop/src/features/agents/AGENTS.md | 7 + .../agents/sharedAgentActivity.test.mjs | 44 +++ ...nagedAgentActivityLaunch.contract.test.mjs | 35 ++ docs/remote-agents.md | 6 +- .../shared_activity_models_test.dart | 29 ++ 21 files changed, 1529 insertions(+), 86 deletions(-) create mode 100644 desktop/src/testing/managedAgentActivityLaunch.contract.test.mjs diff --git a/crates/buzz-acp/src/agent_activity.rs b/crates/buzz-acp/src/agent_activity.rs index a9e09bd60c6..95a99498cad 100644 --- a/crates/buzz-acp/src/agent_activity.rs +++ b/crates/buzz-acp/src/agent_activity.rs @@ -13,6 +13,8 @@ const MAX_TRACKED_TURNS: usize = 256; const MAX_RAW_ID_BYTES: usize = 128; /// A global two-second cadence caps summary traffic at 30 events/minute. pub(crate) const ACTIVITY_PUBLISH_TICK: Duration = Duration::from_secs(2); +/// Shutdown draining stays below the relay's 10 frames/second admission limit. +const ACTIVITY_SHUTDOWN_PUBLISH_INTERVAL: Duration = Duration::from_millis(125); pub(crate) struct ProjectedActivity { pub(crate) channel_id: Uuid, @@ -422,12 +424,14 @@ pub(crate) struct ActivityPublishQueue { } impl ActivityPublishQueue { - pub(crate) fn ingest(&mut self, projected: ProjectedActivity) { + /// Queue a sanitized activity update, returning `false` if accepting it + /// caused any queued update to be dropped. + pub(crate) fn ingest(&mut self, projected: ProjectedActivity) -> bool { let bytes = match serde_json::to_vec(&projected.activity) { Ok(serialized) => serialized.len(), Err(error) => { tracing::warn!("failed to size sanitized agent activity: {error}"); - return; + return false; } }; let channel_id = projected.channel_id; @@ -457,14 +461,14 @@ impl ActivityPublishQueue { activity: projected.activity, }); } else { - return; + return false; } self.pending_items += 1; self.pending_bytes += bytes; - self.enforce_bounds(); + self.enforce_bounds() } - fn enforce_bounds(&mut self) { + fn enforce_bounds(&mut self) -> bool { let mut dropped_items = 0u64; let mut dropped_bytes = 0u64; while self.channels.len() > ACTIVITY_PENDING_MAX_CHANNELS @@ -499,6 +503,7 @@ impl ActivityPublishQueue { "agent activity queue over bound; dropped oldest updates" ); } + dropped_items == 0 } fn oldest_item(&self) -> Option<(Uuid, usize)> { @@ -579,30 +584,78 @@ impl ActivityPublishQueue { } } +pub(crate) struct RelayActivityPublisherTask { + shutdown_tx: Option>, + handle: tokio::task::JoinHandle, +} + +impl RelayActivityPublisherTask { + #[cfg(test)] + pub(crate) fn abort(self) { + self.handle.abort(); + } + + /// Stop intake, drain already-delivered updates, and enqueue sanitized + /// frames into the relay transport before returning. + pub(crate) async fn shutdown(mut self, timeout: Duration) -> bool { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + let abort_handle = self.handle.abort_handle(); + match tokio::time::timeout(timeout, self.handle).await { + Ok(Ok(drained)) => drained, + Ok(Err(error)) => { + tracing::warn!("agent activity publisher exited during shutdown: {error}"); + false + } + Err(_) => { + tracing::warn!("agent activity publisher drain timed out; aborting"); + abort_handle.abort(); + false + } + } + } +} + pub(crate) fn spawn_relay_activity_publisher( observer: crate::observer::ObserverHandle, publisher: crate::relay::RelayEventPublisher, keys: nostr::Keys, agent_pubkey_hex: String, channel_info: crate::pool::ChannelInfoResolver, -) -> tokio::task::JoinHandle<()> { +) -> RelayActivityPublisherTask { // Subscribe synchronously so activity emitted immediately after this call is // live input, while pre-existing snapshot entries remain intentionally absent. let rx = observer.subscribe(); - tokio::spawn(async move { - run_relay_activity_publisher(rx, publisher, keys, agent_pubkey_hex, channel_info).await; - }) + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let handle = tokio::spawn(async move { + run_relay_activity_publisher( + rx, + shutdown_rx, + publisher, + keys, + agent_pubkey_hex, + channel_info, + ) + .await + }); + RelayActivityPublisherTask { + shutdown_tx: Some(shutdown_tx), + handle, + } } async fn run_relay_activity_publisher( mut rx: tokio::sync::broadcast::Receiver, + mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, publisher: crate::relay::RelayEventPublisher, keys: nostr::Keys, agent_pubkey_hex: String, channel_info: crate::pool::ChannelInfoResolver, -) { +) -> bool { let mut projector = ActivityProjector::default(); let mut queue = ActivityPublishQueue::default(); + let mut all_enqueued = true; let mut publish_tick = tokio::time::interval_at( tokio::time::Instant::now() + ACTIVITY_PUBLISH_TICK, ACTIVITY_PUBLISH_TICK, @@ -612,14 +665,50 @@ async fn run_relay_activity_publisher( loop { tokio::select! { + _ = &mut shutdown_rx => { + loop { + match rx.try_recv() { + Ok(event) => { + if let Some(projected) = projector.project(&event) { + all_enqueued &= queue.ingest(projected); + } + } + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(count)) => { + all_enqueued = false; + tracing::warn!( + dropped = count, + "agent activity publisher lagged during shutdown" + ); + } + Err( + tokio::sync::broadcast::error::TryRecvError::Empty + | tokio::sync::broadcast::error::TryRecvError::Closed, + ) => break, + } + } + while !queue.is_empty() { + all_enqueued &= publish_next_activity_frame( + &mut queue, + &publisher, + &keys, + &agent_pubkey_hex, + &channel_info, + ).await; + if !queue.is_empty() { + tokio::time::sleep(ACTIVITY_SHUTDOWN_PUBLISH_INTERVAL).await; + } + } + break; + } result = rx.recv(), if !closed => { match result { Ok(event) => { if let Some(projected) = projector.project(&event) { - queue.ingest(projected); + all_enqueued &= queue.ingest(projected); } } Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + all_enqueued = false; tracing::warn!(dropped = count, "agent activity publisher lagged"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => { @@ -628,33 +717,45 @@ async fn run_relay_activity_publisher( } } _ = publish_tick.tick() => { - if let Some((channel_id, frame)) = queue.next_frame() { - let channel_type = channel_info - .resolve(channel_id) - .await - .map(|info| info.channel_type); - if is_shared_activity_channel_type(channel_type.as_deref()) { - publish_activity_frame( - &publisher, - &keys, - &agent_pubkey_hex, - channel_id, - frame, - ) - .await; - } else { - tracing::debug!( - channel_id = %channel_id, - "sanitized agent activity suppressed for non-shared channel" - ); - } - } + all_enqueued &= publish_next_activity_frame( + &mut queue, + &publisher, + &keys, + &agent_pubkey_hex, + &channel_info, + ).await; if closed && queue.is_empty() { break; } } } } + all_enqueued +} + +async fn publish_next_activity_frame( + queue: &mut ActivityPublishQueue, + publisher: &crate::relay::RelayEventPublisher, + keys: &nostr::Keys, + agent_pubkey_hex: &str, + channel_info: &crate::pool::ChannelInfoResolver, +) -> bool { + let Some((channel_id, frame)) = queue.next_frame() else { + return true; + }; + let channel_type = channel_info + .resolve(channel_id) + .await + .map(|info| info.channel_type); + if is_shared_activity_channel_type(channel_type.as_deref()) { + publish_activity_frame(publisher, keys, agent_pubkey_hex, channel_id, frame).await + } else { + tracing::debug!( + channel_id = %channel_id, + "sanitized agent activity suppressed for non-shared channel" + ); + true + } } fn is_shared_activity_channel_type(channel_type: Option<&str>) -> bool { @@ -667,26 +768,31 @@ async fn publish_activity_frame( agent_pubkey_hex: &str, channel_id: Uuid, frame: AgentActivityFrame, -) { +) -> bool { let builder = match buzz_sdk::build_agent_activity_summary(channel_id, agent_pubkey_hex, &frame) { Ok(builder) => builder, Err(error) => { tracing::warn!("failed to build sanitized agent activity: {error}"); - return; + return false; } }; let signed = match builder.sign_with_keys(keys) { Ok(event) => event, Err(error) => { tracing::warn!("failed to sign sanitized agent activity: {error}"); - return; + return false; } }; - if let Err(error) = publisher.publish_event(signed).await { - // Summary publication is telemetry: relay failure must never surface to - // or delay the prompt task that generated it. - tracing::warn!("sanitized agent activity dropped: {error}"); + match publisher.publish_event(signed).await { + Ok(()) => true, + Err(error) => { + // Summary publication is telemetry: relay failure must never surface to + // or delay the prompt task that generated it, but shutdown must report + // that not every produced frame reached the relay transport. + tracing::warn!("sanitized agent activity dropped: {error}"); + false + } } } @@ -1295,6 +1401,30 @@ mod tests { ); } + #[test] + fn activity_queue_overflow_is_reported_to_caller() { + let channel_id = Uuid::from_u128(42); + let mut queue = ActivityPublishQueue::default(); + for index in 0..ACTIVITY_PENDING_MAX_ITEMS { + assert!( + queue.ingest(ProjectedActivity { + channel_id, + activity: test_turn_activity(index as u128 + 1), + }), + "items within the queue bound must be accepted without loss" + ); + } + + assert!( + !queue.ingest(ProjectedActivity { + channel_id, + activity: test_turn_activity(ACTIVITY_PENDING_MAX_ITEMS as u128 + 1), + }), + "evicting a queued update must be reported to the publisher" + ); + assert_eq!(queue.dropped_items, 1); + } + #[test] fn activity_frames_use_core_limits_and_rotate_channels_fairly() { let channel_a = Uuid::from_u128(1); @@ -1460,6 +1590,148 @@ mod tests { handle.abort(); } + fn activity_test_resolver(channel_id: Uuid) -> crate::pool::ChannelInfoResolver { + crate::pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + crate::relay::ChannelInfo { + name: "stream".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + crate::relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:9".into(), + keys: nostr::Keys::generate(), + auth_tag_json: None, + }, + ) + } + + fn emit_terminal_activity(observer: &crate::observer::ObserverHandle, channel_id: Uuid) { + let context = crate::observer::context_for( + Some(channel_id), + Some("raw-session-secret".into()), + Some("raw-turn-secret".into()), + ); + observer.emit( + "turn_started", + Some(0), + &context, + serde_json::json!({"prompt": "SECRET"}), + ); + observer.emit( + "agent_activity_turn_terminal", + Some(0), + &context, + serde_json::json!({"status": "completed", "result": "SECRET"}), + ); + } + + #[tokio::test] + async fn empty_publish_slot_is_a_successful_noop() { + let channel_id = Uuid::from_u128(200); + let mut queue = ActivityPublishQueue::default(); + let publisher = crate::relay::RelayEventPublisher::disconnected_test_publisher(); + let keys = nostr::Keys::generate(); + + assert!( + publish_next_activity_frame( + &mut queue, + &publisher, + &keys, + &keys.public_key().to_hex(), + &activity_test_resolver(channel_id), + ) + .await, + "no queued frame means there was no enqueue failure" + ); + } + + #[tokio::test] + async fn graceful_shutdown_drains_latest_terminal_update_before_exit() { + let channel_id = Uuid::from_u128(201); + let observer = crate::observer::ObserverHandle::in_process(); + let (publisher, mut published) = crate::relay::RelayEventPublisher::test_pair(); + let keys = nostr::Keys::generate(); + let task = spawn_relay_activity_publisher( + observer.clone(), + publisher, + keys.clone(), + keys.public_key().to_hex(), + activity_test_resolver(channel_id), + ); + emit_terminal_activity(&observer, channel_id); + + assert!( + task.shutdown(Duration::from_secs(2)).await, + "activity publisher must drain cleanly" + ); + let event = published.recv().await.expect("terminal frame published"); + let frame = AgentActivityFrame::parse(&event.content).expect("valid safe frame"); + assert_eq!(frame.activities.len(), 1); + assert_eq!(frame.activities[0].status, AgentActivityStatus::Completed); + assert!(!event.content.contains("SECRET")); + assert!(published.try_recv().is_err()); + } + + #[tokio::test] + async fn graceful_shutdown_reports_closed_relay_command_channel() { + let channel_id = Uuid::from_u128(202); + let observer = crate::observer::ObserverHandle::in_process(); + let publisher = crate::relay::RelayEventPublisher::disconnected_test_publisher(); + let keys = nostr::Keys::generate(); + let task = spawn_relay_activity_publisher( + observer.clone(), + publisher, + keys.clone(), + keys.public_key().to_hex(), + activity_test_resolver(channel_id), + ); + emit_terminal_activity(&observer, channel_id); + + assert!( + !task.shutdown(Duration::from_secs(2)).await, + "a closed relay command channel must make the publisher drain fail" + ); + } + + #[tokio::test] + async fn graceful_shutdown_reports_observer_bus_lag() { + let channel_id = Uuid::from_u128(203); + let (tx, rx) = tokio::sync::broadcast::channel(1); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let keys = nostr::Keys::generate(); + let publisher = crate::relay::RelayEventPublisher::test_pair().0; + for turn_id in ["turn-a", "turn-b"] { + assert!(tx + .send(observer_event( + "ignored", + channel_id, + turn_id, + serde_json::json!({}), + )) + .is_ok()); + } + shutdown_tx.send(()).expect("publisher shutdown receiver"); + + let drained = run_relay_activity_publisher( + rx, + shutdown_rx, + publisher, + keys.clone(), + keys.public_key().to_hex(), + activity_test_resolver(channel_id), + ) + .await; + + assert!( + !drained, + "broadcast lag dropped observer activity and must make the drain visibly fail" + ); + } + #[test] fn only_stream_and_forum_channel_types_are_shareable() { assert!(is_shared_activity_channel_type(Some("stream"))); diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index cd9d588f1ef..7f776f60679 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -475,6 +475,11 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, + /// Publish sanitized member-visible activity summaries over the relay. + /// This is independent of the encrypted owner-only observer feed. + #[arg(long, env = "BUZZ_ACP_RELAY_ACTIVITY", default_value_t = false)] + pub relay_activity: bool, + /// Exit after this many seconds with no dispatched events and no turn in flight. /// 0 disables inactivity self-termination. #[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)] @@ -563,6 +568,8 @@ pub struct Config { pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, + /// Whether to publish sanitized member-visible activity summaries. + pub relay_activity: bool, /// Seconds without dispatched events before an idle harness exits. 0 = disabled. pub exit_after_inactivity_secs: u64, /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. @@ -1117,6 +1124,7 @@ impl Config { persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, + relay_activity: args.relay_activity, exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, idle_pool_sleep_secs: args.idle_pool_sleep, @@ -1489,6 +1497,7 @@ mod tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + relay_activity: false, exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, @@ -2190,6 +2199,19 @@ channels = "ALL" assert!(err.to_string().contains("turn liveness interval must be 0")); } + #[test] + fn relay_activity_is_an_explicit_owner_observer_independent_switch() { + let key = "0".repeat(64); + let default = CliArgs::parse_from(["buzz-acp", "--private-key", &key]); + assert!(!default.relay_observer); + assert!(!default.relay_activity); + + let activity_only = + CliArgs::parse_from(["buzz-acp", "--private-key", &key, "--relay-activity"]); + assert!(!activity_only.relay_observer); + assert!(activity_only.relay_activity); + } + #[test] fn inactivity_exit_defaults_disabled_and_accepts_cli_value() { let key = "0".repeat(64); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 97f4e43e798..3b9780a6d99 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1496,10 +1496,43 @@ fn idle_pool_sleep_due( && inactivity_expired(last_activity, now, bound, turn_in_flight) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ObserverFeaturePlan { + create_bus: bool, + publish_owner_observer: bool, + publish_member_activity: bool, +} + +fn observer_feature_plan(relay_observer: bool, relay_activity: bool) -> ObserverFeaturePlan { + ObserverFeaturePlan { + create_bus: relay_observer || relay_activity, + publish_owner_observer: relay_observer, + publish_member_activity: relay_activity, + } +} + #[cfg(test)] mod inactivity_tests { use super::*; + #[test] + fn owner_observer_and_member_activity_have_independent_runtime_plans() { + let activity_only = observer_feature_plan(false, true); + assert!(activity_only.create_bus); + assert!(!activity_only.publish_owner_observer); + assert!(activity_only.publish_member_activity); + + let owner_only = observer_feature_plan(true, false); + assert!(owner_only.create_bus); + assert!(owner_only.publish_owner_observer); + assert!(!owner_only.publish_member_activity); + + let disabled = observer_feature_plan(false, false); + assert!(!disabled.create_bus); + assert!(!disabled.publish_owner_observer); + assert!(!disabled.publish_member_activity); + } + #[test] fn zero_disables_expiry_and_in_flight_turns_defer_it() { let started = tokio::time::Instant::now(); @@ -1779,8 +1812,9 @@ async fn tokio_main() -> Result<()> { tracing::info!("buzz-acp starting: {}", config.summary()); - let observer = config - .relay_observer + let observer_features = observer_feature_plan(config.relay_observer, config.relay_activity); + let observer = observer_features + .create_bus .then(observer::ObserverHandle::in_process); if let Some(handle) = &observer { handle.emit( @@ -1793,6 +1827,7 @@ async fn tokio_main() -> Result<()> { "agentArgs": config.agent_args, "parallelism": config.agents, "relayObserver": config.relay_observer, + "relayActivity": config.relay_activity, }), ); } @@ -1879,7 +1914,7 @@ async fn tokio_main() -> Result<()> { let mut relay_observer_publisher_task = None; let mut relay_activity_publisher_task = None; let mut relay_observer_publisher = None; - if config.relay_observer { + if observer_features.publish_owner_observer { if let (Some(observer), Some(owner_pubkey_hex)) = (observer.clone(), owner_cache.pubkey.clone()) { @@ -1977,7 +2012,12 @@ async fn tokio_main() -> Result<()> { // owner-only encrypted feed, but is independently sanitized, signed, paced, // and authorized by the relay. It starts after channel discovery so DM and // non-shared channel traffic can be suppressed before publication. - if let Some(activity_observer) = observer.clone() { + if observer_features.publish_member_activity { + let Some(activity_observer) = observer.clone() else { + return Err(anyhow::anyhow!( + "member activity enabled without an observer event bus" + )); + }; relay_activity_publisher_task = Some(agent_activity::spawn_relay_activity_publisher( activity_observer, relay.event_publisher(), @@ -3357,11 +3397,15 @@ async fn tokio_main() -> Result<()> { handle.abort(); } if let Some(handle) = relay_activity_publisher_task.take() { - handle.abort(); + if !handle.shutdown(Duration::from_secs(20)).await { + tracing::warn!("member-safe activity publisher did not drain cleanly"); + } } - // Graceful relay shutdown — sends WebSocket close frame and waits up to 5s - // for the background task to finish, rather than aborting immediately (#40). + // The activity publisher has now enqueued every sanitized terminal update. + // Graceful relay shutdown processes those FIFO commands, waits up to 4s for + // terminal relay acknowledgments, then closes the socket and waits up to 5s + // for the background task. relay.shutdown().await; tracing::info!("buzz-acp stopped"); @@ -6578,6 +6622,7 @@ mod build_mcp_servers_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + relay_activity: false, exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, @@ -6801,6 +6846,7 @@ mod error_outcome_emission_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + relay_activity: false, exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 76a97f1ba61..91cbb40e0c3 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -118,6 +118,10 @@ const GATED_OBSERVER_QUEUE_CAP: usize = 256; const GATED_ACTIVITY_QUEUE_CAP: usize = 120; const ACTIVITY_EVENT_MAX_AGE_SECS: u64 = 240; const ACTIVITY_SEEN_ID_LIMIT: usize = GATED_ACTIVITY_QUEUE_CAP * 2; +/// Bounded period for a healthy socket to send and acknowledge already-enqueued +/// member-safe activity before process shutdown. This never extends an event's +/// live-only freshness window. +const ACTIVITY_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(4); use std::time::Instant; @@ -128,7 +132,7 @@ use buzz_core::kind::{ use futures_util::{SinkExt, StreamExt}; use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag}; use serde_json::{json, Value}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tracing::{debug, info, warn}; @@ -546,6 +550,9 @@ enum RelayCommand { Unsubscribe { channel_id: Uuid }, /// Reconnect to the relay (re-authenticate and resubscribe). Reconnect, + /// Wait boundedly for already-enqueued kind-24201 frames to reach a terminal + /// relay acknowledgment. The caller logs a negative result before closing. + FlushActivity { completion: oneshot::Sender }, /// Shut down the background task. Shutdown, /// Subscribe to global membership notifications. @@ -605,6 +612,14 @@ impl RelayEventPublisher { .map_err(|_| RelayError::ConnectionClosed) } + /// Test-only publisher whose receiving half is already closed. + #[cfg(test)] + pub(crate) fn disconnected_test_publisher() -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel::(1); + drop(cmd_rx); + Self { cmd_tx } + } + /// Test-only publisher pair: published events are forwarded to the /// returned receiver instead of a live relay socket. #[cfg(test)] @@ -930,10 +945,33 @@ impl HarnessRelay { } impl HarnessRelay { - /// Graceful async shutdown — sends Shutdown command and waits up to 5s for - /// the background task to finish. Use this from async contexts instead of - /// relying on `Drop` (which aborts immediately). + /// Graceful async shutdown. First places a FIFO barrier behind all activity + /// publisher commands and waits boundedly for kind-24201 terminal relay + /// acknowledgments, then closes the socket and waits up to 5s for the + /// background task. Failure to drain is explicit rather than silent. pub async fn shutdown(mut self) { + let (completion_tx, completion_rx) = oneshot::channel(); + let drain_queued = self + .cmd_tx + .send(RelayCommand::FlushActivity { + completion: completion_tx, + }) + .await + .is_ok(); + let drained = drain_queued + && matches!( + tokio::time::timeout( + ACTIVITY_SHUTDOWN_DRAIN_TIMEOUT + Duration::from_secs(1), + completion_rx, + ) + .await, + Ok(Ok(true)) + ); + if !drained { + tracing::warn!( + "member-safe activity did not receive terminal relay acknowledgment before shutdown" + ); + } let _ = self.cmd_tx.send(RelayCommand::Shutdown).await; if let Some(handle) = self.bg_handle.take() { let abort_handle = handle.abort_handle(); @@ -1091,6 +1129,8 @@ struct BgState { activity_dropped: u64, activity_expired: u64, activity_rejected: u64, + /// Rejections that cannot be retried or later resolved by an accepted ACK. + activity_terminal_rejected: u64, activity_retried: u64, /// Bounded terminal-ID tombstones. A signed activity event that has already /// been admitted cannot regain retry eligibility after a terminal ACK. @@ -1142,6 +1182,7 @@ impl BgState { activity_dropped: 0, activity_expired: 0, activity_rejected: 0, + activity_terminal_rejected: 0, activity_retried: 0, activity_seen_ids: TwoGenDedup::new(ACTIVITY_SEEN_ID_LIMIT), activity_retry_attempted: HashSet::new(), @@ -1545,6 +1586,7 @@ impl BgState { if retryable && Self::activity_event_is_fresh(&event) { let _ = self.queue_activity_retry(event); } else { + self.activity_terminal_rejected += 1; self.cleanup_activity_id(event_id); } } @@ -1609,6 +1651,11 @@ fn apply_command_to_state(state: &mut BgState, cmd: RelayCommand) { }, // Already reconnecting — redundant. RelayCommand::Reconnect => {} + // A disconnected/reconnecting path cannot prove terminal relay + // acknowledgment within the caller's shutdown window. + RelayCommand::FlushActivity { completion } => { + let _ = completion.send(false); + } // Callers MUST handle Shutdown before calling this function. RelayCommand::Shutdown => { debug_assert!( @@ -1650,6 +1697,9 @@ fn retain_deferred_command_intent( while let Some(cmd) = deferred_commands.pop_front() { match cmd { RelayCommand::Shutdown | RelayCommand::Reconnect => {} + RelayCommand::FlushActivity { completion } => { + let _ = completion.send(false); + } cmd => retain_failed_command_intent(state, cmd), } } @@ -1866,10 +1916,10 @@ async fn execute_connected_command( true } // Control-flow commands — callers handle these before dispatching. - RelayCommand::Shutdown | RelayCommand::Reconnect => { + RelayCommand::Shutdown | RelayCommand::Reconnect | RelayCommand::FlushActivity { .. } => { debug_assert!( false, - "Shutdown/Reconnect must be handled by caller, not execute_connected_command" + "control-flow commands must be handled by caller, not execute_connected_command" ); true } @@ -2310,6 +2360,21 @@ async fn run_background_task( connected_since = Instant::now(); stable_logged = false; } + Some(RelayCommand::FlushActivity { completion }) => { + let drained = drain_shared_activity_before_shutdown( + &mut ws, + &mut state, + &event_tx, + &observer_control_tx, + &keys, + &relay_url, + &agent_pubkey_hex, + auth_tag.as_ref(), + ACTIVITY_SHUTDOWN_DRAIN_TIMEOUT, + ) + .await; + let _ = completion.send(drained); + } Some(RelayCommand::Shutdown) | None => { debug!("background task shutting down — sending close frame"); let _ = ws_send_timeout( @@ -3106,6 +3171,91 @@ async fn drain_gated_activity_pending( Ok(sent) } +/// Bounded shutdown barrier for member-safe activity. +/// +/// The command carrying this barrier sits behind every frame already enqueued by +/// the activity publisher, so the connected loop has admitted those frames into +/// `BgState` before entering here. We preserve normal pacing, freshness, ACK, +/// retry, and rate-gate semantics while continuing to service relay messages. +/// A timeout, socket loss, expiry, overflow, or rejection is reported as an +/// explicit failed drain; no event is persisted or made eligible for history. +#[allow(clippy::too_many_arguments)] +async fn drain_shared_activity_before_shutdown( + ws: &mut WsStream, + state: &mut BgState, + event_tx: &mpsc::Sender>, + observer_control_tx: &mpsc::Sender, + keys: &Keys, + relay_url: &str, + agent_pubkey_hex: &str, + auth_tag: Option<&nostr::Tag>, + max_wait: Duration, +) -> bool { + let deadline = tokio::time::Instant::now() + max_wait; + let terminal_rejected_before = state.activity_terminal_rejected; + let dropped_before = state.activity_dropped; + let expired_before = state.activity_expired; + let failed_before_barrier = + terminal_rejected_before > 0 || dropped_before > 0 || expired_before > 0; + let mut next_send = tokio::time::Instant::now(); + + loop { + state.expire_stale_activity(); + let failed = failed_before_barrier + || state.activity_terminal_rejected != terminal_rejected_before + || state.activity_dropped != dropped_before + || state.activity_expired != expired_before; + if state.gated_activity_pending.is_empty() && state.activity_in_flight.is_empty() { + return !failed; + } + + let now = tokio::time::Instant::now(); + if now >= deadline { + return false; + } + + let gate = state.check_rate_gate(); + if !state.gated_activity_pending.is_empty() && gate.is_none() && now >= next_send { + match drain_gated_activity_pending(ws, state, 1).await { + Ok(sent) => { + if sent > 0 { + next_send = tokio::time::Instant::now() + REQ_PACING_INTERVAL; + } + } + Err(_) => return false, + } + continue; + } + + let wake_at = gate + .or_else(|| (!state.gated_activity_pending.is_empty()).then_some(next_send)) + .map_or(deadline, |wake| wake.min(deadline)); + tokio::select! { + raw = ws.next() => { + let Some(Ok(message)) = raw else { + return false; + }; + if !handle_ws_message( + message, + ws, + event_tx, + observer_control_tx, + state, + keys, + relay_url, + agent_pubkey_hex, + auth_tag, + ) + .await + { + return false; + } + } + _ = tokio::time::sleep_until(wake_at) => {} + } + } +} + /// Drain parked observer telemetry frames once the rate-limit gate clears. /// /// Called by the main loop pacing timer. Sends at most `budget` frames without @@ -3312,6 +3462,12 @@ async fn drain_commands( RelayCommand::Reconnect => { debug!("drained stale Reconnect after reconnect"); } + RelayCommand::FlushActivity { completion } => { + // Reconnect replay cannot prove terminal ACK semantics for a + // shutdown barrier. Report failure; the owner logs it before + // issuing the final close instead of silently discarding state. + let _ = completion.send(false); + } RelayCommand::Shutdown => { debug!("shutdown received during post-reconnect drain"); let _ = ws_send_timeout(ws, Message::Close(None), WS_SEND_TIMEOUT_SECS).await; @@ -6818,6 +6974,187 @@ mod tests { assert!(state.observer_in_flight.is_empty()); } + #[tokio::test] + async fn graceful_shutdown_drain_allows_resolved_transient_retry() { + let (mut client, _server) = test_ws_pair().await; + let mut state = BgState::new(); + state.activity_rejected = 1; + state.activity_retried = 1; + let keys = Keys::generate(); + let (event_tx, _event_rx) = mpsc::channel(1); + let (observer_control_tx, _observer_control_rx) = mpsc::channel(1); + + let drained = drain_shared_activity_before_shutdown( + &mut client, + &mut state, + &event_tx, + &observer_control_tx, + &keys, + "ws://test.invalid", + "agent-pubkey", + None, + Duration::from_millis(40), + ) + .await; + + assert!( + drained, + "a retryable rejection that later succeeded must not poison shutdown" + ); + } + + #[tokio::test] + async fn graceful_shutdown_drain_reports_pre_barrier_rejection() { + let (mut client, _server) = test_ws_pair().await; + let mut state = BgState::new(); + state.activity_rejected = 1; + state.activity_terminal_rejected = 1; + let keys = Keys::generate(); + let (event_tx, _event_rx) = mpsc::channel(1); + let (observer_control_tx, _observer_control_rx) = mpsc::channel(1); + + let drained = drain_shared_activity_before_shutdown( + &mut client, + &mut state, + &event_tx, + &observer_control_tx, + &keys, + "ws://test.invalid", + "agent-pubkey", + None, + Duration::from_millis(40), + ) + .await; + + assert!( + !drained, + "a terminal rejection processed before the FIFO barrier must remain visible" + ); + } + + #[tokio::test] + async fn graceful_shutdown_drain_waits_for_shared_activity_ack() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + let activity_id = activity.id.to_hex(); + assert!(state.park_gated_activity_frame(Box::new(activity))); + + let server_task = tokio::spawn(async move { + let frame = next_test_frame(&mut server).await; + assert_eq!(frame[0], "EVENT"); + assert_eq!(frame[1]["id"], activity_id); + server + .send(Message::Text( + json!(["OK", activity_id, true, ""]).to_string().into(), + )) + .await + .expect("ack shared activity"); + }); + let (event_tx, _event_rx) = mpsc::channel(1); + let (observer_control_tx, _observer_control_rx) = mpsc::channel(1); + + let drained = drain_shared_activity_before_shutdown( + &mut client, + &mut state, + &event_tx, + &observer_control_tx, + &keys, + "ws://test.invalid", + "agent-pubkey", + None, + Duration::from_secs(1), + ) + .await; + + assert!(drained, "accepted shared activity must drain before close"); + assert!(state.gated_activity_pending.is_empty()); + assert!(state.activity_in_flight.is_empty()); + server_task.await.expect("join relay ACK task"); + } + + #[tokio::test] + async fn graceful_shutdown_drain_reports_negative_ack() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + let activity_id = activity.id.to_hex(); + assert!(state.park_gated_activity_frame(Box::new(activity))); + + let server_task = tokio::spawn(async move { + let frame = next_test_frame(&mut server).await; + assert_eq!(frame[0], "EVENT"); + server + .send(Message::Text( + json!(["OK", activity_id, false, "restricted: denied"]) + .to_string() + .into(), + )) + .await + .expect("reject shared activity"); + }); + let (event_tx, _event_rx) = mpsc::channel(1); + let (observer_control_tx, _observer_control_rx) = mpsc::channel(1); + + let drained = drain_shared_activity_before_shutdown( + &mut client, + &mut state, + &event_tx, + &observer_control_tx, + &keys, + "ws://test.invalid", + "agent-pubkey", + None, + Duration::from_secs(1), + ) + .await; + + assert!(!drained, "negative relay OK must fail the shutdown barrier"); + assert!(state.gated_activity_pending.is_empty()); + assert!(state.activity_in_flight.is_empty()); + assert_eq!(state.activity_rejected, 1); + assert_eq!(state.activity_terminal_rejected, 1); + server_task.await.expect("join relay rejection task"); + } + + #[tokio::test] + async fn graceful_shutdown_drain_times_out_without_ack() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let keys = Keys::generate(); + let activity = make_shared_activity_frame(&keys, unix_now_secs()); + assert!(state.park_gated_activity_frame(Box::new(activity))); + + let server_task = tokio::spawn(async move { + let frame = next_test_frame(&mut server).await; + assert_eq!(frame[0], "EVENT"); + tokio::time::sleep(Duration::from_secs(1)).await; + }); + let (event_tx, _event_rx) = mpsc::channel(1); + let (observer_control_tx, _observer_control_rx) = mpsc::channel(1); + let started = tokio::time::Instant::now(); + + let drained = drain_shared_activity_before_shutdown( + &mut client, + &mut state, + &event_tx, + &observer_control_tx, + &keys, + "ws://test.invalid", + "agent-pubkey", + None, + Duration::from_millis(40), + ) + .await; + + assert!(!drained, "missing relay OK must fail the shutdown barrier"); + assert!(started.elapsed() < Duration::from_millis(500)); + assert_eq!(state.activity_in_flight.len(), 1); + server_task.abort(); + } + #[test] fn activity_ok_retires_and_negative_ok_is_visible_without_retry_storm() { let mut state = BgState::new(); 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 beffc294408..d6e498c6f90 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 @@ -25,6 +25,7 @@ "BUZZ_ACP_DISPLAY_NAME": "worker", "BUZZ_ACP_LAZY_POOL": "true", "BUZZ_ACP_MODEL": "gpt-5", + "BUZZ_ACP_RELAY_ACTIVITY": "true", "BUZZ_ACP_RELAY_OBSERVER": "true", "BUZZ_ACP_SESSION_TITLE": "worker", "GOOSE_MODE": "auto" diff --git a/crates/buzz-core/src/agent_activity.rs b/crates/buzz-core/src/agent_activity.rs index 20342cf3be4..980789049a3 100644 --- a/crates/buzz-core/src/agent_activity.rs +++ b/crates/buzz-core/src/agent_activity.rs @@ -5,7 +5,7 @@ //! a closed, channel-scoped projection suitable for current channel members. use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use thiserror::Error; use uuid::Uuid; @@ -111,16 +111,36 @@ pub struct AgentActivity { /// Closed lifecycle status. pub status: AgentActivityStatus, /// Safe tool category. Present only for tool activity. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_non_null_option", + skip_serializing_if = "Option::is_none" + )] pub tool_kind: Option, /// Bounded elapsed duration. Present only for terminal turn/tool updates. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_non_null_option", + skip_serializing_if = "Option::is_none" + )] pub duration_ms: Option, /// Per-turn token counts. Present only for completed usage updates. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_non_null_option", + skip_serializing_if = "Option::is_none" + )] pub usage: Option, } +fn deserialize_non_null_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + impl AgentActivity { fn validate(&self) -> Result<(), AgentActivityError> { if self @@ -257,19 +277,39 @@ pub enum AgentActivityToolKind { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AgentActivityUsage { /// Input tokens for this turn. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_non_null_option", + skip_serializing_if = "Option::is_none" + )] pub input_tokens: Option, /// Output tokens for this turn. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_non_null_option", + skip_serializing_if = "Option::is_none" + )] pub output_tokens: Option, /// Total tokens for this turn. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_non_null_option", + skip_serializing_if = "Option::is_none" + )] pub total_tokens: Option, /// Cache-read tokens for this turn. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_non_null_option", + skip_serializing_if = "Option::is_none" + )] pub cache_read_tokens: Option, /// Cache-write tokens for this turn. - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_non_null_option", + skip_serializing_if = "Option::is_none" + )] pub cache_write_tokens: Option, } diff --git a/crates/buzz-core/tests/agent_activity.rs b/crates/buzz-core/tests/agent_activity.rs index 671d8ba75e4..876b82e76f0 100644 --- a/crates/buzz-core/tests/agent_activity.rs +++ b/crates/buzz-core/tests/agent_activity.rs @@ -88,6 +88,23 @@ fn unknown_or_sensitive_fields_are_rejected_instead_of_ignored() { assert!(error.contains("unknown field"), "unexpected error: {error}"); } +#[test] +fn explicit_null_optional_fields_are_rejected_canonically() { + let cases = [ + r#"{"version":1,"activities":[{"activityId":"63ca9483-c457-4b24-88de-1f14fa97c499","occurredAt":"2024-08-12T08:39:49Z","activityClass":"turn","status":"started","toolKind":null}]}"#, + r#"{"version":1,"activities":[{"activityId":"63ca9483-c457-4b24-88de-1f14fa97c499","occurredAt":"2024-08-12T08:39:49Z","activityClass":"turn","status":"completed","durationMs":null}]}"#, + r#"{"version":1,"activities":[{"activityId":"63ca9483-c457-4b24-88de-1f14fa97c499","occurredAt":"2024-08-12T08:39:49Z","activityClass":"turn","status":"completed","usage":null}]}"#, + r#"{"version":1,"activities":[{"activityId":"63ca9483-c457-4b24-88de-1f14fa97c499","occurredAt":"2024-08-12T08:39:49Z","activityClass":"usage","status":"completed","usage":{"inputTokens":null,"totalTokens":1}}]}"#, + ]; + + for content in cases { + assert!( + AgentActivityFrame::parse(content).is_err(), + "explicit null must not be treated as an omitted optional field: {content}" + ); + } +} + #[test] fn invalid_class_specific_fields_fail_closed() { let mut bad_tool = turn(AgentActivityStatus::Running); diff --git a/crates/buzz-db/src/admin_moderation.rs b/crates/buzz-db/src/admin_moderation.rs index 31efaca3623..4a952d5933a 100644 --- a/crates/buzz-db/src/admin_moderation.rs +++ b/crates/buzz-db/src/admin_moderation.rs @@ -9,6 +9,8 @@ use serde::Serialize; use sqlx::{PgPool, Row as _}; use uuid::Uuid; +use buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY; + use crate::error::Result; /// Maximum rows accepted by one admin query. @@ -174,6 +176,7 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result $2 ORDER BY e.created_at DESC LIMIT 1 ) target ON TRUE @@ -181,6 +184,7 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result>, ) { @@ -322,13 +327,14 @@ mod tests { r#" INSERT INTO events ( community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at - ) VALUES ($1, $2, $3, $4, 9, '[]'::jsonb, $5, $6, $7) + ) VALUES ($1, $2, $3, $4, $5, '[]'::jsonb, $6, $7, $8) "#, ) .bind(community_id) .bind(event_id) .bind(author) .bind(Utc::now()) + .bind(kind) .bind(content) .bind(vec![3_u8; 64]) .bind(deleted_at) @@ -409,6 +415,7 @@ mod tests { report_community, &event_id, &[5_u8; 32], + 9, "reported message", Some(deleted_at), ) @@ -418,6 +425,7 @@ mod tests { other_community, &event_id, &[6_u8; 32], + 9, "wrong tenant message", None, ) @@ -485,4 +493,48 @@ mod tests { delete_report_fixture(&pool, community_id).await; } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn report_detail_never_exposes_live_only_activity_content() { + let pool = setup_pool().await; + let community_id = insert_community(&pool, "live-only-event").await; + let event_id = vec![9_u8; 32]; + insert_event( + &pool, + community_id, + &event_id, + &[5_u8; 32], + buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as i32, + "must never leave the live activity plane", + None, + ) + .await; + let report_id = insert_event_report(&pool, community_id, &event_id).await; + + let detail = get_report(&pool, report_id) + .await + .expect("query report") + .expect("report exists"); + assert!( + detail.message.is_none(), + "admin/audit detail must not disclose a physically seeded live-only row" + ); + + sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete report fixture"); + sqlx::query("DELETE FROM events WHERE community_id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete event fixture"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_id) + .execute(&pool) + .await + .expect("delete community fixture"); + } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index db150571719..744fd8b979c 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -10,8 +10,8 @@ use sqlx::{PgPool, Postgres, QueryBuilder, Row, Transaction}; use uuid::Uuid; use buzz_core::kind::{ - event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, - KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, + event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AGENT_ACTIVITY_SUMMARY, + KIND_AUTH, KIND_EVENT_REMINDER, KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; @@ -24,6 +24,26 @@ use crate::error::{DbError, Result}; /// the advertised ceiling and the enforced one cannot drift. pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000; +/// Ephemeral-only kinds that must be invisible on every historical DB surface, +/// including wildcard queries and aggregate counts, even if a legacy/corrupt +/// row exists despite ingest's no-storage invariant. +const fn historical_query_excluded_kinds() -> &'static [i32] { + &[KIND_AGENT_ACTIVITY_SUMMARY as i32] +} + +#[cfg(test)] +mod live_only_history_tests { + use super::*; + + #[test] + fn shared_agent_activity_is_always_excluded_from_history() { + assert_eq!( + historical_query_excluded_kinds(), + &[KIND_AGENT_ACTIVITY_SUMMARY as i32] + ); + } +} + /// Optional filters for [`query_events`]. #[derive(Debug, Clone)] pub struct EventQuery { @@ -397,6 +417,13 @@ pub(crate) async fn query_events_on( // Use unqualified column names when no join, qualified when joined. let col_prefix = if q.p_tag_hex.is_some() { "e." } else { "" }; + qb.push(format!(" AND {col_prefix}kind NOT IN (")); + let mut excluded = qb.separated(", "); + for kind in historical_query_excluded_kinds() { + excluded.push_bind(*kind); + } + qb.push(")"); + if let Some(ch) = q.channel_id { qb.push(format!(" AND {col_prefix}channel_id = ")) .push_bind(ch); @@ -577,6 +604,13 @@ pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result = row.try_get("pubkey")?; let created_at: DateTime = row.try_get("created_at")?; let kind_i32: i32 = row.try_get("kind")?; + if historical_query_excluded_kinds().contains(&kind_i32) { + tracing::warn!( + kind = kind_i32, + "suppressed live-only event found in historical storage" + ); + return Ok(None); + } let tags_json: serde_json::Value = row.try_get("tags")?; let content: String = row.try_get("content")?; let sig_bytes: Vec = row.try_get("sig")?; @@ -663,6 +697,13 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer let col_prefix = if q.p_tag_hex.is_some() { "e." } else { "" }; + qb.push(format!(" AND {col_prefix}kind NOT IN (")); + let mut excluded = qb.separated(", "); + for kind in historical_query_excluded_kinds() { + excluded.push_bind(*kind); + } + qb.push(")"); + if let Some(ch) = q.channel_id { qb.push(format!(" AND {col_prefix}channel_id = ")) .push_bind(ch); @@ -905,11 +946,12 @@ pub async fn get_last_message_at( ) -> Result>> { let row = sqlx::query( "SELECT created_at FROM events \ - WHERE community_id = $1 AND channel_id = $2 AND deleted_at IS NULL \ + WHERE community_id = $1 AND channel_id = $2 AND kind <> $3 AND deleted_at IS NULL \ ORDER BY created_at DESC LIMIT 1", ) .bind(community_id.as_uuid()) .bind(channel_id) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) .fetch_optional(pool) .await?; @@ -937,7 +979,9 @@ pub async fn get_last_message_at_bulk( WHERE community_id = ", ); qb.push_bind(community_id.as_uuid()); - qb.push(" AND deleted_at IS NULL AND channel_id IN ("); + qb.push(" AND deleted_at IS NULL AND kind <> "); + qb.push_bind(KIND_AGENT_ACTIVITY_SUMMARY as i32); + qb.push(" AND channel_id IN ("); let mut sep = qb.separated(", "); for id in channel_ids { sep.push_bind(*id); @@ -967,10 +1011,12 @@ pub async fn get_event_by_id( ) -> Result> { let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", + FROM events WHERE community_id = $1 AND id = $2 AND kind <> $3 \ + AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", ) .bind(community_id.as_uuid()) .bind(id_bytes) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) .fetch_optional(pool) .await?; @@ -1023,10 +1069,12 @@ pub async fn get_event_by_id_including_deleted( ) -> Result> { let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE community_id = $1 AND id = $2 ORDER BY created_at DESC LIMIT 1", + FROM events WHERE community_id = $1 AND id = $2 AND kind <> $3 \ + ORDER BY created_at DESC LIMIT 1", ) .bind(community_id.as_uuid()) .bind(id_bytes) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) .fetch_optional(pool) .await?; @@ -1070,7 +1118,9 @@ pub(crate) async fn get_events_by_ids_on( FROM events WHERE community_id = ", ); qb.push_bind(community_id.as_uuid()); - qb.push(" AND deleted_at IS NULL AND id IN ("); + qb.push(" AND deleted_at IS NULL AND kind <> "); + qb.push_bind(KIND_AGENT_ACTIVITY_SUMMARY as i32); + qb.push(" AND id IN ("); let mut sep = qb.separated(", "); for id in ids { sep.push_bind(id.to_vec()); @@ -1322,11 +1372,12 @@ pub async fn insert_reaction_event_with_thread_metadata( let target_row = sqlx::query( "SELECT created_at FROM events \ - WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ + WHERE community_id = $1 AND id = $2 AND kind <> $3 AND deleted_at IS NULL \ ORDER BY created_at DESC LIMIT 1", ) .bind(community_id.as_uuid()) .bind(target_event_id) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) .fetch_optional(&mut *tx) .await?; @@ -1817,6 +1868,168 @@ mod tests { assert_eq!(stored as usize, N); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn legacy_shared_activity_rows_are_invisible_to_all_history_surfaces() { + let pool = setup_pool().await; + let community_uuid = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_uuid); + let channel_id = make_test_channel(&pool, community_uuid, None).await; + let keys = Keys::generate(); + let control = EventBuilder::new(Kind::Custom(9), "visible history control") + .custom_created_at(nostr::Timestamp::from(1_800_000_000)) + .sign_with_keys(&keys) + .expect("sign visible control row"); + insert_event(&pool, community, &control, Some(channel_id)) + .await + .expect("insert visible control row"); + let activity = EventBuilder::new( + Kind::Custom(KIND_AGENT_ACTIVITY_SUMMARY as u16), + r#"{"v":1,"activity_id":"00000000-0000-4000-8000-000000000001","occurred_at":1800000100,"class":"turn","status":"active"}"#, + ) + .custom_created_at(nostr::Timestamp::from(1_800_000_100)) + .sign_with_keys(&keys) + .expect("sign legacy activity row"); + + // Bypass Rust ingest deliberately to model a legacy/corrupt row that + // predates the no-storage invariant. Historical reads, metadata, and + // history-derived mutations must all behave as though it never existed. + sqlx::query( + "INSERT INTO events \ + (community_id,id,pubkey,created_at,kind,tags,content,sig,received_at,channel_id) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,now(),$9)", + ) + .bind(community_uuid) + .bind(activity.id.as_bytes().as_slice()) + .bind(activity.pubkey.as_bytes().as_slice()) + .bind(DateTime::from_timestamp(activity.created_at.as_secs() as i64, 0).unwrap()) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) + .bind(serde_json::to_value(&activity.tags).unwrap()) + .bind(&activity.content) + .bind(activity.sig.serialize().as_slice()) + .bind(channel_id) + .execute(&pool) + .await + .expect("insert legacy shared activity directly"); + + let physical_rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id = $1 AND id = $2 AND kind = $3", + ) + .bind(community_uuid) + .bind(activity.id.as_bytes().as_slice()) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) + .fetch_one(&pool) + .await + .expect("count physical legacy row"); + assert_eq!(physical_rows, 1, "fixture must exist physically"); + + assert!( + get_event_by_id(&pool, community, activity.id.as_bytes()) + .await + .expect("direct ID historical lookup") + .is_none(), + "live-only activity must be hidden from direct ID lookup" + ); + assert!( + get_event_by_id_including_deleted(&pool, community, activity.id.as_bytes()) + .await + .expect("including-deleted historical lookup") + .is_none(), + "live-only activity must be hidden from audit lookup" + ); + assert!( + get_events_by_ids(&pool, community, &[activity.id.as_bytes()]) + .await + .expect("batch ID historical lookup") + .is_empty(), + "live-only activity must be hidden from batch ID lookup" + ); + + let wildcard = EventQuery::for_community(community); + let visible = query_events(&pool, &wildcard) + .await + .expect("wildcard historical query"); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].event.id, control.id); + assert_eq!( + count_events(&pool, &wildcard) + .await + .expect("wildcard historical count"), + 1 + ); + + let direct = EventQuery { + kinds: Some(vec![KIND_AGENT_ACTIVITY_SUMMARY as i32]), + ids: Some(vec![activity.id.as_bytes().to_vec()]), + ..EventQuery::for_community(community) + }; + assert!(query_events(&pool, &direct) + .await + .expect("direct activity historical query") + .is_empty()); + assert_eq!( + count_events(&pool, &direct) + .await + .expect("direct activity historical count"), + 0 + ); + + let control_time = DateTime::from_timestamp(control.created_at.as_secs() as i64, 0) + .expect("control timestamp"); + assert_eq!( + get_last_message_at(&pool, community, channel_id) + .await + .expect("single channel last-message metadata"), + Some(control_time), + "live-only activity must not advance channel metadata" + ); + assert_eq!( + get_last_message_at_bulk(&pool, community, &[channel_id]) + .await + .expect("bulk channel last-message metadata") + .get(&channel_id), + Some(&control_time), + "live-only activity must not advance bulk channel metadata" + ); + + let reaction = EventBuilder::new(Kind::Custom(7), "+") + .custom_created_at(nostr::Timestamp::from(1_800_000_200)) + .sign_with_keys(&Keys::generate()) + .expect("sign reaction probe"); + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &reaction, + Some(channel_id), + None, + activity.id.as_bytes(), + reaction.pubkey.as_bytes(), + "+", + ) + .await + .expect("probe reaction target lookup"); + assert!( + matches!(outcome, ReactionEventInsertOutcome::TargetMissing), + "live-only activity must not be usable as a historical reaction target" + ); + + sqlx::query("DELETE FROM events WHERE community_id = $1") + .bind(community_uuid) + .execute(&pool) + .await + .expect("delete event fixtures"); + sqlx::query("DELETE FROM channels WHERE community_id = $1") + .bind(community_uuid) + .execute(&pool) + .await + .expect("delete channel fixture"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community_uuid) + .execute(&pool) + .await + .expect("delete test community"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn get_event_by_id_is_scoped_when_event_id_collides_across_communities() { diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 007677e2581..b67bd9530b7 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -4,7 +4,7 @@ //! nested threads. The `thread_metadata` table is populated when events are //! ingested and updated as replies arrive or are deleted. -use buzz_core::StoredEvent; +use buzz_core::{kind::KIND_AGENT_ACTIVITY_SUMMARY, StoredEvent}; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row}; use uuid::Uuid; @@ -394,7 +394,7 @@ pub(crate) async fn get_thread_replies_on( // Build the query dynamically based on optional filters. // Track the next positional parameter index. - let mut param_idx = 3u32; // $1 is community_id, $2 is root_event_id + let mut param_idx = 4u32; // $1 community, $2 root, $3 live-only exclusion let mut sql = String::from( r#" SELECT @@ -421,6 +421,7 @@ pub(crate) async fn get_thread_replies_on( WHERE tm.community_id = $1 AND tm.root_event_id = $2 AND e.deleted_at IS NULL + AND e.kind <> $3 "#, ); @@ -453,7 +454,8 @@ pub(crate) async fn get_thread_replies_on( let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(community_id.as_uuid()) - .bind(root_event_id); + .bind(root_event_id) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32); if let Some(dl) = depth_limit { q = q.bind(dl as i32); @@ -517,14 +519,20 @@ pub async fn get_thread_summary( ) -> Result> { let row = sqlx::query( r#" - SELECT reply_count, descendant_count, last_reply_at - FROM thread_metadata - WHERE community_id = $1 AND event_id = $2 + SELECT tm.reply_count, tm.descendant_count, tm.last_reply_at + FROM thread_metadata tm + JOIN events e + ON e.community_id = tm.community_id + AND e.created_at = tm.event_created_at + AND e.id = tm.event_id + WHERE tm.community_id = $1 AND tm.event_id = $2 + AND e.kind <> $3 LIMIT 1 "#, ) .bind(community_id.as_uuid()) .bind(event_id) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) .fetch_optional(pool) .await?; @@ -550,6 +558,7 @@ pub async fn get_thread_summary( WHERE tm.community_id = $1 AND tm.root_event_id = $2 AND e.deleted_at IS NULL + AND e.kind <> $3 GROUP BY e.pubkey ) sub ORDER BY last_seen DESC @@ -558,6 +567,7 @@ pub async fn get_thread_summary( ) .bind(community_id.as_uuid()) .bind(event_id) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) .fetch_all(pool) .await?; @@ -619,7 +629,7 @@ pub(crate) async fn get_channel_window_on( cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, ) -> Result { - let mut param_idx = 3u32; // $1 is community_id, $2 is channel_id + let mut param_idx = 4u32; // $1 community, $2 channel, $3 live-only exclusion let mut sql = String::from( r#" SELECT @@ -643,6 +653,7 @@ pub(crate) async fn get_channel_window_on( WHERE e.community_id = $1 AND e.channel_id = $2 AND e.deleted_at IS NULL + AND e.kind <> $3 AND ( tm.depth IS NULL OR tm.depth = 0 @@ -679,7 +690,8 @@ pub(crate) async fn get_channel_window_on( let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(community_id.as_uuid()) - .bind(channel_id); + .bind(channel_id) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32); if let Some((ts, id)) = &cursor { q = q.bind(*ts).bind(id.clone()); } @@ -763,6 +775,7 @@ pub(crate) async fn get_channel_window_on( WHERE tm.community_id = $1 AND tm.root_event_id = ANY($2) AND e.deleted_at IS NULL + AND e.kind <> $3 GROUP BY tm.root_event_id, e.pubkey ) sub WHERE rn <= 10 @@ -771,6 +784,7 @@ pub(crate) async fn get_channel_window_on( ) .bind(community_id.as_uuid()) .bind(&roots) + .bind(KIND_AGENT_ACTIVITY_SUMMARY as i32) .fetch_all(&mut *conn) .await?; @@ -1667,6 +1681,89 @@ mod tests { assert_eq!(unique, expected_sorted, "paged set != inserted tied set"); } + /// A physically present live-only activity row must not consume a channel + /// window slot or influence `has_more`/cursor metadata. This models legacy + /// or out-of-band corruption by bypassing the normal no-storage ingest path. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_window_excludes_live_only_activity_before_pagination() { + let pool = setup_pool().await; + let author = Keys::generate(); + let (channel, community) = create_test_channel( + &pool, + &format!("window-live-only-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + + let control = EventBuilder::new(Kind::Custom(9), "visible control") + .custom_created_at(nostr::Timestamp::from(1_800_000_000)) + .sign_with_keys(&author) + .expect("sign control"); + insert_root(&pool, community, channel.id, &control).await; + + let activity = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as u16), + r#"{"v":1,"activity_id":"00000000-0000-4000-8000-000000000002","occurred_at":1800000100,"class":"turn","status":"active"}"#, + ) + .custom_created_at(nostr::Timestamp::from(1_800_000_100)) + .sign_with_keys(&author) + .expect("sign activity fixture"); + let activity_at = event_created_at(&activity); + sqlx::query( + "INSERT INTO events \ + (community_id,id,pubkey,created_at,kind,tags,content,sig,received_at,channel_id) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,now(),$9)", + ) + .bind(community.as_uuid()) + .bind(activity.id.as_bytes().as_slice()) + .bind(activity.pubkey.as_bytes().as_slice()) + .bind(activity_at) + .bind(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as i32) + .bind(serde_json::to_value(&activity.tags).expect("serialize tags")) + .bind(&activity.content) + .bind(activity.sig.serialize().as_slice()) + .bind(channel.id) + .execute(&pool) + .await + .expect("seed physical live-only row"); + sqlx::query( + "INSERT INTO thread_metadata \ + (community_id,event_created_at,event_id,channel_id,depth,broadcast) \ + VALUES ($1,$2,$3,$4,0,true)", + ) + .bind(community.as_uuid()) + .bind(activity_at) + .bind(activity.id.as_bytes().as_slice()) + .bind(channel.id) + .execute(&pool) + .await + .expect("seed live-only metadata row"); + + let window = get_channel_window(&pool, community, channel.id, 1, None, None) + .await + .expect("fetch live-only-safe window"); + assert_eq!(window.rows.len(), 1, "control row must fill the page"); + assert_eq!(window.rows[0].stored_event.event.id, control.id); + assert!( + !window.has_more, + "hidden live-only rows are not pagination evidence" + ); + assert!(window.next_cursor.is_none()); + assert!( + get_thread_summary(&pool, community, activity.id.as_bytes()) + .await + .expect("live-only thread summary lookup") + .is_none(), + "live-only rows must not expose thread metadata" + ); + } + /// The exact-multiple final page: when the channel's row count is an /// exact multiple of the page limit, the last full page must report /// `has_more = false` (from the limit+1 probe) even though it contains diff --git a/crates/buzz-db/src/usage.rs b/crates/buzz-db/src/usage.rs index f009dc6e056..76484a2ccbe 100644 --- a/crates/buzz-db/src/usage.rs +++ b/crates/buzz-db/src/usage.rs @@ -13,6 +13,7 @@ //! to Prometheus labels and calls `metrics::gauge!(...).set(...)`. use crate::error::Result; +use buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY; use sqlx::PgPool; use uuid::Uuid; @@ -275,8 +276,10 @@ pub async fn active_user_counts( ON u.community_id = e.community_id AND u.pubkey = e.pubkey WHERE e.created_at >= NOW() - INTERVAL '{interval_sql}' AND e.deleted_at IS NULL + AND e.kind <> {live_only_kind} GROUP BY e.community_id - "# + "#, + live_only_kind = KIND_AGENT_ACTIVITY_SUMMARY ); let rows = sqlx::query_as::<_, (Uuid, i64, i64, i64)>(sqlx::AssertSqlSafe(sql)) .fetch_all(pool) @@ -364,7 +367,10 @@ mod tests { const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; async fn get_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) .await .expect("connect to test DB") } @@ -667,6 +673,36 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_active_user_counts_excludes_live_only_activity_publishers() { + let pool = get_pool().await; + let (comm_uuid, _, _) = make_community(&pool).await; + let agent_pk = random_pubkey(); + insert_user(&pool, comm_uuid, &agent_pk, true).await; + sqlx::query( + "INSERT INTO events \ + (community_id,id,pubkey,created_at,kind,tags,content,sig,received_at) \ + VALUES ($1,$2,$3,NOW(),$4,'[]','', $5,NOW())", + ) + .bind(comm_uuid) + .bind(random_pubkey()) + .bind(&agent_pk) + .bind(buzz_core::kind::KIND_AGENT_ACTIVITY_SUMMARY as i32) + .bind(vec![0_u8; 64]) + .execute(&pool) + .await + .expect("seed live-only activity row"); + + let counts = active_user_counts(&pool, "1 day") + .await + .expect("active_user_counts"); + assert!( + counts.iter().all(|row| row.community_id != comm_uuid), + "live-only activity must not create historical active-user metadata" + ); + } + /// Regression: channel_counts returns no row for a community once all /// channels of a type are soft-deleted. The poller zero-fills from /// host_map, so absence from this query is the correct "zero" signal. diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 4055f6877c1..1913f95e612 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1184,6 +1184,10 @@ fn agent_activity_publish_result(published: Result) -> Result<(), &'s .map_err(|_| "error: temporary agent activity fan-out failure") } +fn agent_activity_serving_state_allowed(state: Result) -> bool { + matches!(state, Ok(true)) +} + /// Re-authorize a kind-24201 producer at the final delivery seam shared by /// relay-local and Redis fan-out. All lookups are fresh and fail closed. async fn authorize_agent_activity_delivery( @@ -1191,6 +1195,15 @@ async fn authorize_agent_activity_delivery( community_id: CommunityId, stored_event: &StoredEvent, ) -> Option<(uuid::Uuid, String, String)> { + if !agent_activity_serving_state_allowed( + buzz_deletion::store(&state.db) + .is_serving_active(community_id) + .await, + ) { + warn!("agent activity delivery rejected by community lifecycle fence"); + return None; + } + let routed_channel_id = stored_event.channel_id; let event = stored_event.event.clone(); let now = chrono::Utc::now().timestamp(); @@ -1279,6 +1292,31 @@ async fn handle_agent_activity_event( } let community_id = conn.tenant.community(); + match buzz_deletion::store(&state.db) + .is_serving_active(community_id) + .await + { + Ok(true) => {} + Ok(false) => { + reject("restricted"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: community writes are fenced", + )); + return; + } + Err(error) => { + reject("error"); + warn!(conn_id = %conn_id, "agent activity lifecycle lookup failed: {error}"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal server error", + )); + return; + } + } let agent_bytes = event.pubkey.to_bytes().to_vec(); let (policy, member, channel) = tokio::join!( state @@ -1891,6 +1929,86 @@ mod tests { assert_eq!(super::agent_activity_publish_result::<()>(Ok(1)), Ok(())); } + #[test] + fn agent_activity_serving_state_fails_closed() { + assert!(super::agent_activity_serving_state_allowed::<&str>(Ok( + true + ))); + assert!(!super::agent_activity_serving_state_allowed::<&str>(Ok( + false + ))); + assert!(!super::agent_activity_serving_state_allowed(Err( + "database unavailable" + ))); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn agent_activity_serving_fence_tracks_real_community_lifecycle() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:***@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test DB"); + let store = buzz_deletion::store(&db); + let host = format!("activity-fence-{}.example", Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + + assert!(super::agent_activity_serving_state_allowed( + store.is_serving_active(community).await + )); + let mut tx = pool.begin().await.expect("begin lifecycle transition"); + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true)") + .bind(community.as_uuid().to_string()) + .execute(&mut *tx) + .await + .expect("set executor community"); + sqlx::query("SELECT set_config('buzz.deletion_fence_generation', '0', true)") + .execute(&mut *tx) + .await + .expect("set executor generation"); + sqlx::query( + "UPDATE communities SET deletion_state = 'quiescing', archived_at = now() WHERE id = $1", + ) + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("quiesce test community"); + tx.commit().await.expect("commit lifecycle transition"); + assert!(!super::agent_activity_serving_state_allowed( + store.is_serving_active(community).await + )); + + let mut tx = pool.begin().await.expect("begin lifecycle cleanup"); + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true)") + .bind(community.as_uuid().to_string()) + .execute(&mut *tx) + .await + .expect("set cleanup community"); + sqlx::query("SELECT set_config('buzz.deletion_fence_generation', '0', true)") + .execute(&mut *tx) + .await + .expect("set cleanup generation"); + sqlx::query( + "UPDATE communities SET deletion_state = 'active', archived_at = NULL WHERE id = $1", + ) + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("restore test community"); + tx.commit().await.expect("commit lifecycle cleanup"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .expect("delete test community"); + } + #[test] fn agent_activity_timestamp_accepts_exact_five_minute_boundary_only() { let now = 10_000_i64; diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index bd95e8cdbbc..e00cded4d9d 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -11,7 +11,7 @@ use sqlx::{PgPool, QueryBuilder, Row}; use uuid::Uuid; -use buzz_core::CommunityId; +use buzz_core::{kind::KIND_AGENT_ACTIVITY_SUMMARY, CommunityId}; use buzz_datastore_tracing::datastore_span; use crate::error::SearchError; @@ -207,6 +207,7 @@ fn normalized_search_text(q: &str) -> Option { /// AS query /// WHERE community_id = $ctx /// AND deleted_at IS NULL +/// AND kind <> 24201 /// AND search_tsv @@ query /// [+ channel scope, kinds, authors, since, until] /// ORDER BY rank DESC, created_at DESC, id @@ -250,7 +251,9 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result "); + qb.push_bind(KIND_AGENT_ACTIVITY_SUMMARY as i32); + qb.push(" AND search_tsv @@ search_query.query"); // Channel scope — see `ChannelScope` doc for the four-case mapping. The // emitted SQL fragments are identical to the legacy 2x2 tuple for the diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index e7c196ee3e8..b178c07cd34 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -8,8 +8,8 @@ use buzz_core::{ kind::{ - AUTHOR_ONLY_KINDS, KIND_AGENT_TURN_METRIC, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, P_GATED_KINDS, + AUTHOR_ONLY_KINDS, KIND_AGENT_ACTIVITY_SUMMARY, KIND_AGENT_TURN_METRIC, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, P_GATED_KINDS, }, CommunityId, }; @@ -1315,6 +1315,75 @@ async fn excluded_kinds_are_storage_level_unsearchable() { teardown(pool, &schema).await; } +/// Populated installations may retain the pre-allowlist generated-column +/// expression until scheduled maintenance. Even when such a legacy expression +/// indexes an accidentally persisted activity row, the query itself must keep +/// the live-only kind out of search results. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn live_only_activity_is_query_excluded_on_legacy_search_schema() { + let (pool, schema) = setup().await; + pool.execute("ALTER TABLE events DROP COLUMN search_tsv") + .await + .expect("drop fresh-install search column"); + pool.execute( + "ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS \ + (to_tsvector('simple', content)) STORED", + ) + .await + .expect("model populated legacy search expression"); + pool.execute("CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv)") + .await + .expect("recreate search index"); + + let community = mk_community(&pool, "live-only-search.example").await; + let token = "liveonly_search_oracle_marker"; + let control_id = rand_bytes32(); + insert_event( + &pool, + community, + control_id, + rand_bytes32(), + 9, + token, + None, + 1_700_000_000, + ) + .await; + insert_event( + &pool, + community, + rand_bytes32(), + rand_bytes32(), + KIND_AGENT_ACTIVITY_SUMMARY as i32, + token, + None, + 1_700_000_001, + ) + .await; + + let result = SearchService::new(pool.clone()) + .search(&SearchQuery { + community, + q: token.into(), + channel_scope: ChannelScope::Any, + kinds: None, + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: buzz_search::SearchMode::FullText, + }) + .await + .expect("search legacy schema"); + assert_eq!(result.hits.len(), 1); + assert_eq!(result.hits[0].event_id, control_id); + assert_eq!(result.hits[0].kind, 9); + + teardown(pool, &schema).await; +} + /// Tripwire: every Rust-side author-only kind MUST be excluded from /// `search_tsv` at the storage layer. /// diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 47ee5f92d49..ff3cda67cf2 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -73,6 +73,7 @@ pub(super) fn build_launch_block( } } policy_env.insert("BUZZ_ACP_RELAY_OBSERVER".into(), "true".into()); + policy_env.insert("BUZZ_ACP_RELAY_ACTIVITY".into(), "true".into()); policy_env.insert("BUZZ_ACP_LAZY_POOL".into(), "true".into()); policy_env.insert( "BUZZ_ACP_AGENTS".into(), @@ -277,6 +278,7 @@ mod tests { assert_eq!(launch["policy_env"]["GOOSE_MODE"], "auto"); assert_eq!(launch["policy_env"]["BUZZ_ACP_LAZY_POOL"], "true"); assert_eq!(launch["policy_env"]["BUZZ_ACP_RELAY_OBSERVER"], "true"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_RELAY_ACTIVITY"], "true"); assert_eq!( launch["policy_env"]["BUZZ_ACP_TEAM_INSTRUCTIONS"], "Coordinate" diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b1c342e9955..5c91cfcfab5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -775,6 +775,7 @@ pub fn spawn_agent_child( } command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); + command.env("BUZZ_ACP_RELAY_ACTIVITY", "true"); // ── Git credential helper for Buzz relay ────────────────────────── // diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index b578326eba3..c4e5e97d302 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -171,6 +171,11 @@ with a TypeScript lookup table or an id comparison in a component. `getAgentAccessOwnerOnly()` is true, every managed agent's access control is locked to owner-only, including provider-backed agents. A provider backend does not prove remote execution and must never create a policy carve-out. +12. **Managed-agent launch policy enables both relay planes.** Local and + provider-backed launches set `BUZZ_ACP_RELAY_OBSERVER=true` for private + owner telemetry and `BUZZ_ACP_RELAY_ACTIVITY=true` for the separate + member-safe activity publisher. Keep those switches explicit and adjacent; + neither plane may silently imply or replace the other. ## The tests that enforce this @@ -196,6 +201,8 @@ with a TypeScript lookup table or an id comparison in a component. restoration, zero-write Skip, Next save failure/retry, navigation, and successful-empty vs failed optional-model discovery. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. +- `src/testing/managedAgentActivityLaunch.contract.test.mjs` plus the provider + wire fixture pin explicit local and remote enablement of both relay planes. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. diff --git a/desktop/src/features/agents/sharedAgentActivity.test.mjs b/desktop/src/features/agents/sharedAgentActivity.test.mjs index 91854920fd9..a367d6e6898 100644 --- a/desktop/src/features/agents/sharedAgentActivity.test.mjs +++ b/desktop/src/features/agents/sharedAgentActivity.test.mjs @@ -164,6 +164,50 @@ test("enforces closed class/status/field combinations", () => { } }); +test("rejects explicit null for every optional field", () => { + for (const field of ["toolKind", "durationMs", "usage"]) { + assert.equal( + parse( + signedEvent({ + content: JSON.stringify({ + version: 1, + activities: [activity({ [field]: null })], + }), + }), + ), + null, + field, + ); + } + for (const field of [ + "inputTokens", + "outputTokens", + "totalTokens", + "cacheReadTokens", + "cacheWriteTokens", + ]) { + assert.equal( + parse( + signedEvent({ + content: JSON.stringify({ + version: 1, + activities: [ + activity({ + activityClass: "usage", + status: "completed", + toolKind: undefined, + usage: { [field]: null }, + }), + ], + }), + }), + ), + null, + field, + ); + } +}); + test("merges lifecycle updates by opaque id and keeps a bounded newest window", () => { const first = activity({ occurredAt: "2027-01-15T08:00:00Z" }); const completed = activity({ diff --git a/desktop/src/testing/managedAgentActivityLaunch.contract.test.mjs b/desktop/src/testing/managedAgentActivityLaunch.contract.test.mjs new file mode 100644 index 00000000000..d40caf1e848 --- /dev/null +++ b/desktop/src/testing/managedAgentActivityLaunch.contract.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const remoteLaunchSource = readFileSync( + new URL("../../src-tauri/src/commands/agents_deploy.rs", import.meta.url), + "utf8", +); +const localLaunchSource = readFileSync( + new URL("../../src-tauri/src/managed_agents/runtime.rs", import.meta.url), + "utf8", +); + +function occurrences(source, fragment) { + return source.split(fragment).length - 1; +} + +test("managed-agent launch paths enable the member-safe activity publisher", () => { + assert.equal( + occurrences( + remoteLaunchSource, + 'policy_env.insert("BUZZ_ACP_RELAY_ACTIVITY".into(), "true".into())', + ), + 1, + "remote launch policy must enable sanitized activity exactly once", + ); + assert.equal( + occurrences( + localLaunchSource, + 'command.env("BUZZ_ACP_RELAY_ACTIVITY", "true")', + ), + 1, + "local launch policy must enable sanitized activity exactly once", + ); +}); diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 45289ef910a..750f9d0e14b 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -486,7 +486,8 @@ spawn**, and the provider applies it mechanically. // (resolve_effective_harness_descriptor) "policy_env": {str: str}, // overridable behavior defaults (tier 1, below): // runtime default_env (e.g. GOOSE_MODE=auto), - // BUZZ_ACP_RELAY_OBSERVER, BUZZ_ACP_LAZY_POOL=true, + // BUZZ_ACP_RELAY_OBSERVER, BUZZ_ACP_RELAY_ACTIVITY, + // BUZZ_ACP_LAZY_POOL=true, // BUZZ_ACP_SESSION_TITLE (resolved), // BUZZ_ACP_TEAM_INSTRUCTIONS, BUZZ_ACP_MODEL, // MCP_HOOK_SERVERS=* (mcp_hooks runtimes only) @@ -1606,7 +1607,8 @@ Desktop- and harness-side, discovered during this design: `agent_args` serialize as blank/empty — a different command line than the identical local agent; (c) no `owner_pubkey` — a null-`auth_tag` agent cannot match `!shutdown` (it *answers* it), stranding §Stop; (d) spawn - policy (`BUZZ_ACP_RELAY_OBSERVER`, runtime `default_env` such as + policy (`BUZZ_ACP_RELAY_OBSERVER`, `BUZZ_ACP_RELAY_ACTIVITY`, runtime + `default_env` such as `GOOSE_MODE=auto`, team instructions, session title, lazy-pool selection) is absent — remote pods run different observer/approval semantics (`BUZZ_ACP_DEDUP`/`BUZZ_ACP_MULTIPLE_EVENT_HANDLING` are *not* on this diff --git a/mobile/test/features/channels/agent_activity/shared_activity_models_test.dart b/mobile/test/features/channels/agent_activity/shared_activity_models_test.dart index 53c7db0db5e..cff6199b761 100644 --- a/mobile/test/features/channels/agent_activity/shared_activity_models_test.dart +++ b/mobile/test/features/channels/agent_activity/shared_activity_models_test.dart @@ -117,6 +117,35 @@ void main() { } }); + test('rejects explicit null for every optional field', () { + for (final field in ['toolKind', 'durationMs', 'usage']) { + final candidate = activity()..[field] = null; + expect( + () => parseSharedActivityFrame(frame([candidate])), + throwsFormatException, + reason: field, + ); + } + for (final field in [ + 'inputTokens', + 'outputTokens', + 'totalTokens', + 'cacheReadTokens', + 'cacheWriteTokens', + ]) { + final candidate = activity( + activityClass: 'usage', + status: 'completed', + usage: {field: null}, + ); + expect( + () => parseSharedActivityFrame(frame([candidate])), + throwsFormatException, + reason: field, + ); + } + }); + test('enforces version, byte, count, duration, and usage bounds', () { expect( () => parseSharedActivityFrame(