Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7d9fe9c
Consolidate kind constants, add helpers, remove dead code
tlongwell-block Mar 9, 2026
4e5d004
Wire workflow execution pipeline: on_event() → run creation → async e…
tlongwell-block Mar 9, 2026
4a5d8b7
Round 2 crossfire fixes: semaphore, emoji filter, duration overflow
tlongwell-block Mar 9, 2026
5ca53df
Fix CI: cargo fmt formatting
tlongwell-block Mar 9, 2026
db4a4cb
Address crossfire findings: semaphore backpressure, approval gates, r…
tlongwell-block Mar 9, 2026
8d1f621
Round 2 crossfire fixes: unified semaphore, consistent approval handl…
tlongwell-block Mar 9, 2026
7073131
Round 3 crossfire fixes: reaction channel security, resume Running st…
tlongwell-block Mar 10, 2026
c87b17d
Round 4 crossfire fixes: preserve trace on resume, fail-closed reacti…
tlongwell-block Mar 10, 2026
1e0d532
Preserve partial execution progress on run failures
tlongwell-block Mar 10, 2026
3be9f7c
Fix E2E test channel UUIDs to match UUID5-derived DB values
tlongwell-block Mar 10, 2026
c42a134
Crossfire round 6: partial trace preservation, reaction channel seman…
tlongwell-block Mar 10, 2026
375a3ee
Crossfire round 7: approval status guard, tag filtering alignment, DB…
tlongwell-block Mar 10, 2026
c39d452
Crossfire round 8: deny_approval WaitingApproval guard
tlongwell-block Mar 10, 2026
43ac326
Extract finalize_run, decompose on_event, remove dead code
tlongwell-block Mar 10, 2026
7478077
Merge remote-tracking branch 'origin/main' into initial_revisions
tlongwell-block Mar 10, 2026
d24c580
Address review findings: delay/timeout race, auth gap, 202 approvals,…
tlongwell-block Mar 10, 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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ Steps 10–12 are fire-and-forget: they are spawned as independent async tasks.

Step 9 (fan-out) also checks global subscriptions (no `channel_id` constraint) — broad subscriptions receive channel-scoped events if their filters match.

Workflow loop prevention: kinds 46001–46012 (workflow execution events) are excluded from triggering workflows. Exception: stream message kinds (40001, 40002) always trigger regardless of other exclusion rules.
Workflow loop prevention: kinds 46001–46012 (workflow execution events) are excluded from triggering workflows. Exception: stream message kind 40001 (`KIND_STREAM_MESSAGE`) always triggers regardless of other exclusion rules. Kind 40002 (`KIND_STREAM_MESSAGE_V2`) does not trigger workflows.

### Ephemeral Sub-Pipeline (kinds 20000–29999)

Expand Down
2 changes: 2 additions & 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ Copy `.env.example` to `.env`. All defaults work with `docker compose up` out of
| `REDIS_URL` | `redis://localhost:6379` | Redis connection string |
| `TYPESENSE_URL` | `http://localhost:8108` | Typesense base URL |
| `TYPESENSE_API_KEY` | `sprout_dev_key` | Typesense API key |
| `TYPESENSE_COLLECTION` | `events` | Typesense collection name |
| `SPROUT_BIND_ADDR` | `0.0.0.0:3000` | Relay bind address (host:port) |
| `RELAY_URL` | `ws://localhost:3000` | Public URL (used in NIP-42 challenges) |
| `SPROUT_REQUIRE_AUTH_TOKEN` | `false` | Require bearer token for auth (set `true` in production) |
Expand Down
3 changes: 2 additions & 1 deletion crates/sprout-audit/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use futures_util::FutureExt as _;
use sqlx::{Acquire, MySqlPool, Row};
use tracing::{debug, instrument, warn};

use sprout_core::kind::KIND_AUTH;

