Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,14 @@ pub const KIND_JOB_RESULT: u32 = 43004;
pub const KIND_JOB_CANCEL: u32 = 43005;
/// An agent job failed with an error.
pub const KIND_JOB_ERROR: u32 = 43006;
/// An owner-signed trust decision about one of an agent's work sessions —
/// approved or rejected. Tagged with the agent (`agent`, `p`) and the session
/// (`session`). The ledger of these decisions is what earns an agent autonomy.
pub const KIND_TRUST_DECISION: u32 = 43101;
/// A portable, owner-signed work receipt summarizing an agent's trust ledger
/// (decisions, approval rate, earned level). Verifiable off-platform from the
/// event signature alone.
pub const KIND_WORK_RECEIPT: u32 = 43102;

/// Relay-signed notification: the target pubkey was added to a channel.
/// Stored globally (channel_id = None) with p-tag = target, h-tag = channel UUID.
Expand Down Expand Up @@ -731,6 +739,8 @@ pub const ALL_KINDS: &[u32] = &[
KIND_JOB_RESULT,
KIND_JOB_CANCEL,
KIND_JOB_ERROR,
KIND_TRUST_DECISION,
KIND_WORK_RECEIPT,
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_AGENT_TURN_METRIC,
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub(crate) fn bounded_kind_label(kind: u32) -> String {
30023 | 30315 | 39000..=39003 => kind.to_string(),
40002..=40100 => kind.to_string(),
41001 | 41010..=41012 => kind.to_string(),
43001..=43006 => kind.to_string(),
43001..=43006 | 43101..=43102 => kind.to_string(),
44100..=44101 => kind.to_string(),
44200 => kind.to_string(),
45001..=45003 => kind.to_string(),
Expand Down
38 changes: 24 additions & 14 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@ use buzz_core::kind::{
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,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_HUDDLE_SUMMARY, KIND_HUDDLE_TRANSCRIPT,
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_TRUST_DECISION,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, KIND_WORK_RECEIPT,
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
Expand Down Expand Up @@ -524,7 +525,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
| KIND_HUDDLE_PARTICIPANT_JOINED
| KIND_HUDDLE_PARTICIPANT_LEFT
| KIND_HUDDLE_ENDED
| KIND_HUDDLE_GUIDELINES => Ok(Scope::ChannelsWrite),
| KIND_HUDDLE_GUIDELINES
| KIND_HUDDLE_TRANSCRIPT
| KIND_HUDDLE_SUMMARY => Ok(Scope::ChannelsWrite),
// NIP-34: Git repository events
KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite),
// NIP-MP: a project is repository metadata — grouping repositories needs
Expand All @@ -542,6 +545,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_DM_OPEN | KIND_DM_ADD_MEMBER | KIND_DM_HIDE => Ok(Scope::MessagesWrite),
KIND_WORKFLOW_DEF | KIND_WORKFLOW_TRIGGER => Ok(Scope::MessagesWrite),
KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY => Ok(Scope::MessagesWrite),
// Trust ledger: owner-signed decisions about agent work + portable
// receipts. Global (no channel) like turn metrics.
KIND_TRUST_DECISION | KIND_WORK_RECEIPT => Ok(Scope::MessagesWrite),
_ => Err("restricted: unknown event kind"),
}
}
Expand Down Expand Up @@ -698,6 +704,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
// NIP-AM: agent turn metrics are owner-scoped global events.
// Channel identity is encrypted inside the payload — no `h` tag.
| KIND_AGENT_TURN_METRIC
// Trust ledger: owner-signed decisions + receipts are about an
// agent (tagged), never channel-scoped.
| KIND_TRUST_DECISION
| KIND_WORK_RECEIPT
// NIP-PL leases are author-owned, addressable global state.
| super::push_lease::KIND_PUSH_LEASE
)
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ mod site_import;
mod social;
mod team_snapshot;
mod teams;
mod time_machine;
mod trust_ledger;
mod updater;
mod window_chrome;
mod window_vibrancy;
Expand Down Expand Up @@ -137,6 +139,8 @@ pub use site_import::*;
pub use social::*;
pub use team_snapshot::*;
pub use teams::*;
pub use time_machine::*;
pub use trust_ledger::*;
pub use updater::*;
pub use window_chrome::*;
pub use window_vibrancy::*;
Expand Down
186 changes: 186 additions & 0 deletions desktop/src-tauri/src/commands/time_machine.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! Time machine — the org's history as a scrubbable, replayable timeline.
//!
//! Everything in Buzz is a signed event, so "what was happening at 3:12pm"
//! is a query, not a guess. This returns a compact, time-ordered slice of the
//! event log; the desktop reconstructs who was working, which rooms were
//! live, and what was in flight at any instant from it.

use nostr::Event;
use serde::Serialize;
use tauri::State;

use crate::app_state::AppState;
use crate::relay::query_relay;

const KIND_AGENT_TURN_METRIC: u32 = 44200;
const PREVIEW_CHARS: usize = 140;

