Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4b588e0
Wake agents from verified workflow mentions
loganj Aug 27, 2026
657dc33
Dispatch workflow mentions only after verification
loganj Aug 27, 2026
b39c060
Enforce current access for workflow wakes
loganj Aug 27, 2026
61bb300
Allow safe kindless channel search
loganj Aug 27, 2026
d96446a
Recover workflow wakes across reconnects
loganj Aug 28, 2026
1001821
Harden durable workflow wake admission
loganj Aug 28, 2026
0c7b725
Repair workflow wake lifecycle boundaries
loganj Aug 28, 2026
86c2cad
Reconcile wake migrations with current foundation
loganj Aug 28, 2026
d233964
Configure lifecycle fixtures before state construction
loganj Aug 28, 2026
4d37d13
Keep lifecycle regressions in backend integration gate
loganj Aug 28, 2026
5b833a8
Create workflow fixture owner in tenant user table
loganj Aug 28, 2026
2deec77
Exercise authority admission with real Redis in lifecycle tests
loganj Aug 28, 2026
8d0cd30
Use bridge filter array in wake removal regression
loganj Aug 28, 2026
9028367
Exercise wake revocation across WebSocket read and fanout paths
loganj Aug 28, 2026
fcf8511
Distinguish unavailable wake authority from revocation
loganj Aug 28, 2026
fc24e97
Pin PostgreSQL timeout recovery and clear bridge lint
loganj Aug 28, 2026
6f66d3f
Exercise exhausted authority recovery through transport replay
loganj Aug 28, 2026
b1c8485
Handle background heartbeat in wake replay fixture
loganj Aug 28, 2026
d27fdad
Recover workflow wakes after interrupted authority bodies
loganj Aug 28, 2026
d40d36b
Keep replay-guard outages distinct from authentication denials
loganj Aug 28, 2026
fe71683
Verify captured revision revocation through deletion ingress
loganj Aug 28, 2026
35fd46f
Supply authenticated deletion scope and check fixture reads
loganj Aug 28, 2026
e6c9fec
Integrate workflow delivery with domain datastore tracing
loganj Aug 28, 2026
b47efb0
Place wake migrations after updated workflow foundation
loganj Aug 28, 2026
24922e1
Point FTS migration fixture at renumbered wake migration
loganj Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,14 @@ jobs:
--run-ignored ignored-only
env:
BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Workflow wake lifecycle PostgreSQL tests
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-relay) and test(/^workflow_sink::/)' \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: Database pressure observability PostgreSQL tests
# Explicit pool acquisition and advisory-lock metrics require real
# Postgres and are ignored by the infrastructure-free unit-test job.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ reqwest = { workspace = true }
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }

# IDs
uuid = { workspace = true }
Expand Down
36 changes: 32 additions & 4 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,7 @@ pub fn resolve_channel_filters(
) -> HashMap<Uuid, ChannelFilter> {
use buzz_core::kind::{
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_WORKFLOW_MENTION_WAKE,
};

let target_channels: Vec<Uuid> = if let Some(ref overrides) = config.channels_override {
Expand All @@ -1297,6 +1298,7 @@ pub fn resolve_channel_filters(
let kinds = config.kinds_override.clone().unwrap_or_else(|| {
vec![
KIND_STREAM_MESSAGE,
KIND_WORKFLOW_MENTION_WAKE,
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_STREAM_REMINDER,
]
Expand Down Expand Up @@ -1380,6 +1382,7 @@ pub fn resolve_dynamic_channel_filter(
) -> Option<ChannelFilter> {
use buzz_core::kind::{
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_WORKFLOW_MENTION_WAKE,
};

// In Mentions/All mode, if the operator explicitly constrained channels
Expand All @@ -1402,6 +1405,7 @@ pub fn resolve_dynamic_channel_filter(
kinds: Some(config.kinds_override.clone().unwrap_or_else(|| {
vec![
KIND_STREAM_MESSAGE,
KIND_WORKFLOW_MENTION_WAKE,
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_STREAM_REMINDER,
]
Expand Down Expand Up @@ -1549,13 +1553,37 @@ mod tests {
for ch in &channels {
let f = result.get(ch).expect("channel should be present");
assert!(f.require_mention, "mentions mode requires mention");
let kinds = f.kinds.as_ref().expect("should have kinds");
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE));
assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED));
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER));
assert_eq!(
f.kinds,
Some(vec![
buzz_core::kind::KIND_STREAM_MESSAGE,
buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE,
buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED,
buzz_core::kind::KIND_STREAM_REMINDER,
])
);
}
}

#[test]
fn test_mentions_mode_dynamic_default_kinds_include_workflow_wake() {
let config = test_config(SubscribeMode::Mentions);
let channel = Uuid::new_v4();
let filter = resolve_dynamic_channel_filter(&config, channel, &[])
.expect("dynamic channel should be subscribed");

assert!(filter.require_mention);
assert_eq!(
filter.kinds,
Some(vec![
buzz_core::kind::KIND_STREAM_MESSAGE,
buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE,
buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED,
buzz_core::kind::KIND_STREAM_REMINDER,
])
);
}

#[test]
fn test_mentions_mode_custom_kinds() {
let mut config = test_config(SubscribeMode::Mentions);
Expand Down
84 changes: 83 additions & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod queue;
mod relay;
mod setup_mode;
mod usage;
mod workflow_wake;

pub use usage::TurnUsage;

Expand Down Expand Up @@ -2017,6 +2018,12 @@ async fn tokio_main() -> Result<()> {
tracing::warn!("failed to set startup watermark: {e}");
}

let workflow_relay_pubkey = relay
.rest_client()
.relay_signing_pubkey()
.await
.map_err(|e| anyhow::anyhow!("relay signing identity error: {e}"))?;

tracing::info!("connected to relay at {}", config.relay_url);

relay
Expand Down Expand Up @@ -2108,6 +2115,7 @@ async fn tokio_main() -> Result<()> {
kinds: config.kinds_override.clone().unwrap_or_else(|| {
vec![
KIND_STREAM_MESSAGE,
buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE,
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_STREAM_REMINDER,
]
Expand Down Expand Up @@ -2631,6 +2639,79 @@ async fn tokio_main() -> Result<()> {
match buzz_event {
Some(buzz_event) => {
let kind_u32 = buzz_event.event.kind.as_u16() as u32;
if workflow_wake::requires_verified_wake(
&buzz_event.event,
workflow_relay_pubkey,
) {
continue;
}

let (buzz_event, admission_author_override) = if kind_u32
== buzz_core::kind::KIND_WORKFLOW_MENTION_WAKE
{
let Some(wake) = workflow_wake::authenticate(
&buzz_event.event,
workflow_relay_pubkey,
) else {
tracing::warn!("workflow wake authentication failed");
continue;
};
let authority = match ctx
.rest_client
.workflow_wake_authority(wake.run_id(), &wake.message_event_id())
.await
{
Ok(authority) => authority,
Err(error) if error.is_transient() => {
// HTTP-status failures exhaust bounded retries; body
// interruptions also return transient after pacing.
// Transport dedup recorded this relay-signed wake, but
// dispatch has not occurred. Re-admit it for filtered
// replay rather than losing it or bypassing verification.
if let Err(replay_error) = relay
.replay_event(
buzz_event.channel_id,
buzz_event.event.id.to_hex(),
buzz_event.event.created_at.as_secs(),
)
.await
{
tracing::warn!(
%replay_error,
"failed to arrange workflow wake authority replay"
);
}
tracing::warn!(%error, "workflow wake authority unavailable; replay queued");
continue;
}
Err(error) => {
// 403/404 and malformed authority bundles are terminal:
// replays cannot make a rejected or invalid authority safe.
tracing::warn!(%error, "workflow wake authority rejected");
continue;
}
};
let Some((message, signed_author)) = workflow_wake::verify(
&buzz_event.event,
authority,
workflow_relay_pubkey,
config.keys.public_key(),
buzz_event.channel_id,
) else {
tracing::warn!("workflow wake authority verification failed");
continue;
};
(
relay::BuzzEvent {
channel_id: buzz_event.channel_id,
event: message,
},
Some(signed_author),
)
} else {
(buzz_event, None)
};
let kind_u32 = buzz_event.event.kind.as_u16() as u32;

if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION
|| kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION
Expand Down Expand Up @@ -2868,7 +2949,8 @@ async fn tokio_main() -> Result<()> {
// explicit pubkey list on top, for external people;
// it never revokes same-owner team bots.
{
let author = buzz_event.event.pubkey.to_hex();
let author = admission_author_override
.unwrap_or_else(|| buzz_event.event.pubkey.to_hex());
// DM hardening: resolve channel type (fail-closed
// to DM) so allowlist/anyone modes cannot be
// exercised by non-owner authors inside DMs.
Expand Down
Loading
Loading