use crate::{
action::AuditAction,
entry::{AuditEntry, NewAuditEntry},
Expand All @@ -11,7 +13,6 @@ use crate::{
schema::AUDIT_SCHEMA_SQL,
};

const KIND_AUTH: u32 = 22242;
const AUDIT_LOCK_NAME: &str = "sprout_audit";
const AUDIT_LOCK_TIMEOUT_SECS: i64 = 10;

Expand Down
39 changes: 39 additions & 0 deletions crates/sprout-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub const KIND_REACTION: u32 = 7;
pub const KIND_GIFT_WRAP: u32 = 1059;
/// NIP-94: File metadata attachment.
pub const KIND_FILE_METADATA: u32 = 1063;
/// NIP-42 auth event — never stored (carries bearer tokens).
pub const KIND_AUTH: u32 = 22242;

// NIP-29 group admin events
/// NIP-29: Add a user to a group.
Expand Down Expand Up @@ -56,6 +58,11 @@ pub const KIND_NIP29_GROUP_MEMBERS: u32 = 39002;
/// NIP-29: Addressable group roles definition.
pub const KIND_NIP29_GROUP_ROLES: u32 = 39003;

/// Lower bound of the ephemeral event range (20000–29999). Never stored.
pub const EPHEMERAL_KIND_MIN: u32 = 20000;
/// Upper bound of the ephemeral event range (20000–29999). Never stored.
pub const EPHEMERAL_KIND_MAX: u32 = 29999;

// Ephemeral events (20000–29999) — Redis pub/sub only, never stored.
/// Ephemeral: user presence update (online/away/offline).
pub const KIND_PRESENCE_UPDATE: u32 = 20001;
Expand All @@ -77,6 +84,8 @@ pub const KIND_STREAM_MESSAGE_BOOKMARKED: u32 = 40005;
pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006;
/// A reminder attached to a stream message or time.
pub const KIND_STREAM_REMINDER: u32 = 40007;
/// Canvas (shared document) for a channel.
pub const KIND_CANVAS: u32 = 40100;

// Direct messages (41000–41999)
/// A new direct-message conversation was created.
Expand Down Expand Up @@ -215,6 +224,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_REMINDER,
KIND_CANVAS,
KIND_DM_CREATED,
KIND_DM_MEMBER_ADDED,
KIND_DM_MEMBER_REMOVED,
Expand Down Expand Up @@ -260,6 +270,35 @@ pub const ALL_KINDS: &[u32] = &[
KIND_HUDDLE_RECORDING_AVAILABLE,
];

/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
pub const fn is_ephemeral(kind: u32) -> bool {
kind >= EPHEMERAL_KIND_MIN && kind <= EPHEMERAL_KIND_MAX
}

/// Returns `true` if `kind` is a workflow execution event (46001–46012).
/// These must not trigger workflows (prevents infinite loops).
pub const fn is_workflow_execution_kind(kind: u32) -> bool {
kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED
}

/// Extract the kind from a nostr Event as u32.
/// NIP-01 specifies kind as an unsigned integer; u32 covers the full range.
pub fn event_kind_u32(event: &nostr::Event) -> u32 {
event.kind.as_u16() as u32
}

/// Extract the kind from a nostr Event as i32 (for MySQL INT columns).
/// Safe: all Sprout kinds fit in i32 (max 65535 < i32::MAX).
pub fn event_kind_i32(event: &nostr::Event) -> i32 {
event.kind.as_u16() as i32
}

// Compile-time: all Sprout kind constants fit in nostr's u16-backed Kind.
const _: () = assert!(KIND_AUTH <= u16::MAX as u32);
const _: () = assert!(KIND_CANVAS <= u16::MAX as u32);
const _: () = assert!(KIND_HUDDLE_RECORDING_AVAILABLE <= u16::MAX as u32);
const _: () = assert!(EPHEMERAL_KIND_MIN < EPHEMERAL_KIND_MAX);

#[cfg(test)]
mod tests {
use super::*;
Expand Down
10 changes: 3 additions & 7 deletions crates/sprout-db/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,11 @@ use nostr::Event;
use sqlx::{MySqlPool, QueryBuilder, Row};
use uuid::Uuid;

use sprout_core::kind::{event_kind_i32, is_ephemeral, KIND_AUTH};
use sprout_core::StoredEvent;

use crate::error::{DbError, Result};

/// NIP-42 auth event kind — never stored (carries bearer tokens).
const KIND_AUTH: u32 = 22242;
const EPHEMERAL_KIND_MIN: u32 = 20000;
const EPHEMERAL_KIND_MAX: u32 = 29999;

/// Optional filters for [`query_events`].
#[derive(Debug, Default, Clone)]
pub struct EventQuery {
Expand Down Expand Up @@ -51,7 +47,7 @@ pub async fn insert_event(
if kind_u32 == KIND_AUTH {
return Err(DbError::AuthEventRejected);
}
if (EPHEMERAL_KIND_MIN..=EPHEMERAL_KIND_MAX).contains(&kind_u32) {
if is_ephemeral(kind_u32) {
return Err(DbError::EphemeralEventRejected(kind_u16));
}

Expand All @@ -60,7 +56,7 @@ pub async fn insert_event(
let sig_bytes = event.sig.serialize();
let tags_json = serde_json::to_value(&event.tags)?;
// Cast chain: nostr Kind (u16) → i32 (MySQL INT column). Safe: all Sprout kinds fit in i32.
let kind_i32 = event.kind.as_u16() as i32;
let kind_i32 = event_kind_i32(event);
let created_at_secs = event.created_at.as_u64() as i64;
let created_at = DateTime::from_timestamp(created_at_secs, 0)
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
Expand Down
5 changes: 3 additions & 2 deletions crates/sprout-db/src/feed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ pub async fn query_activity(
);

// Stream messages, forum posts, agent job events.
// KIND_JOB_REQUEST = agent job created, KIND_JOB_PROGRESS = agent job completed, KIND_JOB_RESULT = agent job failed.
// KIND_JOB_REQUEST = agent job requested, KIND_JOB_PROGRESS = in-flight progress update, KIND_JOB_RESULT = completed result.
qb.push(format!(
" AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})"
));
Expand Down Expand Up @@ -407,7 +407,8 @@ mod tests {
KIND_JOB_RESULT,
];

for kind in 46001u32..=46012 {
use sprout_core::kind::{KIND_WORKFLOW_APPROVAL_DENIED, KIND_WORKFLOW_TRIGGERED};
for kind in KIND_WORKFLOW_TRIGGERED..=KIND_WORKFLOW_APPROVAL_DENIED {
assert!(
!activity_kinds.contains(&kind),
"workflow execution kind {kind} must NOT be in activity"
Expand Down
3 changes: 3 additions & 0 deletions crates/sprout-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ name = "sprout-mcp-server"
path = "src/main.rs"

[dependencies]
# Sprout core types
sprout-core = { workspace = true }

# MCP SDK
rmcp = { workspace = true }
schemars = { workspace = true }
Expand Down
4 changes: 4 additions & 0 deletions crates/sprout-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
//! [Sprout]: https://github.com/sprout-rs/sprout
//! [NIP-42]: https://github.com/nostr-protocol/nips/blob/master/42.md

// NOTE: `parse_relay_message`, `OkResponse`, and `RelayMessage` from `relay_client`
// are re-exported by `sprout-test-client`. Changes to these types are a breaking
// change for the test harness.

/// WebSocket client for the Sprout relay (NIP-42 auth, subscriptions, reconnect).
pub mod relay_client;
/// MCP tool implementations backed by the relay client.
Expand Down
14 changes: 10 additions & 4 deletions crates/sprout-mcp/src/server.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use sprout_core::kind::{event_kind_u32, KIND_CANVAS};

use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{ServerCapabilities, ServerInfo},
Expand Down Expand Up @@ -310,7 +312,7 @@ impl SproutMcpServer {
"id": event.id.to_hex(),
"pubkey": event.pubkey.to_hex(),
"content": event.content,
"kind": event.kind.as_u16() as u32,
"kind": event_kind_u32(event),
"created_at": event.created_at.as_u64(),
})
})
Expand Down Expand Up @@ -381,7 +383,7 @@ impl SproutMcpServer {
nostr::SingleLetterTag::lowercase(nostr::Alphabet::E),
[p.channel_id.as_str()],
)
.kind(nostr::Kind::Custom(40100))
.kind(nostr::Kind::Custom(KIND_CANVAS as u16))
.limit(1);

let sub_id = format!("canvas-{}", uuid::Uuid::new_v4());
Expand Down Expand Up @@ -415,8 +417,12 @@ impl SproutMcpServer {
Err(e) => return format!("Error building tag: {e}"),
};

let event = match nostr::EventBuilder::new(nostr::Kind::Custom(40100), &p.content, [e_tag])
.sign_with_keys(&keys)
let event = match nostr::EventBuilder::new(
nostr::Kind::Custom(KIND_CANVAS as u16),
&p.content,
[e_tag],
)
.sign_with_keys(&keys)
{
Ok(e) => e,
Err(e) => return format!("Error signing event: {e}"),
Expand Down
Loading