// Grouped so each filter stays small; groups never overlap so no event is
// returned twice.
const KIND_GROUPS: &[&[u32]] = &[
&[40002, 40099],
&[43001, 43002, 43003, 43004, 43005, 43006],
&[43101, 43102],
&[KIND_AGENT_TURN_METRIC],
&[45001, 45003],
&[
46001, 46002, 46003, 46004, 46005, 46006, 46007, 46010, 46011, 46012, 46030, 46031,
],
&[48100, 48101, 48102, 48103, 48107, 48108],
];

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TimelineEvent {
pub id: String,
pub kind: u32,
/// Unix seconds.
pub at: i64,
pub author: String,
pub channel_id: Option<String>,
/// The agent this event is about (turn metrics: the author).
pub agent: Option<String>,
pub session_id: Option<String>,
/// The event this one references (`e` tag) — job results point at their
/// request, approvals at what they approve.
pub ref_id: Option<String>,
pub preview: String,
}

fn tag_value(event: &Event, name: &str) -> Option<String> {
event
.tags
.iter()
.find(|tag| tag.kind().to_string() == name)
.and_then(|tag| tag.content().map(|value| value.to_string()))
}

fn preview_of(content: &str) -> String {
let single_line = content.split_whitespace().collect::<Vec<_>>().join(" ");
if single_line.chars().count() <= PREVIEW_CHARS {
return single_line;
}
let cut: String = single_line.chars().take(PREVIEW_CHARS - 1).collect();
format!("{cut}…")
}

fn describe(event: &Event, model: Option<&str>) -> String {
match event.kind.as_u16() as u32 {
KIND_AGENT_TURN_METRIC => match model {
Some(model) => format!("Completed a turn · {model}"),
None => "Completed a turn".to_string(),
},
43101 => {
let decision = tag_value(event, "decision").unwrap_or_default();
let note = preview_of(&event.content);
if note.is_empty() {
format!("Work {decision}")
} else {
format!("Work {decision} — {note}")
}
}
43102 => "Work receipt minted".to_string(),
48100 => "Huddle started".to_string(),
48101 => "Joined the huddle".to_string(),
48102 => "Left the huddle".to_string(),
48103 => "Huddle ended".to_string(),
48108 => format!("Meeting summary — {}", preview_of(&event.content)),
_ => preview_of(&event.content),
}
}

/// A time-ordered slice of the workspace's event log between `since` and
/// `until` (unix seconds), capped per kind group by `limit`.
#[tauri::command]
pub async fn get_org_timeline(
state: State<'_, AppState>,
since: i64,
until: i64,
limit: Option<u32>,
) -> Result<Vec<TimelineEvent>, String> {
if until < since {
return Err("until must not be before since".to_string());
}
let since = since.max(0) as u64;
let until = until.max(0) as u64;
let per_group = limit.unwrap_or(400).clamp(1, 1000);

let filters: Vec<serde_json::Value> = KIND_GROUPS
.iter()
.map(|kinds| {
serde_json::json!({
"kinds": kinds,
"since": since,
"until": until,
"limit": per_group,
})
})
.collect();
let events = query_relay(&state, &filters).await?;
let owner = state.keys.lock().map_err(|e| e.to_string())?.clone();

let mut timeline: Vec<TimelineEvent> = events
.iter()
.map(|event| {
let kind = event.kind.as_u16() as u32;
let mut channel_id = tag_value(event, "h");
let mut session_id = tag_value(event, "session");
let mut model: Option<String> = None;
let agent = if kind == KIND_AGENT_TURN_METRIC {
// Metrics are encrypted to the owner; decrypt when we can so
// the timeline knows the channel and session, else keep the
// envelope facts (author + time), which are still signed.
if let Ok(payload) =
buzz_core_pkg::agent_turn_metric::decrypt_agent_turn_metric(&owner, event)
{
if channel_id.is_none() {
channel_id = payload.channel_id.clone();
}
session_id = payload.session_id.clone();
model = payload.model.clone();
}
Some(event.pubkey.to_hex())
} else {
tag_value(event, "agent")
};
TimelineEvent {
id: event.id.to_hex(),
kind,
at: event.created_at.as_secs() as i64,
author: event.pubkey.to_hex(),
channel_id,
agent,
session_id,
ref_id: tag_value(event, "e"),
preview: describe(event, model.as_deref()),
}
})
.collect();
timeline.sort_by(|a, b| a.at.cmp(&b.at).then_with(|| a.id.cmp(&b.id)));
timeline.dedup_by(|a, b| a.id == b.id);
Ok(timeline)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn preview_collapses_whitespace_and_truncates() {
assert_eq!(preview_of("hello\n\n world"), "hello world");
let long = "x".repeat(500);
let preview = preview_of(&long);
assert_eq!(preview.chars().count(), PREVIEW_CHARS);
assert!(preview.ends_with('…'));
}

#[test]
fn kind_groups_do_not_overlap() {
let mut seen = std::collections::HashSet::new();
for group in KIND_GROUPS {
for kind in *group {
assert!(seen.insert(*kind), "kind {kind} appears in two groups");
}
}
}
}
Loading
Loading