From 69beeacb3a898dc4e801414b9e2b0c339da697b2 Mon Sep 17 00:00:00 2001 From: Garfield Lawrence <3stepwin@gmail.com> Date: Fri, 21 Aug 2026 10:53:52 -0400 Subject: [PATCH 01/30] Reconstruct mission truth from signed observer evidence Constraint: Preserve Buzz mission/governance/proof architecture and the active dirty live checkout. Rejected: A second mission database or agent-authored VERIFIED state | duplicates authority and breaks proof boundaries. Confidence: high Scope-risk: moderate Directive: Do not treat turn_completed or managed-agent claims as verified outcomes. Tested: 3793 desktop tests; 68 archive tests; typecheck; Biome checks; production build; cargo check/fmt; SQLite close/reopen persistence. Not-tested: installed-app deployment; OS crash before archive commit; Honey direct query; durable owner overrides. Signed-off-by: Garfield Lawrence <3stepwin@gmail.com> --- desktop/src-tauri/src/archive/mod.rs | 39 + desktop/src-tauri/src/archive/store.rs | 86 ++ desktop/src-tauri/src/archive/store_tests.rs | 92 ++ desktop/src-tauri/src/lib.rs | 1 + .../features/agents/activityLedger.test.mjs | 532 ++++++++++ desktop/src/features/agents/activityLedger.ts | 970 ++++++++++++++++++ .../ingestArchivedObserverEvents.test.mjs | 34 +- .../src/features/agents/observerRelayStore.ts | 68 +- .../agents/ui/ManagedAgentSessionPanel.tsx | 53 + .../ui/agentSessionPanelLayout.test.mjs | 19 + .../agents/ui/agentSessionPanelLayout.ts | 13 +- .../features/agents/ui/agentSessionTypes.ts | 12 + desktop/src/shared/api/tauriArchive.ts | 89 ++ 13 files changed, 1979 insertions(+), 29 deletions(-) create mode 100644 desktop/src/features/agents/activityLedger.test.mjs create mode 100644 desktop/src/features/agents/activityLedger.ts diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 81bc2133528..afb00ffca39 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -578,6 +578,45 @@ pub async fn read_archived_observer_events_for_channel( .await } +/// Read a paginated owner-scoped observer page for a half-open time range. +#[tauri::command] +pub fn read_archived_observer_events_for_range( + state: State<'_, AppState>, + start_created_at: i64, + end_created_at: i64, + agent_pubkey: Option, + channel_id: Option, + before_created_at: Option, + before_id: Option, + limit: Option, +) -> Result, String> { + if start_created_at >= end_created_at { + return Err("archive range must have start_created_at < end_created_at".into()); + } + if before_created_at.is_some() != before_id.is_some() { + return Err("archive range cursor requires both before_created_at and before_id".into()); + } + let limit = limit.unwrap_or(DEFAULT_READ_LIMIT); + if !(1..=500).contains(&limit) { + return Err("archive range limit must be between 1 and 500".into()); + } + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::read_archived_observer_events_for_range( + &conn, + &identity_pk, + &relay_url, + start_created_at, + end_created_at, + agent_pubkey.as_deref(), + channel_id.as_deref(), + before_created_at, + before_id.as_deref(), + limit, + ) +} + // ── index_observer_channel_id ───────────────────────────────────────────────── /// Index one or more archived observer frame ids with their decoded channelId. diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs index 54cb9a4193b..e466f7b63fc 100644 --- a/desktop/src-tauri/src/archive/store.rs +++ b/desktop/src-tauri/src/archive/store.rs @@ -839,6 +839,92 @@ pub fn read_archived_observer_events_for_channel( .map_err(|e| format!("read read_archived_observer_events_for_channel row: {e}")) } +/// Read owner-scoped observer events for a half-open time range. +/// +/// The compound cursor mirrors the sort order, so same-second siblings are +/// never skipped while the owner-facing Today surface pages through SQLite. +#[allow(clippy::too_many_arguments)] +pub fn read_archived_observer_events_for_range( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + start_created_at: i64, + end_created_at: i64, + agent_pubkey: Option<&str>, + channel_id: Option<&str>, + before_created_at: Option, + before_id: Option<&str>, + limit: i64, +) -> Result, String> { + let mut params_vec: Vec> = vec![ + Box::new(identity_pubkey.to_owned()), + Box::new(relay_url.to_owned()), + Box::new(identity_pubkey.to_owned()), + Box::new(start_created_at), + Box::new(end_created_at), + ]; + let mut clauses = String::new(); + + if let Some(agent) = agent_pubkey { + params_vec.push(Box::new(agent.to_owned())); + clauses.push_str(&format!(" AND ae.pubkey = ?{}", params_vec.len())); + } + if let Some(channel) = channel_id { + params_vec.push(Box::new(channel.to_owned())); + clauses.push_str(&format!( + " AND EXISTS ( + SELECT 1 FROM observer_channel_index oci + WHERE oci.identity_pubkey = ae.identity_pubkey + AND oci.relay_url = ae.relay_url + AND oci.id = ae.id + AND oci.channel_id = ?{} + )", + params_vec.len() + )); + } + if let (Some(created_at), Some(id)) = (before_created_at, before_id) { + params_vec.push(Box::new(created_at)); + let created_at_slot = params_vec.len(); + params_vec.push(Box::new(id.to_owned())); + let id_slot = params_vec.len(); + clauses.push_str(&format!( + " AND (ae.created_at < ?{created_at_slot} + OR (ae.created_at = ?{created_at_slot} AND ae.id < ?{id_slot}))" + )); + } + params_vec.push(Box::new(limit)); + let limit_slot = params_vec.len(); + + let sql = format!( + "SELECT ae.raw_json + FROM archived_events ae + INNER JOIN archived_event_scopes aes + ON aes.identity_pubkey = ae.identity_pubkey + AND aes.relay_url = ae.relay_url + AND aes.id = ae.id + WHERE ae.identity_pubkey = ?1 + AND ae.relay_url = ?2 + AND aes.scope_type = 'owner_p' + AND aes.scope_value = ?3 + AND ae.kind = 24200 + AND ae.created_at >= ?4 + AND ae.created_at < ?5 + {clauses} + ORDER BY ae.created_at DESC, ae.id DESC + LIMIT ?{limit_slot}" + ); + let param_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(|param| param.as_ref()).collect(); + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("prepare read_archived_observer_events_for_range: {e}"))?; + let rows = stmt + .query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) + .map_err(|e| format!("query read_archived_observer_events_for_range: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("read read_archived_observer_events_for_range row: {e}")) +} + /// GC: delete orphaned event rows whose last scope row was just removed, and /// atomically cascade-delete any `agent_metric_index` rows whose canonical /// `archived_events` row no longer exists. diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs index bbd15391e75..506ae30de3a 100644 --- a/desktop/src-tauri/src/archive/store_tests.rs +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -854,3 +854,95 @@ fn test_read_unindexed_observer_rows_excludes_processed_rows() { "ev-old must be excluded from unindexed rows after null-channel_id indexing" ); } + +fn insert_owner_observer( + conn: &Connection, + id: &str, + agent: &str, + created_at: i64, + channel: Option<&str>, +) { + upsert_archived_event( + conn, + "owner", + "wss://r", + id, + 24200, + agent, + created_at, + &format!(r#"{{"id":"{id}","created_at":{created_at}}}"#), + created_at, + ) + .unwrap(); + upsert_event_scope(conn, "owner", "wss://r", id, "owner_p", "owner", created_at).unwrap(); + if let Some(channel) = channel { + upsert_observer_channel_index(conn, "owner", "wss://r", id, Some(channel), created_at) + .unwrap(); + } +} + +#[test] +fn test_read_archived_observer_events_for_range_pages_without_gaps() { + let conn = in_memory(); + insert_owner_observer(&conn, "z", "agent-a", 1002, Some("ch-1")); + insert_owner_observer(&conn, "a", "agent-a", 1002, Some("ch-1")); + insert_owner_observer(&conn, "old", "agent-b", 1001, Some("ch-2")); + insert_owner_observer(&conn, "outside", "agent-a", 999, Some("ch-1")); + + let page_one = read_archived_observer_events_for_range( + &conn, "owner", "wss://r", 1000, 1003, None, None, None, None, 1, + ) + .unwrap(); + assert_eq!(page_one.len(), 1); + assert!(page_one[0].contains(r#""id":"z""#)); + + let page_two = read_archived_observer_events_for_range( + &conn, + "owner", + "wss://r", + 1000, + 1003, + None, + None, + Some(1002), + Some("z"), + 10, + ) + .unwrap(); + assert_eq!(page_two.len(), 2); + assert!(page_two[0].contains(r#""id":"a""#)); + assert!(page_two[1].contains(r#""id":"old""#)); + + let filtered = read_archived_observer_events_for_range( + &conn, + "owner", + "wss://r", + 1000, + 1003, + Some("agent-a"), + Some("ch-1"), + None, + None, + 10, + ) + .unwrap(); + assert_eq!(filtered.len(), 2); +} + +#[test] +fn test_archived_observer_range_survives_close_and_reopen() { + use tempfile::NamedTempFile; + + let db_file = NamedTempFile::new().unwrap(); + { + let conn = open_archive_db(db_file.path()).unwrap(); + insert_owner_observer(&conn, "persisted", "agent-a", 1001, Some("ch-1")); + } + let reopened = open_archive_db(db_file.path()).unwrap(); + let rows = read_archived_observer_events_for_range( + &reopened, "owner", "wss://r", 1000, 1002, None, None, None, None, 10, + ) + .unwrap(); + assert_eq!(rows.len(), 1); + assert!(rows[0].contains(r#""id":"persisted""#)); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e1b8a9551b1..b7f4b331d29 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -839,6 +839,7 @@ pub fn run() { archive::delete_save_subscription, archive::read_archived_events, archive::read_archived_observer_events_for_channel, + archive::read_archived_observer_events_for_range, archive::index_observer_channel_id, archive::read_unindexed_observer_rows, archive::get_agent_usage_series, diff --git a/desktop/src/features/agents/activityLedger.test.mjs b/desktop/src/features/agents/activityLedger.test.mjs new file mode 100644 index 00000000000..124e4103188 --- /dev/null +++ b/desktop/src/features/agents/activityLedger.test.mjs @@ -0,0 +1,532 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyOwnerJournalOverride, + buildMissionJournal, + buildTodayActivitySurface, + normalizeActivityEvents, +} from "./activityLedger.ts"; + +const AGENT_A = "a".repeat(64); +const AGENT_B = "b".repeat(64); +const CHANNEL_A = "11111111-1111-1111-1111-111111111111"; +const CHANNEL_B = "22222222-2222-2222-2222-222222222222"; + +function observerEvent(overrides = {}) { + return { + seq: 1, + timestamp: "2026-08-21T14:00:00.000Z", + kind: "turn_started", + agentIndex: 0, + channelId: CHANNEL_A, + sessionId: "sess-1", + turnId: "turn-1", + payload: { source: "channel", triggeringEventIds: ["f".repeat(64)] }, + sourceEventId: (overrides.seq ?? 1).toString(16).padStart(64, "0"), + ...overrides, + }; +} + +function sessionUpdate(seq, sessionUpdate, update = {}, eventOverrides = {}) { + return observerEvent({ + ...eventOverrides, + seq, + sourceEventId: + eventOverrides.sourceEventId ?? seq.toString(16).padStart(64, "0"), + kind: "acp_read", + payload: { + method: "session/update", + params: { + sessionId: eventOverrides.sessionId ?? "sess-1", + update: { + sessionUpdate, + ...update, + }, + }, + }, + }); +} + +test("normalizeActivityEvents marks failed tool updates as FAILED and preserves tool correlation", () => { + const events = normalizeActivityEvents([ + observerEvent(), + sessionUpdate(2, "tool_call", { + toolCallId: "call-1", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "cargo test" }, + }), + sessionUpdate(3, "tool_call_update", { + toolCallId: "call-1", + status: "failed", + title: "shell", + kind: "shell", + rawInput: { command: "cargo test" }, + rawOutput: "boom", + }), + ]); + + const toolUpdate = events.find( + (event) => event.category === "tool" && event.status === "failed", + ); + assert.ok(toolUpdate); + assert.equal(toolUpdate.proofState, "FAILED"); + assert.equal(toolUpdate.correlationId, "call-1"); + assert.equal(toolUpdate.provenance.toolCallId, "call-1"); +}); + +test("buildMissionJournal flags turn completion without supporting evidence", () => { + const normalized = normalizeActivityEvents([ + observerEvent(), + observerEvent({ + seq: 2, + kind: "turn_completed", + payload: {}, + sourceEventId: "d".repeat(64), + }), + ]); + + const journal = buildMissionJournal(normalized); + assert.equal(journal.status, "ended_unverified"); + assert.equal(journal.proofState, "OBSERVED"); + assert.equal(journal.claimedCompletionWithoutEvidence, true); + assert.match(journal.summary, /without supporting evidence/i); +}); + +test("normalizeActivityEvents deduplicates duplicate observer frames", () => { + const duplicate = sessionUpdate(2, "tool_call", { + toolCallId: "call-dup", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "Cargo.toml" }, + }); + const events = normalizeActivityEvents([duplicate, duplicate]); + assert.equal(events.length, 1); +}); + +test("signed provenance keeps same seq and timestamp with different ids distinct", () => { + const events = normalizeActivityEvents([ + observerEvent({ sourceEventId: "1".repeat(64) }), + observerEvent({ sourceEventId: "2".repeat(64) }), + ]); + assert.equal(events.length, 2); +}); + +test("completed tool output is RECEIPTED but never implicitly VERIFIED", () => { + const events = normalizeActivityEvents([ + sessionUpdate(2, "tool_call_update", { + toolCallId: "call-receipt", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "ok", + }), + ]); + assert.equal(events[0].proofState, "RECEIPTED"); +}); + +test("assistant done claim does not verify an unconditional turn end", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent(), + sessionUpdate(2, "agent_message_chunk", { + messageId: "message-1", + content: [{ type: "text", text: "Done." }], + }), + observerEvent({ + seq: 3, + kind: "turn_completed", + payload: {}, + sourceEventId: "3".repeat(64), + }), + ]), + ); + assert.equal(journal.status, "ended_unverified"); + assert.equal(journal.proofState, "OBSERVED"); + assert.equal(journal.claimedCompletionWithoutEvidence, true); +}); + +test("agent-authored verifier fields cannot mint VERIFIED", () => { + const events = normalizeActivityEvents([ + observerEvent({ + kind: "proof_verified", + payload: { + verified: true, + verifierPubkey: "9".repeat(64), + receiptRef: "receipt:independent-1", + }, + }), + ]); + assert.equal(events[0].proofState, "CLAIMED"); +}); + +test("agent-authored journal override cannot rewrite the observed summary", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent(), + observerEvent({ + seq: 2, + kind: "journal_override", + payload: { + summary: "Owner says complete", + modifiedBy: "owner", + }, + }), + ]), + ); + assert.equal(journal.summarySource, "auto"); + assert.equal(journal.ownerModifiedBy, null); + assert.notEqual(journal.summary, "Owner says complete"); +}); + +test("stale started turn is incomplete and UNKNOWN", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([observerEvent()]), + { asOf: "2026-08-21T14:10:00.000Z", incompleteAfterMs: 60_000 }, + ); + assert.equal(journal.status, "incomplete"); + assert.equal(journal.proofState, "UNKNOWN"); +}); + +test("recent liveness keeps a long-running turn in progress", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent(), + observerEvent({ + seq: 2, + timestamp: "2026-08-21T14:09:00.000Z", + kind: "turn_liveness", + payload: {}, + }), + ]), + { asOf: "2026-08-21T14:10:00.000Z", incompleteAfterMs: 5 * 60_000 }, + ); + assert.equal(journal.status, "in_progress"); + assert.equal(journal.endedAt, "2026-08-21T14:09:00.000Z"); +}); + +test("real managed-agent runtime failure shape fails the journal", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent({ + kind: "managed_agent_runtime_lifecycle", + payload: { + pubkey: AGENT_A, + relayUrl: "wss://relay.example", + startNonce: "start-1", + lifecycle: "failed", + error: "pool wake task failed", + }, + }), + ]), + ); + assert.equal(journal.status, "failed"); + assert.equal(journal.proofState, "FAILED"); + assert.match(journal.summary, /pool wake task failed/); +}); + +test("a later runtime ready event clears an earlier runtime failure", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent({ + kind: "managed_agent_runtime_lifecycle", + payload: { + pubkey: AGENT_A, + startNonce: "start-1", + lifecycle: "failed", + error: "pool wake task failed", + }, + }), + observerEvent({ + seq: 2, + timestamp: "2026-08-21T14:01:00.000Z", + kind: "managed_agent_runtime_lifecycle", + payload: { + pubkey: AGENT_A, + startNonce: "start-2", + lifecycle: "ready", + }, + sourceEventId: "8".repeat(64), + }), + ]), + ); + assert.equal(journal.status, "observed"); + assert.equal(journal.proofState, "OBSERVED"); + assert.doesNotMatch(journal.summary, /pool wake task failed/); +}); + +test("the real listening to waking to ready lifecycle does not stay in progress", () => { + const events = ["listening", "waking", "ready"].map((lifecycle, index) => + observerEvent({ + seq: index + 1, + timestamp: `2026-08-21T14:0${index}:00.000Z`, + kind: "managed_agent_runtime_lifecycle", + payload: { lifecycle, startNonce: "start-1" }, + sourceEventId: `${index + 1}`.repeat(64), + }), + ); + const journal = buildMissionJournal(normalizeActivityEvents(events)); + assert.equal(journal.status, "observed"); + assert.equal(journal.proofState, "OBSERVED"); +}); + +test("a repeated runtime failure reports the latest failure reason", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent({ + kind: "managed_agent_runtime_lifecycle", + payload: { lifecycle: "failed", error: "old failure" }, + }), + observerEvent({ + seq: 2, + timestamp: "2026-08-21T14:01:00.000Z", + kind: "managed_agent_runtime_lifecycle", + payload: { lifecycle: "ready" }, + sourceEventId: "8".repeat(64), + }), + observerEvent({ + seq: 3, + timestamp: "2026-08-21T14:02:00.000Z", + kind: "managed_agent_runtime_lifecycle", + payload: { lifecycle: "failed", error: "new failure" }, + sourceEventId: "9".repeat(64), + }), + ]), + ); + assert.equal(journal.status, "failed"); + assert.equal(journal.proofState, "FAILED"); + assert.match(journal.summary, /new failure/); + assert.doesNotMatch(journal.summary, /old failure/); +}); + +test("successful retry prevents an earlier failed tool from failing the journal", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent(), + sessionUpdate(2, "tool_call_update", { + toolCallId: "call-retry", + status: "failed", + title: "shell", + rawOutput: "boom", + }), + sessionUpdate(3, "tool_call_update", { + toolCallId: "call-retry", + status: "completed", + title: "shell", + rawOutput: "ok", + }), + observerEvent({ + seq: 4, + kind: "turn_completed", + payload: {}, + sourceEventId: "6".repeat(64), + }), + ]), + ); + assert.equal(journal.status, "completed"); + assert.equal(journal.proofState, "RECEIPTED"); +}); + +test("unrecovered tool failure remains FAILED without claiming mission failure", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent(), + sessionUpdate(2, "tool_call_update", { + toolCallId: "call-failed", + status: "failed", + title: "shell", + rawOutput: "boom", + }), + observerEvent({ + seq: 3, + kind: "turn_completed", + payload: {}, + sourceEventId: "7".repeat(64), + }), + ]), + ); + assert.equal(journal.status, "ended_unverified"); + assert.equal(journal.proofState, "FAILED"); +}); + +test("buildMissionJournal reconstructs the same result from a restart replay", () => { + const raw = [ + observerEvent(), + sessionUpdate(2, "tool_call", { + toolCallId: "call-2", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "pnpm test" }, + }), + sessionUpdate(3, "tool_call_update", { + toolCallId: "call-2", + status: "completed", + title: "shell", + kind: "shell", + rawInput: { command: "pnpm test" }, + rawOutput: "3 passed", + }), + observerEvent({ + seq: 4, + kind: "turn_completed", + payload: {}, + sourceEventId: "c".repeat(64), + }), + ]; + + const first = buildMissionJournal(normalizeActivityEvents(raw)); + const replayed = buildMissionJournal(normalizeActivityEvents([...raw])); + + assert.deepEqual(replayed, first); +}); + +test("buildMissionJournal selects the most recently active overlapping turn", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent({ + turnId: "turn-a", + timestamp: "2026-08-21T10:00:00.000Z", + sourceEventId: "a".repeat(64), + }), + observerEvent({ + turnId: "turn-b", + timestamp: "2026-08-21T11:00:00.000Z", + sourceEventId: "b".repeat(64), + }), + observerEvent({ + turnId: "turn-a", + timestamp: "2026-08-21T12:00:00.000Z", + kind: "turn_completed", + payload: {}, + sourceEventId: "c".repeat(64), + }), + ]), + ); + assert.equal(journal.turnId, "turn-a"); + assert.equal(journal.endedAt, "2026-08-21T12:00:00.000Z"); +}); + +test("buildTodayActivitySurface keeps multi-agent handoffs distinct while aggregating one day", () => { + const feed = buildTodayActivitySurface( + [ + { + agentPubkey: AGENT_A, + agentName: "Fizz", + events: normalizeActivityEvents([ + observerEvent({ + sessionId: "sess-a", + turnId: "turn-a", + channelId: CHANNEL_A, + }), + sessionUpdate( + 2, + "tool_call_update", + { + toolCallId: "call-a", + status: "completed", + title: "shell", + kind: "shell", + rawInput: { command: "cargo test" }, + rawOutput: "ok", + }, + { sessionId: "sess-a", turnId: "turn-a", channelId: CHANNEL_A }, + ), + ]), + }, + { + agentPubkey: AGENT_B, + agentName: "Honey", + events: normalizeActivityEvents([ + observerEvent({ + sessionId: "sess-b", + turnId: "turn-b", + channelId: CHANNEL_A, + timestamp: "2026-08-21T14:05:00.000Z", + }), + sessionUpdate( + 2, + "plan", + { content: [{ type: "text", text: "Follow up with owner" }] }, + { + sessionId: "sess-b", + turnId: "turn-b", + channelId: CHANNEL_A, + timestamp: "2026-08-21T14:06:00.000Z", + }, + ), + ]), + }, + ], + { day: "2026-08-21" }, + ); + + assert.equal(feed.journals.length, 2); + assert.deepEqual( + feed.journals.map((journal) => journal.correlationId), + ["f".repeat(64), "f".repeat(64)], + ); + assert.equal(feed.channels[0].channelId, CHANNEL_A); + assert.deepEqual( + feed.channels[0].agentPubkeys.sort(), + [AGENT_A, AGENT_B].sort(), + ); +}); + +test("applyOwnerJournalOverride keeps owner edits separate from observed proof", () => { + const journal = buildMissionJournal( + normalizeActivityEvents([ + observerEvent(), + sessionUpdate(2, "tool_call_update", { + toolCallId: "call-3", + status: "completed", + title: "shell", + kind: "shell", + rawInput: { command: "npm test" }, + rawOutput: "ok", + }), + ]), + ); + + const overridden = applyOwnerJournalOverride(journal, { + summary: "Owner note: verify with Bumble before closing.", + modifiedAt: "2026-08-21T15:00:00.000Z", + modifiedBy: "owner", + }); + + assert.equal(overridden.summarySource, "owner"); + assert.equal( + overridden.summary, + "Owner note: verify with Bumble before closing.", + ); + assert.equal(overridden.proofState, journal.proofState); +}); + +test("buildTodayActivitySurface filters out work from other local days", () => { + const feed = buildTodayActivitySurface( + [ + { + agentPubkey: AGENT_A, + agentName: "Fizz", + events: normalizeActivityEvents([ + observerEvent({ + timestamp: "2026-08-20T23:58:00.000Z", + channelId: CHANNEL_B, + }), + observerEvent({ + seq: 2, + timestamp: "2026-08-21T15:00:00.000Z", + channelId: CHANNEL_A, + }), + ]), + }, + ], + { day: "2026-08-21" }, + ); + + assert.equal(feed.journals.length, 1); + assert.equal(feed.journals[0].channelId, CHANNEL_A); +}); diff --git a/desktop/src/features/agents/activityLedger.ts b/desktop/src/features/agents/activityLedger.ts new file mode 100644 index 00000000000..5042ff9c295 --- /dev/null +++ b/desktop/src/features/agents/activityLedger.ts @@ -0,0 +1,970 @@ +import type { ObserverEvent } from "./ui/agentSessionTypes"; + +export type ActivityProofState = + | "OBSERVED" + | "CLAIMED" + | "RECEIPTED" + | "VERIFIED" + | "FAILED" + | "UNKNOWN"; + +export type ActivityStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "blocked" + | "unknown"; + +export type MissionJournalStatus = + | "in_progress" + | "completed" + | "failed" + | "ended_unverified" + | "incomplete" + | "observed"; + +export type ActivityCategory = + | "turn" + | "tool" + | "message" + | "thought" + | "plan" + | "permission" + | "prompt" + | "status"; + +export type ActivityProvenance = { + sourceEventId: string | null; + sourcePubkey: string | null; + sourceKind: number | null; + sourceCreatedAt: number | null; + sourceSignature: string | null; + origin: "live_observer" | "historical_backfill" | "unknown"; + observerKind: string; + method: string | null; + sessionUpdate: string | null; + seq: number; + timestamp: string; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + toolCallId: string | null; + messageId: string | null; + triggeringEventIds: string[]; +}; + +export type NormalizedActivityEvent = { + id: string; + journalKey: string; + correlationId: string; + category: ActivityCategory; + title: string; + detail: string | null; + status: ActivityStatus; + proofState: ActivityProofState; + timestamp: string; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + toolCallId: string | null; + messageId: string | null; + provenance: ActivityProvenance; + tags: string[]; + ownerModifiedAt?: string | null; + ownerModifiedBy?: string | null; +}; + +export type MissionJournal = { + id: string; + journalKey: string; + correlationId: string; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + startedAt: string; + endedAt: string; + status: MissionJournalStatus; + proofState: ActivityProofState; + summary: string; + summarySource: "auto" | "owner"; + ownerModifiedAt: string | null; + ownerModifiedBy: string | null; + claimedCompletionWithoutEvidence: boolean; + eventCount: number; + events: NormalizedActivityEvent[]; +}; + +export type MissionJournalOverride = { + summary: string; + modifiedAt: string; + modifiedBy: string; +}; + +export type MissionJournalBuildOptions = { + asOf?: string | Date; + incompleteAfterMs?: number; +}; + +export type TodayActivityFeedInput = { + agentPubkey: string; + agentName: string; + events: NormalizedActivityEvent[]; +}; + +export type TodayActivityChannel = { + channelId: string; + journalIds: string[]; + agentPubkeys: string[]; + agentNames: string[]; + lastActivityAt: string; +}; + +export type TodayActivityJournal = MissionJournal & { + agentPubkey: string; + agentName: string; +}; + +export type TodayActivitySurface = { + day: string; + journals: TodayActivityJournal[]; + channels: TodayActivityChannel[]; + counts: { + journals: number; + failed: number; + inProgress: number; + claimedWithoutEvidence: number; + }; +}; + +const PROOF_RANK: Record = { + UNKNOWN: 0, + CLAIMED: 1, + OBSERVED: 2, + RECEIPTED: 3, + VERIFIED: 4, + FAILED: 5, +}; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function extractContentText(value: unknown): string | null { + if (typeof value === "string") { + return value.trim() || null; + } + const record = asRecord(value); + const text = asString(record.text); + if (text) return text; + if (Array.isArray(value)) { + const parts = value + .map((entry) => extractContentText(entry)) + .filter((entry): entry is string => Boolean(entry)); + return parts.length > 0 ? parts.join("\n") : null; + } + return null; +} + +function toolTitle(update: Record) { + return ( + asString(update.toolName) ?? + asString(update.kind) ?? + asString(update.title) ?? + "tool" + ); +} + +function toolCallId(update: Record) { + return asString(update.toolCallId) ?? asString(update.tool_call_id); +} + +function messageId(update: Record) { + return asString(update.messageId) ?? asString(update.message_id); +} + +function statusFromUpdate(status: string | null | undefined): ActivityStatus { + switch (status) { + case "pending": + return "pending"; + case "executing": + return "running"; + case "completed": + case "done": + return "completed"; + case "failed": + case "error": + return "failed"; + default: + return "unknown"; + } +} + +function proofStateForTool( + status: ActivityStatus, + output: unknown, +): ActivityProofState { + if (status === "failed") return "FAILED"; + if (status !== "completed") return "OBSERVED"; + return output === undefined || output === null ? "OBSERVED" : "RECEIPTED"; +} + +function triggeringEventIds(event: ObserverEvent): string[] { + const ids = asRecord(event.payload).triggeringEventIds; + return Array.isArray(ids) + ? ids.filter((id): id is string => typeof id === "string" && id.length > 0) + : []; +} + +function correlationId( + event: ObserverEvent, + update?: Record, + turnCorrelationId?: string | null, +) { + const toolId = update ? toolCallId(update) : null; + return ( + toolId ?? + triggeringEventIds(event)[0] ?? + turnCorrelationId ?? + event.journalKey ?? + event.turnId ?? + event.sessionId ?? + event.channelId ?? + `${event.kind}:${event.seq}` + ); +} + +function journalKey(event: ObserverEvent) { + return ( + event.journalKey ?? + event.turnId ?? + event.sessionId ?? + event.channelId ?? + "global" + ); +} + +function buildId( + event: ObserverEvent, + category: ActivityCategory, + suffix: string | null = null, +) { + return [ + category, + event.sourceEventId ?? `${event.seq}:${event.timestamp}:${event.kind}`, + suffix, + ] + .filter(Boolean) + .join(":"); +} + +function eventTagSet( + category: ActivityCategory, + updateType: string | null, + toolName: string | null, +): string[] { + const tags: string[] = [category]; + if (updateType) tags.push(updateType); + if (toolName) tags.push(`tool:${toolName}`); + return tags; +} + +function compareObserverEvents(left: ObserverEvent, right: ObserverEvent) { + const leftTime = Date.parse(left.timestamp); + const rightTime = Date.parse(right.timestamp); + if ( + Number.isFinite(leftTime) && + Number.isFinite(rightTime) && + leftTime !== rightTime + ) { + return leftTime - rightTime; + } + return left.seq - right.seq; +} + +function dedupeObserverEvents(events: readonly ObserverEvent[]) { + const seen = new Set(); + const deduped: ObserverEvent[] = []; + for (const event of [...events].sort(compareObserverEvents)) { + const key = event.sourceEventId + ? `source:${event.sourceEventId}` + : `legacy:${event.seq}:${event.timestamp}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(event); + } + return deduped; +} + +function localDay(timestamp: string) { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function normalizeOne( + event: ObserverEvent, + turnCorrelationId: string | null, +): NormalizedActivityEvent | null { + const payload = asRecord(event.payload); + const method = asString(payload.method); + const update = + method === "session/update" + ? asRecord(asRecord(payload.params).update) + : null; + const updateType = update ? asString(update.sessionUpdate) : null; + const toolName = update ? toolTitle(update) : null; + const callId = update ? toolCallId(update) : null; + const msgId = update ? messageId(update) : null; + const base = { + journalKey: journalKey(event), + correlationId: correlationId(event, update ?? undefined, turnCorrelationId), + timestamp: event.timestamp, + channelId: event.channelId ?? null, + sessionId: event.sessionId ?? null, + turnId: event.turnId ?? null, + toolCallId: callId, + messageId: msgId, + provenance: { + sourceEventId: event.sourceEventId ?? null, + sourcePubkey: event.sourcePubkey ?? null, + sourceKind: event.sourceKind ?? null, + sourceCreatedAt: event.sourceCreatedAt ?? null, + sourceSignature: event.sourceSignature ?? null, + origin: + event.origin === "live_observer" || + event.origin === "historical_backfill" + ? event.origin + : "unknown", + observerKind: event.kind, + method: method ?? null, + sessionUpdate: updateType ?? null, + seq: event.seq, + timestamp: event.timestamp, + channelId: event.channelId ?? null, + sessionId: event.sessionId ?? null, + turnId: event.turnId ?? null, + toolCallId: callId, + messageId: msgId, + triggeringEventIds: triggeringEventIds(event), + } satisfies ActivityProvenance, + }; + + if (event.kind === "turn_started") { + return { + ...base, + id: buildId(event, "turn"), + category: "turn", + title: "Turn started", + detail: null, + status: "running", + proofState: "OBSERVED", + tags: eventTagSet("turn", null, null), + }; + } + + if (event.kind === "turn_completed") { + return { + ...base, + id: buildId(event, "turn"), + category: "turn", + title: "Turn completed", + detail: null, + status: "completed", + proofState: "OBSERVED", + tags: eventTagSet("turn", null, null), + }; + } + + if (event.kind === "turn_error" || event.kind === "agent_panic") { + return { + ...base, + id: buildId(event, "turn"), + category: "turn", + title: event.kind === "agent_panic" ? "Agent crashed" : "Turn failed", + detail: asString(payload.error) ?? extractContentText(payload) ?? null, + status: "failed", + proofState: "FAILED", + tags: eventTagSet("turn", null, null), + }; + } + + if (event.kind === "managed_agent_runtime_lifecycle") { + const lifecycle = asRecord(event.payload); + const phase = asString(lifecycle.lifecycle); + const detail = asString(lifecycle.error) ?? phase; + const status = + phase === "failed" + ? "failed" + : phase === "ready" + ? "completed" + : "running"; + return { + ...base, + id: buildId(event, "status", "runtime-lifecycle"), + category: "status", + title: + phase === "failed" + ? "Runtime failed" + : phase === "ready" + ? "Runtime ready" + : "Runtime lifecycle observed", + detail, + status, + proofState: phase === "failed" ? "FAILED" : "OBSERVED", + tags: eventTagSet("status", "managed_agent_runtime_lifecycle", null), + }; + } + + if (event.kind === "turn_liveness") { + return { + ...base, + id: buildId(event, "status", "turn-liveness"), + category: "status", + title: "Turn active", + detail: null, + status: "running", + proofState: "OBSERVED", + tags: eventTagSet("status", "turn_liveness", null), + }; + } + + if (event.kind === "session_config_captured") { + const config = asRecord(event.payload); + const provider = asString(config.provider); + const model = asString(config.model); + const detail = + [ + provider ? `provider ${provider}` : null, + model ? `model ${model}` : null, + ] + .filter((entry): entry is string => Boolean(entry)) + .join(", ") || null; + return { + ...base, + id: buildId(event, "status", "session-config"), + category: "status", + title: "Session config captured", + detail, + status: "completed", + proofState: "OBSERVED", + tags: eventTagSet("status", "session_config_captured", null), + }; + } + + if (event.kind === "journal_override") { + // Managed observer envelopes are signed by the agent, not the owner. + // Owner edits enter through applyOwnerJournalOverride after an authenticated + // owner action; agent-authored lookalikes are deliberately ignored. + return null; + } + + if (event.kind === "proof_verified" || event.kind === "proof_failed") { + const receiptRef = asString(payload.receiptRef); + const failed = event.kind === "proof_failed"; + return { + ...base, + id: buildId(event, "status", event.kind), + category: "status", + title: failed ? "Proof verification failed" : "Verification claimed", + detail: receiptRef, + status: failed ? "failed" : "completed", + // Observer envelopes are signed by the managed agent. Fields naming a + // verifier are still self-reported until a separate trusted signature is + // validated, so this path must never mint VERIFIED. + proofState: failed ? "FAILED" : "CLAIMED", + tags: eventTagSet("status", event.kind, null), + }; + } + + if (event.kind === "session_resolved") { + return { + ...base, + id: buildId(event, "status"), + category: "status", + title: "Session ready", + detail: extractContentText(payload), + status: "running", + proofState: "OBSERVED", + tags: eventTagSet("status", null, null), + }; + } + + if (method === "session/request_permission") { + return { + ...base, + id: buildId(event, "permission"), + category: "permission", + title: "Permission requested", + detail: + asString(asRecord(asRecord(payload.params).title)) ?? + asString(asRecord(payload.params).message) ?? + null, + status: "blocked", + proofState: "OBSERVED", + tags: eventTagSet("permission", null, null), + }; + } + + if (event.kind === "acp_write" && !method) { + const result = asRecord(asRecord(payload.result).outcome); + const outcome = asString(result.outcome); + if (outcome) { + return { + ...base, + id: buildId(event, "permission"), + category: "permission", + title: "Permission resolved", + detail: outcome, + status: "completed", + proofState: "RECEIPTED", + tags: eventTagSet("permission", null, null), + }; + } + } + + if (event.kind === "acp_write" && method === "session/prompt") { + return { + ...base, + id: buildId(event, "prompt"), + category: "prompt", + title: "Prompt issued", + detail: extractContentText(asRecord(payload.params).prompt), + status: "completed", + proofState: "RECEIPTED", + tags: eventTagSet("prompt", null, null), + }; + } + + if (updateType === "tool_call" || updateType === "tool_call_update") { + const status = statusFromUpdate(asString(update?.status)); + const name = toolName ?? "tool"; + const output = update?.rawOutput ?? update?.content; + return { + ...base, + id: buildId(event, "tool", callId ?? name), + category: "tool", + title: name, + detail: + extractContentText(update?.rawOutput) ?? + extractContentText(update?.content) ?? + extractContentText(update?.rawInput) ?? + null, + status, + proofState: proofStateForTool(status, output), + tags: eventTagSet("tool", updateType, name), + }; + } + + if ( + updateType === "agent_message_chunk" || + updateType === "user_message_chunk" + ) { + return { + ...base, + id: buildId(event, "message", msgId ?? updateType), + category: "message", + title: + updateType === "agent_message_chunk" ? "Agent message" : "User message", + detail: extractContentText(update?.content), + status: "completed", + proofState: "CLAIMED", + tags: eventTagSet("message", updateType, null), + }; + } + + if (updateType === "agent_thought_chunk") { + return { + ...base, + id: buildId(event, "thought"), + category: "thought", + title: "Thought", + detail: extractContentText(update?.content), + status: "completed", + proofState: "CLAIMED", + tags: eventTagSet("thought", updateType, null), + }; + } + + if (updateType === "plan") { + return { + ...base, + id: buildId(event, "plan"), + category: "plan", + title: "Plan updated", + detail: extractContentText(update?.content), + status: "completed", + proofState: "CLAIMED", + tags: eventTagSet("plan", updateType, null), + }; + } + + const freeformText = asString(payload.text) ?? extractContentText(payload); + if (freeformText) { + return { + ...base, + id: buildId(event, "status"), + category: "status", + title: asString(payload.title) ?? event.kind, + detail: freeformText, + status: event.kind.includes("error") ? "failed" : "completed", + proofState: event.kind.includes("error") ? "FAILED" : "OBSERVED", + tags: eventTagSet("status", updateType, null), + }; + } + + return null; +} + +export function normalizeActivityEvents( + events: readonly ObserverEvent[], +): NormalizedActivityEvent[] { + const deduped = dedupeObserverEvents(events); + const latestLiveness = new Map(); + for (const event of deduped) { + if (event.kind !== "turn_liveness") continue; + const key = event.turnId ?? event.sessionId ?? event.channelId ?? "global"; + latestLiveness.set(key, event); + } + const compacted = deduped.filter((event) => { + if (event.kind !== "turn_liveness") return true; + const key = event.turnId ?? event.sessionId ?? event.channelId ?? "global"; + return latestLiveness.get(key) === event; + }); + const turnCorrelations = new Map(); + for (const event of compacted) { + const root = triggeringEventIds(event)[0]; + if (event.turnId && root) turnCorrelations.set(event.turnId, root); + } + return compacted + .map((event) => + normalizeOne( + event, + event.turnId ? (turnCorrelations.get(event.turnId) ?? null) : null, + ), + ) + .filter((event): event is NormalizedActivityEvent => Boolean(event)); +} + +function bestProofState(events: readonly NormalizedActivityEvent[]) { + return events.reduce((best, event) => { + return PROOF_RANK[event.proofState] > PROOF_RANK[best] + ? event.proofState + : best; + }, "UNKNOWN"); +} + +function buildSummary( + events: readonly NormalizedActivityEvent[], + status: MissionJournalStatus, + claimedCompletionWithoutEvidence: boolean, +) { + const ownerOverride = events.find((event) => event.ownerModifiedAt != null); + if (ownerOverride?.detail) { + return ownerOverride.detail; + } + + const toolNames = [ + ...new Set( + events + .filter((event) => event.category === "tool") + .map((event) => event.title), + ), + ]; + if (status === "failed") { + const failed = [...events] + .reverse() + .find( + (event) => + event.status === "failed" && + (event.category === "turn" || + event.provenance.observerKind === + "managed_agent_runtime_lifecycle"), + ); + return failed?.detail + ? `${failed.title}: ${failed.detail}` + : `${failed?.title ?? "Turn failed"} during observed execution.`; + } + if (claimedCompletionWithoutEvidence) { + return "Execution ended without supporting evidence for the requested outcome."; + } + if (status === "incomplete") { + return "Execution started but no terminal event was observed before the activity became stale."; + } + if (status === "completed" && toolNames.length > 0) { + return `Execution ended with receipted activity in ${toolNames.join(", ")}; outcome verification remains separate.`; + } + if (toolNames.length > 0) { + return `Observed work in ${toolNames.join(", ")}.`; + } + const claimed = events.find( + (event) => event.proofState === "CLAIMED" && event.detail, + ); + if (claimed?.detail) { + return claimed.detail; + } + return "Observed agent activity."; +} + +export function groupMissionJournals( + events: readonly NormalizedActivityEvent[], + options: MissionJournalBuildOptions = {}, +): MissionJournal[] { + const grouped = new Map(); + for (const event of [...events].sort((left, right) => { + const leftTime = Date.parse(left.timestamp); + const rightTime = Date.parse(right.timestamp); + return leftTime === rightTime + ? left.provenance.seq - right.provenance.seq + : leftTime - rightTime; + })) { + const bucket = grouped.get(event.journalKey) ?? []; + bucket.push(event); + grouped.set(event.journalKey, bucket); + } + + return [...grouped.entries()].map(([key, bucket]) => { + const startedAt = bucket[0]?.timestamp ?? new Date(0).toISOString(); + const endedAt = bucket[bucket.length - 1]?.timestamp ?? startedAt; + const latestRuntimeLifecycle = [...bucket] + .reverse() + .find( + (event) => + event.provenance.observerKind === "managed_agent_runtime_lifecycle", + ); + const effectiveProofEvents = latestRuntimeLifecycle + ? bucket.filter( + (event) => + event.provenance.observerKind !== + "managed_agent_runtime_lifecycle" || + event === latestRuntimeLifecycle, + ) + : bucket; + const hasTerminalFailure = + bucket.some( + (event) => event.status === "failed" && event.category === "turn", + ) || latestRuntimeLifecycle?.status === "failed"; + const hasCompletion = bucket.some( + (event) => event.category === "turn" && event.status === "completed", + ); + const successfulEvidence = bucket.filter( + (event) => + event.status === "completed" && + (event.proofState === "VERIFIED" || + (event.proofState === "RECEIPTED" && + (event.category === "tool" || event.category === "status"))), + ); + const claimedCompletionWithoutEvidence = + hasCompletion && successfulEvidence.length === 0; + const latestToolState = new Map(); + for (const event of bucket) { + if (event.category === "tool") { + latestToolState.set(event.correlationId, event.status); + } + } + const hasUnresolvedToolFailure = [...latestToolState.values()].some( + (status) => status === "failed", + ); + const hasProofFailure = bucket.some( + (event) => + event.provenance.observerKind === "proof_failed" || + (event.category === "status" && + event.proofState === "FAILED" && + event.provenance.observerKind !== "managed_agent_runtime_lifecycle"), + ); + const asOf = + options.asOf instanceof Date + ? options.asOf.getTime() + : options.asOf + ? Date.parse(options.asOf) + : Number.NaN; + const incompleteAfterMs = options.incompleteAfterMs ?? 5 * 60_000; + const isStaleIncomplete = + !hasCompletion && + !hasTerminalFailure && + Number.isFinite(asOf) && + asOf - Date.parse(endedAt) >= incompleteAfterMs && + bucket.some( + (event) => event.category === "turn" && event.status === "running", + ); + + let status: MissionJournalStatus = "observed"; + if (hasTerminalFailure) { + status = "failed"; + } else if (claimedCompletionWithoutEvidence) { + status = "ended_unverified"; + } else if (hasCompletion) { + status = "completed"; + } else if (isStaleIncomplete) { + status = "incomplete"; + } else if ( + effectiveProofEvents.some((event) => event.status === "running") + ) { + status = "in_progress"; + } + + const ownerOverride = bucket.find((event) => event.ownerModifiedAt != null); + const proofState: ActivityProofState = + hasTerminalFailure || hasProofFailure || hasUnresolvedToolFailure + ? "FAILED" + : isStaleIncomplete + ? "UNKNOWN" + : successfulEvidence.some((event) => event.proofState === "VERIFIED") + ? "VERIFIED" + : successfulEvidence.some( + (event) => event.proofState === "RECEIPTED", + ) + ? "RECEIPTED" + : claimedCompletionWithoutEvidence + ? "OBSERVED" + : bestProofState(effectiveProofEvents); + + return { + id: key, + journalKey: key, + correlationId: bucket[0]?.correlationId ?? key, + channelId: bucket[0]?.channelId ?? null, + sessionId: bucket.find((event) => event.sessionId)?.sessionId ?? null, + turnId: bucket.find((event) => event.turnId)?.turnId ?? null, + startedAt, + endedAt, + status, + proofState, + summary: buildSummary(bucket, status, claimedCompletionWithoutEvidence), + summarySource: ownerOverride ? "owner" : "auto", + ownerModifiedAt: ownerOverride?.ownerModifiedAt ?? null, + ownerModifiedBy: ownerOverride?.ownerModifiedBy ?? null, + claimedCompletionWithoutEvidence, + eventCount: bucket.length, + events: bucket, + } satisfies MissionJournal; + }); +} + +export function buildMissionJournal( + events: readonly NormalizedActivityEvent[], + options: MissionJournalBuildOptions = {}, +): MissionJournal { + const journals = groupMissionJournals(events, options); + let latest: MissionJournal | undefined; + for (const journal of journals) { + if ( + !latest || + Date.parse(journal.endedAt) > Date.parse(latest.endedAt) || + (journal.endedAt === latest.endedAt && journal.id > latest.id) + ) { + latest = journal; + } + } + if (latest) return latest; + return { + id: "empty", + journalKey: "empty", + correlationId: "empty", + channelId: null, + sessionId: null, + turnId: null, + startedAt: new Date(0).toISOString(), + endedAt: new Date(0).toISOString(), + status: "observed", + proofState: "UNKNOWN", + summary: "No observed activity.", + summarySource: "auto", + ownerModifiedAt: null, + ownerModifiedBy: null, + claimedCompletionWithoutEvidence: false, + eventCount: 0, + events: [], + }; +} + +export function applyOwnerJournalOverride( + journal: MissionJournal, + override: MissionJournalOverride, +): MissionJournal { + return { + ...journal, + summary: override.summary, + summarySource: "owner", + ownerModifiedAt: override.modifiedAt, + ownerModifiedBy: override.modifiedBy, + }; +} + +export function buildTodayActivitySurface( + feeds: readonly TodayActivityFeedInput[], + options: { day: string }, +): TodayActivitySurface { + const journals: TodayActivityJournal[] = []; + const channels = new Map< + string, + { + journalIds: string[]; + agentPubkeys: Set; + agentNames: Set; + lastActivityAt: string; + } + >(); + + for (const feed of feeds) { + for (const journal of groupMissionJournals( + feed.events.filter((event) => localDay(event.timestamp) === options.day), + )) { + journals.push({ + ...journal, + agentPubkey: feed.agentPubkey, + agentName: feed.agentName, + }); + + if (!journal.channelId) continue; + const bucket = channels.get(journal.channelId) ?? { + journalIds: [], + agentPubkeys: new Set(), + agentNames: new Set(), + lastActivityAt: journal.endedAt, + }; + bucket.journalIds.push(journal.id); + bucket.agentPubkeys.add(feed.agentPubkey); + bucket.agentNames.add(feed.agentName); + if (Date.parse(journal.endedAt) > Date.parse(bucket.lastActivityAt)) { + bucket.lastActivityAt = journal.endedAt; + } + channels.set(journal.channelId, bucket); + } + } + + journals.sort( + (left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt), + ); + + return { + day: options.day, + journals, + channels: [...channels.entries()] + .map(([channelId, bucket]) => ({ + channelId, + journalIds: bucket.journalIds, + agentPubkeys: [...bucket.agentPubkeys], + agentNames: [...bucket.agentNames], + lastActivityAt: bucket.lastActivityAt, + })) + .sort((left, right) => left.channelId.localeCompare(right.channelId)), + counts: { + journals: journals.length, + failed: journals.filter((journal) => journal.status === "failed").length, + inProgress: journals.filter((journal) => journal.status === "in_progress") + .length, + claimedWithoutEvidence: journals.filter( + (journal) => journal.claimedCompletionWithoutEvidence, + ).length, + }, + }; +} diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs index 343ce241335..3b6bf8b5c24 100644 --- a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs +++ b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs @@ -154,6 +154,12 @@ describe("ingestArchivedObserverEvents", () => { ); assert.equal(archivedEvents.length, 1, "archive must contain 1 raw event"); assert.equal(archivedEvents[0].seq, 1); + assert.equal(archivedEvents[0].sourceEventId, "e".repeat(64)); + assert.equal(archivedEvents[0].sourcePubkey, AGENT_PUBKEY); + assert.equal(archivedEvents[0].sourceKind, 24200); + assert.equal(archivedEvents[0].sourceCreatedAt, 1000); + assert.equal(archivedEvents[0].sourceSignature, "s".repeat(128)); + assert.equal(archivedEvents[0].origin, "historical_backfill"); // Also verify the live snapshot is untouched — archive separation. const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); assert.equal( @@ -183,6 +189,28 @@ describe("ingestArchivedObserverEvents", () => { ); }); + it("test_signed_ids_keep_same_seq_timestamp_frames_distinct", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + const sameDecodedFrame = makeObserverEvent({ + seq: 5, + timestamp: "2026-01-01T00:00:05.000Z", + }); + await ingestArchivedObserverEvents( + [ + makeRawEvent({ id: "e".repeat(64) }), + makeRawEvent({ id: "f".repeat(64) }), + ], + () => Promise.resolve(sameDecodedFrame), + ); + + const archiveEvents = _testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-1"); + assert.equal(archiveEvents.length, 2); + assert.deepEqual(archiveEvents.map((event) => event.sourceEventId).sort(), [ + "e".repeat(64), + "f".repeat(64), + ]); + }); + it("test_older_archived_event_sorts_before_live", async () => { // Pre-seed a newer live event (no channelId → goes to live path). const liveObs = makeObserverEvent({ @@ -235,7 +263,11 @@ describe("ingestArchivedObserverEvents", () => { const decryptFn = () => Promise.resolve(events[callIdx++]); // All three raw events pass the guards (same pubkey/agent tag). await ingestArchivedObserverEvents( - [makeRawEvent(), makeRawEvent(), makeRawEvent()], + [ + makeRawEvent({ id: "1".repeat(64) }), + makeRawEvent({ id: "2".repeat(64) }), + makeRawEvent({ id: "3".repeat(64) }), + ], decryptFn, ); // All have channelId "chan-1" — verify archive window, not live snapshot. diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 68fa290ad25..86ecb969973 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -223,6 +223,28 @@ function observerTag(event: RelayEvent, tagName: string) { return event.tags.find((tag) => tag[0] === tagName)?.[1] ?? null; } +function observerEventIdentity(event: ObserverEvent): string { + return event.sourceEventId + ? `source:${event.sourceEventId}:${event.timestamp.length}:${event.timestamp}:${event.seq}` + : `legacy:${event.seq}:${event.timestamp}`; +} + +function withRelayProvenance( + parsed: ObserverEvent, + event: RelayEvent, + origin: "live_observer" | "historical_backfill", +): ObserverEvent { + return { + ...parsed, + sourceEventId: event.id, + sourcePubkey: event.pubkey, + sourceKind: event.kind, + sourceCreatedAt: event.created_at, + sourceSignature: event.sig, + origin, + }; +} + function appendAgentEvents( agentPubkey: string, events: readonly ObserverEvent[], @@ -257,15 +279,10 @@ function appendAgentEvents( const seen = allAtEnd ? new Set() - : new Set( - current.map( - (event) => - `${event.timestamp.length}:${event.timestamp}:${event.seq}`, - ), - ); + : new Set(current.map(observerEventIdentity)); const added: ObserverEvent[] = []; for (const event of admissible) { - const eventKey = `${event.timestamp.length}:${event.timestamp}:${event.seq}`; + const eventKey = observerEventIdentity(event); if (seen.has(eventKey)) continue; seen.add(eventKey); added.push(event); @@ -312,15 +329,9 @@ function appendAgentEvents( invalidateSnapshot(key); if (!trimmed) return sortedAdded; - const retainedKeys = new Set( - final.map( - (event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`, - ), - ); + const retainedKeys = new Set(final.map(observerEventIdentity)); return sortedAdded.filter((event) => - retainedKeys.has( - `${event.timestamp.length}:${event.timestamp}:${event.seq}`, - ), + retainedKeys.has(observerEventIdentity(event)), ); } @@ -346,7 +357,8 @@ function archiveChannelKey(agentPubkey: string, channelId: string): string { * the channel archive window grows only by explicit paged loads from SQLite, * so unbounded growth from live relay events is impossible. * - * Deduplicates on `(seq, timestamp)` — identical to `appendAgentEvent` — so + * Deduplicates on signed source id (with `(seq, timestamp)` for legacy/E2E + * events) — identical to `appendAgentEvent` — so * events that arrive on the live relay before the archive page is loaded are * silently skipped. The archive window and the live transcript are kept * strictly separate: live events never write here. @@ -362,11 +374,11 @@ function appendArchivedChannelEvent( const key = archiveChannelKey(agentPubkey, channelId); const current = archiveEventsByChannel.get(key) ?? []; - // Dedup: skip if (seq, timestamp) already present in the archive window. + // Dedup: prefer the signed envelope id; legacy/E2E events use seq+timestamp. if ( current.some( (existing) => - existing.seq === event.seq && existing.timestamp === event.timestamp, + observerEventIdentity(existing) === observerEventIdentity(event), ) ) { return false; @@ -563,7 +575,10 @@ async function handleRelayObserverEvent( if (activeGeneration !== generation) { return; } - processLiveObserverEvents(agentPubkey, unwrapObserverBatch(parsed)); + const events = unwrapObserverBatch(parsed).map((inner) => + withRelayProvenance(inner, event, "live_observer"), + ); + processLiveObserverEvents(agentPubkey, events); } catch (error) { if (activeGeneration !== generation) { return; @@ -810,7 +825,7 @@ export function useManagedAgentObserverBridge( * - The event sender (`pubkey`) must match the `agent` tag value. * - Event must decrypt successfully via `decryptObserverEvent`. * - * Routes through `appendAgentEvent` so dedup on `(seq, timestamp)` and + * Routes through the shared stores so signed-id deduplication and * sort are reused — archived events that are already present (live-delivered) * are silently skipped. Failed decryptions are silently dropped (same as * live path error handling). @@ -842,20 +857,25 @@ export async function ingestArchivedObserverEvents( try { const parsed = (await _decryptFn(event)) as ObserverEvent; for (const inner of unwrapObserverBatch(parsed)) { + const enriched = withRelayProvenance( + inner, + event, + "historical_backfill", + ); // Route archived events to the channel-scoped archive window (no cap) // rather than the per-agent live-relay store (MAX_OBSERVER_EVENTS cap). // Events without a channelId fall through to the live store so they // remain visible in the agent's general transcript. - if (inner.channelId) { + if (enriched.channelId) { const added = appendArchivedChannelEvent( agentPubkey, - inner.channelId, - inner, + enriched.channelId, + enriched, ); if (added) archiveChanged = true; } else { // Live path already calls notifyListeners() inside appendAgentEvent. - appendAgentEvent(agentPubkey, inner); + appendAgentEvent(agentPubkey, enriched); } } } catch { diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 594fa1cbaa5..0d7875ec1a8 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -8,9 +8,15 @@ import { } from "lucide-react"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { + buildMissionJournal, + normalizeActivityEvents, + type MissionJournal, +} from "@/features/agents/activityLedger"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { useNow } from "@/shared/lib/useNow"; import { Badge } from "@/shared/ui/badge"; import { Skeleton } from "@/shared/ui/skeleton"; import { Spinner } from "@/shared/ui/spinner"; @@ -127,6 +133,14 @@ export function ManagedAgentSessionPanel({ () => deriveLatestSessionId(displayEvents), [displayEvents], ); + const journalAsOf = useNow(60_000); + const latestJournal = React.useMemo( + () => + buildMissionJournal(normalizeActivityEvents(combinedEvents), { + asOf: new Date(journalAsOf), + }), + [combinedEvents, journalAsOf], + ); return (
) : null} + {latestJournal.eventCount > 0 ? ( + + ) : null} + +
+ + Mission journal + + {journalStatusLabel(journal.status)} + {journal.proofState} + {journal.claimedCompletionWithoutEvidence ? ( + Evidence gap + ) : null} +
+

{journal.summary}

+ + ); +} + function SessionHeader({ connectionState, eventCount, diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs index 35e1c5db18e..177bd74d8d6 100644 --- a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { deriveLatestSessionId, + mergeObserverEventWindows, observerEventScrollId, resolveDisplayEvents, resolveRawRailLayout, @@ -118,3 +119,21 @@ test("observerEventScrollId returns distinct ids for same seq across a restart", const after = { seq: 1, timestamp: "2026-07-13T21:00:00.000Z" }; assert.notEqual(observerEventScrollId(before), observerEventScrollId(after)); }); + +test("mergeObserverEventWindows preserves signed frames with colliding seq and timestamp", () => { + const timestamp = "2026-07-13T21:00:00.000Z"; + const live = { seq: 1, timestamp, sourceEventId: "a".repeat(64) }; + const archived = { seq: 1, timestamp, sourceEventId: "b".repeat(64) }; + const merged = mergeObserverEventWindows([live], [archived]); + assert.equal(merged.length, 2); +}); + +test("mergeObserverEventWindows deduplicates the same signed frame", () => { + const timestamp = "2026-07-13T21:00:00.000Z"; + const sourceEventId = "a".repeat(64); + const live = { seq: 1, timestamp, sourceEventId }; + const archived = { seq: 9, timestamp, sourceEventId }; + const merged = mergeObserverEventWindows([live], [archived]); + assert.equal(merged.length, 1); + assert.equal(merged[0], live); +}); diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts index 19d62428424..7cbbd692603 100644 --- a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts @@ -21,7 +21,8 @@ export function scopeByChannel( * paged history loaded from SQLite — it extends the visible range beyond the cap. * * Deduplication: events present in both (e.g. a frame that arrived live and was - * also loaded from the archive) are collapsed to one entry by `(seq, timestamp)`. + * also loaded from the archive) are collapsed by signed source event id, with + * `(seq, timestamp)` retained only for legacy/E2E events. * The live copy is preferred when a duplicate exists, since the live path may * have applied incremental transcript mutations via `processTranscriptEvent`. * @@ -36,9 +37,13 @@ export function mergeObserverEventWindows( if (liveEvents.length === 0) return archivedEvents as ObserverEvent[]; // Dedup key: same as appendAgentEvent / appendArchivedChannelEvent. - const liveKeySet = new Set(liveEvents.map((e) => `${e.seq}:${e.timestamp}`)); + const eventKey = (event: ObserverEvent) => + event.sourceEventId + ? `source:${event.sourceEventId}` + : `legacy:${event.seq}:${event.timestamp}`; + const liveKeySet = new Set(liveEvents.map(eventKey)); const uniqueArchived = archivedEvents.filter( - (e) => !liveKeySet.has(`${e.seq}:${e.timestamp}`), + (event) => !liveKeySet.has(eventKey(event)), ); if (uniqueArchived.length === 0) return liveEvents as ObserverEvent[]; @@ -68,7 +73,7 @@ export function mergeObserverEventWindows( * agentSessionTranscript.ts) — unique within one channel's combined window. */ export function observerEventScrollId(event: ObserverEvent): string { - return `${event.seq}:${event.timestamp}`; + return event.sourceEventId ?? `${event.seq}:${event.timestamp}`; } /** diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 578f98076cd..0b1ed864243 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -4,11 +4,23 @@ export type ObserverEvent = { seq: number; timestamp: string; kind: string; + /** Signed outer relay-event provenance; attached after successful decrypt. */ + sourceEventId?: string | null; + sourcePubkey?: string | null; + sourceKind?: number | null; + sourceCreatedAt?: number | null; + sourceSignature?: string | null; + origin?: "live_observer" | "historical_backfill" | null; agentIndex: number | null; channelId: string | null; sessionId: string | null; turnId: string | null; startedAt?: string | null; + /** Optional semantic fields used only by explicit journal events. */ + journalKey?: string | null; + ownerModifiedAt?: string | null; + ownerModifiedBy?: string | null; + ownerModified?: boolean; payload: unknown; }; diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index 10fbb733ca9..90af860f94e 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -456,6 +456,95 @@ export async function readArchivedObserverEventsForChannel( .filter((e): e is import("@/shared/api/types").RelayEvent => e !== null); } +export type ArchivedObserverRangeCursor = { + createdAt: number; + id: string; +}; + +export type ArchivedObserverRangePage = { + events: import("@/shared/api/types").RelayEvent[]; + hasMore: boolean; + nextBefore: ArchivedObserverRangeCursor | null; +}; + +/** Read one durable, owner-scoped observer page for a half-open time range. */ +export async function readArchivedObserverEventsForRange(opts: { + startCreatedAt: number; + endCreatedAt: number; + agentPubkey?: string | null; + channelId?: string | null; + before?: ArchivedObserverRangeCursor | null; + limit?: number; +}): Promise { + const limit = opts.limit ?? 200; + if (!Number.isInteger(limit) || limit < 1 || limit > 500) { + throw new Error( + "Archived observer range limit must be an integer from 1 to 500.", + ); + } + const rawRows = await invokeTauri( + "read_archived_observer_events_for_range", + { + startCreatedAt: opts.startCreatedAt, + endCreatedAt: opts.endCreatedAt, + agentPubkey: opts.agentPubkey ?? null, + channelId: opts.channelId ?? null, + beforeCreatedAt: opts.before?.createdAt ?? null, + beforeId: opts.before?.id ?? null, + limit, + }, + ); + const events = rawRows + .map((raw) => { + try { + return JSON.parse(raw) as import("@/shared/api/types").RelayEvent; + } catch { + console.warn( + "[tauriArchive] failed to parse ranged observer raw_json:", + raw, + ); + return null; + } + }) + .filter( + (event): event is import("@/shared/api/types").RelayEvent => + event !== null, + ); + const oldest = events.at(-1) ?? null; + return { + events, + hasMore: rawRows.length === limit, + nextBefore: oldest ? { createdAt: oldest.created_at, id: oldest.id } : null, + }; +} + +/** Exhaust the paginated range without silently truncating a busy Today view. */ +export async function readAllArchivedObserverEventsForRange(opts: { + startCreatedAt: number; + endCreatedAt: number; + agentPubkey?: string | null; + channelId?: string | null; + pageSize?: number; +}): Promise { + const all: import("@/shared/api/types").RelayEvent[] = []; + let before: ArchivedObserverRangeCursor | null = null; + for (;;) { + const page = await readArchivedObserverEventsForRange({ + ...opts, + before, + limit: opts.pageSize ?? 200, + }); + all.push(...page.events); + if (!page.hasMore) return all; + if (!page.nextBefore) { + throw new Error( + "Archived observer range reported more rows without a cursor.", + ); + } + before = page.nextBefore; + } +} + /** * Index one or more archived observer frames by channelId. * From dbeeba31fadf64268564ffa437c47d526abb0e1c Mon Sep 17 00:00:00 2001 From: Garfield Lawrence <3stepwin@gmail.com> Date: Fri, 21 Aug 2026 11:43:12 -0400 Subject: [PATCH 02/30] Close Activity Ledger durability and authority gaps Constraint: preserve Buzz mission, governance, proof, and running signed-app architecture. Rejected: agent-authored verification and direct SQLite Honey reads | they bypass owner authority and provenance. Confidence: high Scope-risk: moderate Directive: never promote tool completion, lifecycle completion, or agent claims to VERIFIED. Tested: desktop 3811/3811; archive 83/83; buzz-agent full suite; typecheck; lint; build; cargo workspace check; formatting; file-size gates. Not-tested: replacement of the installed Developer-ID-signed app; relay frames killed before callback entry. Signed-off-by: Garfield Lawrence <3stepwin@gmail.com> --- crates/buzz-agent/Cargo.toml | 4 +- crates/buzz-agent/src/agent.rs | 11 + crates/buzz-agent/src/builtin.rs | 790 +++++++++++++++++- crates/buzz-agent/tests/hints_integration.rs | 106 +++ .../src/archive/journal_authority.rs | 639 ++++++++++++++ .../src/archive/journal_authority_tests.rs | 276 ++++++ desktop/src-tauri/src/archive/mod.rs | 114 +++ desktop/src-tauri/src/archive/store.rs | 22 + .../src-tauri/src/archive/today_snapshot.rs | 341 ++++++++ desktop/src-tauri/src/lib.rs | 6 + .../src/managed_agents/activity_ledger_env.rs | 67 ++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/reserved_env_keys.rs | 5 + .../src-tauri/src/managed_agents/runtime.rs | 2 +- desktop/src/app/AppShell.tsx | 2 + .../agents/activityLedgerAuthority.test.mjs | 115 +++ .../agents/activityLedgerAuthority.ts | 139 +++ .../agents/activityLedgerToday.test.mjs | 144 ++++ .../features/agents/activityLedgerToday.ts | 185 ++++ .../agents/ui/ManagedAgentSessionPanel.tsx | 202 ++++- .../agents/useActivityLedgerTodaySnapshot.ts | 156 ++++ desktop/src/shared/api/tauriArchive.ts | 108 +++ 22 files changed, 3427 insertions(+), 8 deletions(-) create mode 100644 desktop/src-tauri/src/archive/journal_authority.rs create mode 100644 desktop/src-tauri/src/archive/journal_authority_tests.rs create mode 100644 desktop/src-tauri/src/archive/today_snapshot.rs create mode 100644 desktop/src-tauri/src/managed_agents/activity_ledger_env.rs create mode 100644 desktop/src/features/agents/activityLedgerAuthority.test.mjs create mode 100644 desktop/src/features/agents/activityLedgerAuthority.ts create mode 100644 desktop/src/features/agents/activityLedgerToday.test.mjs create mode 100644 desktop/src/features/agents/activityLedgerToday.ts create mode 100644 desktop/src/features/agents/useActivityLedgerTodaySnapshot.ts diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index fabf75754e1..758a84bcf86 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -47,11 +47,11 @@ webbrowser = "1" dirs = "6" [target.'cfg(unix)'.dependencies] -nix = { version = "0.31", default-features = false, features = ["signal", "process"] } +nix = { version = "0.31", default-features = false, features = ["fs", "signal", "process"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] } -nix = { version = "0.31", default-features = false, features = ["signal", "process"] } +nix = { version = "0.31", default-features = false, features = ["fs", "signal", "process"] } axum = { workspace = true } hex = { workspace = true } serde = { workspace = true } diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 9258ce449f3..d050149f2e4 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -382,6 +382,9 @@ impl RunCtx<'_> { if !self.skills.is_empty() { tools.push(builtin::load_skill_def()); } + if builtin::activity_ledger_today_enabled() { + tools.push(builtin::activity_ledger_today_def()); + } round = round.saturating_add(1); let response_result = tokio::select! { biased; @@ -818,6 +821,14 @@ impl RunCtx<'_> { results[idx] = Some(result); continue; } + if call.name == builtin::ACTIVITY_LEDGER_TODAY_TOOL { + emit_in_progress(self.wire, self.session_id, call).await; + let mut result = builtin::call_activity_ledger_today(&call.arguments).await; + result.provider_id = call.provider_id.clone(); + emit_completed(self.wire, self.session_id, call, &result).await; + results[idx] = Some(result); + continue; + } // Hook tools (bare name starts with `_`) are invisible to the // LLM and only callable via `call_hooks`. Treat any direct diff --git a/crates/buzz-agent/src/builtin.rs b/crates/buzz-agent/src/builtin.rs index 9b604766d42..ae406b3ebb0 100644 --- a/crates/buzz-agent/src/builtin.rs +++ b/crates/buzz-agent/src/builtin.rs @@ -4,13 +4,21 @@ //! and returns it so the agent can load skill content on demand rather than //! having every skill inlined into the system prompt at session start. -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use crate::hints::{strip_frontmatter, SkillEntry, MAX_SKILL_BODY_BYTES}; use crate::mcp::truncate_at_boundary; use crate::types::{ToolDef, ToolResult, ToolResultContent}; pub const LOAD_SKILL_TOOL: &str = "load_skill"; +pub const ACTIVITY_LEDGER_TODAY_TOOL: &str = "get_activity_ledger_today"; +const ACTIVITY_LEDGER_TODAY_SCHEMA: &str = "buzz.activity-ledger.today/v1"; +const ACTIVITY_LEDGER_TODAY_PATH_ENV: &str = "BUZZ_ACTIVITY_LEDGER_TODAY_PATH"; +const ACTIVITY_LEDGER_TODAY_CAPABILITY_ENV: &str = "BUZZ_ACTIVITY_LEDGER_TODAY_CAPABILITY"; +const ACTIVITY_LEDGER_MAX_LIFETIME_SECS: u64 = 24 * 60 * 60; +const ACTIVITY_LEDGER_MAX_SNAPSHOT_BYTES: u64 = 8 * 1024 * 1024; +const ACTIVITY_LEDGER_MAX_FUTURE_GENERATED_AT_SECS: u64 = 300; +const ACTIVITY_LEDGER_DEFAULT_LIMIT: usize = 25; /// Return the `ToolDef` for `load_skill` to include in the LLM tool list. pub fn load_skill_def() -> ToolDef { @@ -36,6 +44,51 @@ pub fn load_skill_def() -> ToolDef { } } +/// Returns true when the runtime was explicitly provisioned with a local +/// Desktop-authored Today snapshot and matching capability marker. +pub fn activity_ledger_today_enabled() -> bool { + env_non_empty(ACTIVITY_LEDGER_TODAY_PATH_ENV).is_some() + && env_non_empty(ACTIVITY_LEDGER_TODAY_CAPABILITY_ENV).is_some() +} + +/// Return the `ToolDef` for `get_activity_ledger_today`. +pub fn activity_ledger_today_def() -> ToolDef { + ToolDef { + name: ACTIVITY_LEDGER_TODAY_TOOL.to_owned(), + description: "Read the owner-authorized Buzz Activity Ledger Today snapshot from a local Desktop-produced file. Fails closed if the snapshot is missing, stale, misconfigured, or does not match the configured capability." + .to_owned(), + input_schema: json!({ + "type": "object", + "properties": { + "channelId": { + "type": "string", + "description": "Optional exact channel id filter." + }, + "agentPubkey": { + "type": "string", + "description": "Optional exact agent pubkey filter." + }, + "status": { + "type": "string", + "description": "Optional exact mission journal status filter." + }, + "proofState": { + "type": "string", + "description": "Optional exact proof state filter." + }, + "limit": { + "type": "integer", + "description": "Maximum journals to return, from 1 to 100." + }, + "includeEvents": { + "type": "boolean", + "description": "When true, include each journal's normalized events. Defaults to false." + } + } + }), + } +} + /// Execute a `load_skill` call. Returns a `ToolResult` on success or a /// user-visible error result if the skill is not found or cannot be read. pub async fn call_load_skill(arguments: &Value, skills: &[SkillEntry]) -> ToolResult { @@ -112,6 +165,503 @@ pub async fn call_load_skill(arguments: &Value, skills: &[SkillEntry]) -> ToolRe } } +/// Execute a `get_activity_ledger_today` call against a Desktop-authored local +/// snapshot. Fails closed on missing env, unsafe file properties, schema or +/// capability mismatch, or stale data. +pub async fn call_activity_ledger_today(arguments: &Value) -> ToolResult { + let Some(path) = env_non_empty(ACTIVITY_LEDGER_TODAY_PATH_ENV) else { + return error_result(&format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: missing {ACTIVITY_LEDGER_TODAY_PATH_ENV}" + )); + }; + let Some(capability) = env_non_empty(ACTIVITY_LEDGER_TODAY_CAPABILITY_ENV) else { + return error_result(&format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: missing {ACTIVITY_LEDGER_TODAY_CAPABILITY_ENV}" + )); + }; + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let arguments = arguments.clone(); + match tokio::task::spawn_blocking(move || { + read_activity_ledger_today(&path, &capability, &arguments, now_secs) + }) + .await + .unwrap_or_else(|e| Err(format!("{ACTIVITY_LEDGER_TODAY_TOOL}: task failed: {e}"))) + { + Ok(output) => ToolResult { + provider_id: String::new(), + content: vec![ToolResultContent::Text(output)], + is_error: false, + }, + Err(msg) => error_result(&msg), + } +} + +fn env_non_empty(name: &str) -> Option { + std::env::var(name).ok().and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) + }) +} + +#[derive(Clone)] +struct ActivityLedgerQuery { + channel_id: Option, + agent_pubkey: Option, + status: Option, + proof_state: Option, + limit: usize, + include_events: bool, +} + +fn read_activity_ledger_today( + path: &str, + capability: &str, + arguments: &Value, + now_secs: u64, +) -> Result { + let query = parse_activity_ledger_query(arguments)?; + let path_buf = std::path::PathBuf::from(path); + if !path_buf.is_absolute() { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot path must be absolute" + )); + } + let body = read_activity_ledger_snapshot_body(&path_buf)?; + let root: Value = serde_json::from_str(&body) + .map_err(|e| format!("{ACTIVITY_LEDGER_TODAY_TOOL}: invalid snapshot JSON: {e}"))?; + let result = filter_activity_ledger_snapshot(&root, capability, &query, now_secs)?; + serde_json::to_string_pretty(&result) + .map_err(|e| format!("{ACTIVITY_LEDGER_TODAY_TOOL}: could not serialize query result: {e}")) +} + +#[cfg(unix)] +fn read_activity_ledger_snapshot_body(path: &std::path::Path) -> Result { + use nix::errno::Errno; + use nix::fcntl::{open, OFlag}; + use nix::sys::stat::{fstat, Mode, SFlag}; + use std::io::Read; + use std::os::fd::OwnedFd; + use std::os::unix::fs::PermissionsExt; + + let fd: OwnedFd = + open(path, OFlag::O_RDONLY | OFlag::O_NOFOLLOW, Mode::empty()).map_err(|e| { + if e == Errno::ELOOP { + format!("{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot path must not be a symlink") + } else { + format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: could not open snapshot {:?}: {e}", + path + ) + } + })?; + let stat = fstat(&fd).map_err(|e| { + format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: could not stat opened snapshot {:?}: {e}", + path + ) + })?; + if SFlag::from_bits_truncate(stat.st_mode) != SFlag::S_IFREG { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot path must be a regular file" + )); + } + let mode = stat.st_mode & 0o777; + if mode != 0o600 { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot mode must be 0600, got {:03o}", + mode + )); + } + + let mut file = std::fs::File::from(fd); + let metadata = file.metadata().map_err(|e| { + format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: could not read snapshot metadata {:?}: {e}", + path + ) + })?; + let file_mode = metadata.permissions().mode() & 0o777; + if file_mode != 0o600 { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot mode must be 0600, got {:03o}", + file_mode + )); + } + if metadata.len() > ACTIVITY_LEDGER_MAX_SNAPSHOT_BYTES { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot exceeds {ACTIVITY_LEDGER_MAX_SNAPSHOT_BYTES} bytes" + )); + } + + let mut body = String::new(); + file.read_to_string(&mut body).map_err(|e| { + format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: could not read snapshot {:?}: {e}", + path + ) + })?; + Ok(body) +} + +#[cfg(not(unix))] +fn read_activity_ledger_snapshot_body(path: &std::path::Path) -> Result { + let symlink_meta = std::fs::symlink_metadata(path).map_err(|e| { + format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: could not stat snapshot {:?}: {e}", + path + ) + })?; + if symlink_meta.file_type().is_symlink() { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot path must not be a symlink" + )); + } + if !symlink_meta.file_type().is_file() { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot path must be a regular file" + )); + } + if symlink_meta.len() > ACTIVITY_LEDGER_MAX_SNAPSHOT_BYTES { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot exceeds {ACTIVITY_LEDGER_MAX_SNAPSHOT_BYTES} bytes" + )); + } + std::fs::read_to_string(path).map_err(|e| { + format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: could not read snapshot {:?}: {e}", + path + ) + }) +} + +fn parse_activity_ledger_query(arguments: &Value) -> Result { + let object = arguments + .as_object() + .ok_or_else(|| format!("{ACTIVITY_LEDGER_TODAY_TOOL}: arguments must be an object"))?; + Ok(ActivityLedgerQuery { + channel_id: optional_string_arg(object, "channelId")?, + agent_pubkey: optional_string_arg(object, "agentPubkey")?, + status: optional_string_arg(object, "status")?, + proof_state: optional_string_arg(object, "proofState")?, + limit: parse_limit_arg(object.get("limit"))?, + include_events: parse_bool_arg(object.get("includeEvents"))?, + }) +} + +fn optional_string_arg(object: &Map, key: &str) -> Result, String> { + match object.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: {key} must not be blank" + )) + } else { + Ok(Some(trimmed.to_owned())) + } + } + Some(_) => Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: {key} must be a string" + )), + } +} + +fn parse_limit_arg(value: Option<&Value>) -> Result { + match value { + None | Some(Value::Null) => Ok(ACTIVITY_LEDGER_DEFAULT_LIMIT), + Some(Value::Number(number)) => { + let Some(limit) = number.as_u64() else { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: limit must be an integer from 1 to 100" + )); + }; + if !(1..=100).contains(&limit) { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: limit must be an integer from 1 to 100" + )); + } + Ok(limit as usize) + } + Some(_) => Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: limit must be an integer from 1 to 100" + )), + } +} + +fn parse_bool_arg(value: Option<&Value>) -> Result { + match value { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(flag)) => Ok(*flag), + Some(_) => Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: includeEvents must be a boolean" + )), + } +} + +fn filter_activity_ledger_snapshot( + root: &Value, + capability: &str, + query: &ActivityLedgerQuery, + now_secs: u64, +) -> Result { + let object = root + .as_object() + .ok_or_else(|| format!("{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot root must be an object"))?; + require_string_field(object, "schema", ACTIVITY_LEDGER_TODAY_SCHEMA)?; + require_string_field(object, "capability", capability)?; + let owner_pubkey = required_string_field(object, "ownerPubkey")?; + if !is_hex_64(owner_pubkey) { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: ownerPubkey must be 64 lowercase hex chars" + )); + } + let generated_at = required_u64_field(object, "generatedAt")?; + let expires_at = required_u64_field(object, "expiresAt")?; + if generated_at > now_secs.saturating_add(ACTIVITY_LEDGER_MAX_FUTURE_GENERATED_AT_SECS) { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: generatedAt is more than {} seconds in the future", + ACTIVITY_LEDGER_MAX_FUTURE_GENERATED_AT_SECS + )); + } + if expires_at <= generated_at { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: expiresAt must be greater than generatedAt" + )); + } + if expires_at - generated_at > ACTIVITY_LEDGER_MAX_LIFETIME_SECS { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot lifetime exceeds {} seconds", + ACTIVITY_LEDGER_MAX_LIFETIME_SECS + )); + } + if now_secs >= expires_at { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot expired at {expires_at}" + )); + } + + let surface = object + .get("surface") + .and_then(Value::as_object) + .ok_or_else(|| { + format!("{ACTIVITY_LEDGER_TODAY_TOOL}: snapshot surface must be an object") + })?; + let day = required_string_field(surface, "day")?.to_owned(); + let journals = surface + .get("journals") + .and_then(Value::as_array) + .ok_or_else(|| { + format!("{ACTIVITY_LEDGER_TODAY_TOOL}: surface.journals must be an array") + })?; + + let mut filtered = Vec::new(); + for journal in journals { + if journal_matches_query(journal, query)? { + filtered.push(strip_events_from_journal(journal, query.include_events)?); + } + } + let matching_journals = filtered.len(); + let truncated = matching_journals > query.limit; + if truncated { + filtered.truncate(query.limit); + } + + let channels = rebuild_filtered_channels(&filtered); + let failed = filtered + .iter() + .filter(|journal| string_field(journal, "status") == Some("failed")) + .count(); + let in_progress = filtered + .iter() + .filter(|journal| string_field(journal, "status") == Some("in_progress")) + .count(); + let claimed_without_evidence = filtered + .iter() + .filter(|journal| bool_field(journal, "claimedCompletionWithoutEvidence")) + .count(); + + Ok(json!({ + "schema": "buzz.activity-ledger.today.query-result/v1", + "sourceSchema": ACTIVITY_LEDGER_TODAY_SCHEMA, + "day": day, + "ownerPubkey": owner_pubkey, + "generatedAt": generated_at, + "expiresAt": expires_at, + "capability": capability, + "filters": { + "channelId": query.channel_id, + "agentPubkey": query.agent_pubkey, + "status": query.status, + "proofState": query.proof_state, + "limit": query.limit, + "includeEvents": query.include_events, + }, + "counts": { + "matchingJournals": matching_journals, + "returnedJournals": filtered.len(), + "failed": failed, + "inProgress": in_progress, + "claimedWithoutEvidence": claimed_without_evidence, + }, + "truncated": truncated, + "journals": filtered, + "channels": channels, + })) +} + +fn journal_matches_query(journal: &Value, query: &ActivityLedgerQuery) -> Result { + let object = journal + .as_object() + .ok_or_else(|| format!("{ACTIVITY_LEDGER_TODAY_TOOL}: each journal must be an object"))?; + if let Some(channel_id) = &query.channel_id { + if object.get("channelId").and_then(Value::as_str) != Some(channel_id.as_str()) { + return Ok(false); + } + } + if let Some(agent_pubkey) = &query.agent_pubkey { + if object.get("agentPubkey").and_then(Value::as_str) != Some(agent_pubkey.as_str()) { + return Ok(false); + } + } + if let Some(status) = &query.status { + if object.get("status").and_then(Value::as_str) != Some(status.as_str()) { + return Ok(false); + } + } + if let Some(proof_state) = &query.proof_state { + if object.get("proofState").and_then(Value::as_str) != Some(proof_state.as_str()) { + return Ok(false); + } + } + Ok(true) +} + +fn strip_events_from_journal(journal: &Value, include_events: bool) -> Result { + if include_events { + return Ok(journal.clone()); + } + let mut object = journal + .as_object() + .cloned() + .ok_or_else(|| format!("{ACTIVITY_LEDGER_TODAY_TOOL}: each journal must be an object"))?; + object.remove("events"); + Ok(Value::Object(object)) +} + +fn rebuild_filtered_channels(journals: &[Value]) -> Vec { + let mut channels: std::collections::BTreeMap< + String, + ( + Vec, + std::collections::BTreeSet, + std::collections::BTreeSet, + String, + ), + > = std::collections::BTreeMap::new(); + + for journal in journals { + let Some(channel_id) = string_field(journal, "channelId").map(str::to_owned) else { + continue; + }; + let journal_id = string_field(journal, "id").unwrap_or_default().to_owned(); + let agent_pubkey = string_field(journal, "agentPubkey") + .unwrap_or_default() + .to_owned(); + let agent_name = string_field(journal, "agentName") + .unwrap_or_default() + .to_owned(); + let ended_at = string_field(journal, "endedAt") + .unwrap_or_default() + .to_owned(); + + let entry = channels.entry(channel_id).or_insert_with(|| { + ( + Vec::new(), + std::collections::BTreeSet::new(), + std::collections::BTreeSet::new(), + ended_at.clone(), + ) + }); + entry.0.push(journal_id); + if !agent_pubkey.is_empty() { + entry.1.insert(agent_pubkey); + } + if !agent_name.is_empty() { + entry.2.insert(agent_name); + } + if ended_at > entry.3 { + entry.3 = ended_at; + } + } + + channels + .into_iter() + .map( + |(channel_id, (journal_ids, agent_pubkeys, agent_names, last_activity_at))| { + json!({ + "channelId": channel_id, + "journalIds": journal_ids, + "agentPubkeys": agent_pubkeys.into_iter().collect::>(), + "agentNames": agent_names.into_iter().collect::>(), + "lastActivityAt": last_activity_at, + }) + }, + ) + .collect() +} + +fn require_string_field( + object: &Map, + key: &str, + expected: &str, +) -> Result<(), String> { + let value = required_string_field(object, key)?; + if value != expected { + return Err(format!( + "{ACTIVITY_LEDGER_TODAY_TOOL}: {key} mismatch: expected {expected:?}, got {value:?}" + )); + } + Ok(()) +} + +fn required_string_field<'a>(object: &'a Map, key: &str) -> Result<&'a str, String> { + object + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| format!("{ACTIVITY_LEDGER_TODAY_TOOL}: missing string field {key}")) +} + +fn required_u64_field(object: &Map, key: &str) -> Result { + object + .get(key) + .and_then(Value::as_u64) + .ok_or_else(|| format!("{ACTIVITY_LEDGER_TODAY_TOOL}: missing integer field {key}")) +} + +fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.as_object()?.get(key)?.as_str() +} + +fn bool_field(value: &Value, key: &str) -> bool { + value + .as_object() + .and_then(|object| object.get(key)) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn is_hex_64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + /// Load a supporting file identified by `skill_name/rel_path`. /// Matches against the pre-enumerated `supporting_files` list and applies a /// canonicalize-based traversal guard before reading. @@ -273,6 +823,71 @@ mod tests { } } + fn write_activity_snapshot( + dir: &TempDir, + capability: &str, + generated_at: u64, + expires_at: u64, + ) -> PathBuf { + let path = dir.path().join("activity-ledger-today.json"); + let snapshot = json!({ + "schema": ACTIVITY_LEDGER_TODAY_SCHEMA, + "ownerPubkey": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "generatedAt": generated_at, + "expiresAt": expires_at, + "capability": capability, + "surface": { + "day": "2026-08-21", + "journals": [ + { + "id": "journal-a", + "channelId": "chan-a", + "agentPubkey": "agent-a", + "agentName": "Honey", + "status": "completed", + "proofState": "RECEIPTED", + "endedAt": "2026-08-21T14:00:00.000Z", + "claimedCompletionWithoutEvidence": false, + "events": [ + { "id": "event-a", "detail": "receipted activity" } + ] + }, + { + "id": "journal-b", + "channelId": "chan-b", + "agentPubkey": "agent-b", + "agentName": "Fizz", + "status": "failed", + "proofState": "FAILED", + "endedAt": "2026-08-21T15:00:00.000Z", + "claimedCompletionWithoutEvidence": true, + "events": [ + { "id": "event-b", "detail": "failed activity" } + ] + } + ] + } + }); + std::fs::write(&path, serde_json::to_vec(&snapshot).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + path + } + + fn write_custom_activity_snapshot(dir: &TempDir, snapshot: Value) -> PathBuf { + let path = dir.path().join("activity-ledger-today.json"); + std::fs::write(&path, serde_json::to_vec(&snapshot).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + path + } + #[tokio::test] async fn call_load_skill_missing_name_arg() { let result = call_load_skill(&serde_json::json!({}), &[]).await; @@ -572,4 +1187,177 @@ mod tests { "missing supporting-file header: {text}" ); } + + #[test] + fn activity_ledger_today_filters_and_strips_events_by_default() { + let tmp = TempDir::new().unwrap(); + let capability = "buzz.activity-ledger.today.read/v1"; + let path = write_activity_snapshot(&tmp, capability, 100, 160); + + let output = read_activity_ledger_today( + path.to_str().unwrap(), + capability, + &json!({"agentPubkey": "agent-a", "limit": 10}), + 120, + ) + .unwrap(); + let result: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(result["counts"]["matchingJournals"], 1); + assert_eq!(result["counts"]["returnedJournals"], 1); + assert_eq!(result["journals"][0]["id"], "journal-a"); + assert!(result["journals"][0].get("events").is_none()); + assert_eq!(result["channels"][0]["channelId"], "chan-a"); + } + + #[test] + fn activity_ledger_today_includes_events_when_requested() { + let tmp = TempDir::new().unwrap(); + let capability = "buzz.activity-ledger.today.read/v1"; + let path = write_activity_snapshot(&tmp, capability, 100, 160); + + let output = read_activity_ledger_today( + path.to_str().unwrap(), + capability, + &json!({"channelId": "chan-b", "includeEvents": true, "limit": 10}), + 120, + ) + .unwrap(); + let result: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(result["journals"][0]["id"], "journal-b"); + assert_eq!(result["journals"][0]["events"][0]["id"], "event-b"); + assert_eq!(result["counts"]["failed"], 1); + assert_eq!(result["counts"]["claimedWithoutEvidence"], 1); + } + + #[test] + fn activity_ledger_today_rejects_relative_path() { + let error = read_activity_ledger_today( + "relative.json", + "buzz.activity-ledger.today.read/v1", + &json!({}), + 120, + ) + .unwrap_err(); + assert!( + error.contains("snapshot path must be absolute"), + "got: {error}" + ); + } + + #[test] + fn activity_ledger_today_rejects_oversized_snapshot_before_reading() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("oversized.json"); + let file = std::fs::File::create(&path).unwrap(); + file.set_len(ACTIVITY_LEDGER_MAX_SNAPSHOT_BYTES + 1) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + let error = read_activity_ledger_today( + path.to_str().unwrap(), + "buzz.activity-ledger.today.read/v1", + &json!({}), + 120, + ) + .unwrap_err(); + assert!(error.contains("snapshot exceeds"), "got: {error}"); + } + + #[test] + fn activity_ledger_today_rejects_capability_mismatch_and_staleness() { + let tmp = TempDir::new().unwrap(); + let capability = "buzz.activity-ledger.today.read/v1"; + let path = write_activity_snapshot(&tmp, "wrong-capability", 100, 160); + + let capability_error = + read_activity_ledger_today(path.to_str().unwrap(), capability, &json!({}), 120) + .unwrap_err(); + assert!( + capability_error.contains("capability mismatch"), + "got: {capability_error}" + ); + + let stale_path = write_activity_snapshot(&tmp, capability, 100, 110); + let stale_error = + read_activity_ledger_today(stale_path.to_str().unwrap(), capability, &json!({}), 120) + .unwrap_err(); + assert!( + stale_error.contains("snapshot expired"), + "got: {stale_error}" + ); + } + + #[test] + fn activity_ledger_today_rejects_future_generated_at_and_uppercase_owner() { + let tmp = TempDir::new().unwrap(); + let capability = "buzz.activity-ledger.today.read/v1"; + + let future_path = write_activity_snapshot(&tmp, capability, 500, 560); + let future_error = + read_activity_ledger_today(future_path.to_str().unwrap(), capability, &json!({}), 120) + .unwrap_err(); + assert!( + future_error.contains("generatedAt is more than 300 seconds in the future"), + "got: {future_error}" + ); + + let uppercase_owner_path = write_custom_activity_snapshot( + &tmp, + json!({ + "schema": ACTIVITY_LEDGER_TODAY_SCHEMA, + "ownerPubkey": "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD", + "generatedAt": 100, + "expiresAt": 160, + "capability": capability, + "surface": { + "day": "2026-08-21", + "journals": [] + } + }), + ); + let owner_error = read_activity_ledger_today( + uppercase_owner_path.to_str().unwrap(), + capability, + &json!({}), + 120, + ) + .unwrap_err(); + assert!( + owner_error.contains("ownerPubkey must be 64 lowercase hex chars"), + "got: {owner_error}" + ); + } + + #[cfg(unix)] + #[test] + fn activity_ledger_today_rejects_symlink_and_non_0600_mode() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let tmp = TempDir::new().unwrap(); + let capability = "buzz.activity-ledger.today.read/v1"; + let path = write_activity_snapshot(&tmp, capability, 100, 160); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let mode_error = + read_activity_ledger_today(path.to_str().unwrap(), capability, &json!({}), 120) + .unwrap_err(); + assert!( + mode_error.contains("snapshot mode must be 0600"), + "got: {mode_error}" + ); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let link_path = tmp.path().join("linked.json"); + symlink(&path, &link_path).unwrap(); + let symlink_error = + read_activity_ledger_today(link_path.to_str().unwrap(), capability, &json!({}), 120) + .unwrap_err(); + assert!( + symlink_error.contains("must not be a symlink") + || symlink_error.contains("could not open snapshot"), + "got: {symlink_error}" + ); + } } diff --git a/crates/buzz-agent/tests/hints_integration.rs b/crates/buzz-agent/tests/hints_integration.rs index 63a55514dbe..6486c56450e 100644 --- a/crates/buzz-agent/tests/hints_integration.rs +++ b/crates/buzz-agent/tests/hints_integration.rs @@ -572,3 +572,109 @@ async fn load_skill_tool_returns_body() { ); h.shutdown().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn activity_ledger_today_tool_returns_filtered_snapshot() { + let tmp = tempfile::TempDir::new().unwrap(); + let cwd = tmp.path(); + let snapshot_path = cwd.join("activity-ledger-today.json"); + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let snapshot = json!({ + "schema": "buzz.activity-ledger.today/v1", + "ownerPubkey": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "generatedAt": now_secs.saturating_sub(60), + "expiresAt": now_secs + 300, + "capability": "buzz.activity-ledger.today.read/v1", + "surface": { + "day": "2026-08-21", + "journals": [ + { + "id": "journal-a", + "channelId": "chan-a", + "agentPubkey": "agent-a", + "agentName": "Honey", + "status": "completed", + "proofState": "RECEIPTED", + "endedAt": "2026-08-21T14:00:00.000Z", + "claimedCompletionWithoutEvidence": false, + "events": [{ "id": "event-a", "detail": "receipted activity" }] + } + ] + } + }); + std::fs::write(&snapshot_path, serde_json::to_vec(&snapshot).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&snapshot_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + + let tool_call = json!({ + "id": "cc-ledger", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", "content": null, + "tool_calls": [{ + "id": "tc-ledger", "type": "function", + "function": { + "name": "get_activity_ledger_today", + "arguments": "{\"agentPubkey\":\"agent-a\"}" + } + }] + }, + "finish_reason": "tool_calls" + }] + }); + let end_turn = openai_text("done"); + + let llm = spawn_capturing_llm(vec![tool_call, end_turn]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ( + "BUZZ_ACTIVITY_LEDGER_TODAY_PATH", + snapshot_path.to_str().unwrap(), + ), + ( + "BUZZ_ACTIVITY_LEDGER_TODAY_CAPABILITY", + "buzz.activity-ledger.today.read/v1", + ), + ], + ) + .await; + let sid = init_session(&mut h, cwd.to_str().unwrap()).await; + + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"What did Honey do today?"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p)).await; + + let reqs = llm.captured.lock().await; + assert!( + reqs.len() >= 2, + "expected at least 2 LLM requests, got {}", + reqs.len() + ); + let round1 = serde_json::to_string(&reqs[0]).unwrap(); + assert!( + round1.contains("get_activity_ledger_today"), + "tool was not advertised in round 1: {round1}" + ); + let round2 = serde_json::to_string(&reqs[1]).unwrap(); + assert!( + round2.contains("journal-a"), + "tool result missing filtered journal: {round2}" + ); + assert!( + !round2.contains("\"events\""), + "events should be stripped by default: {round2}" + ); + h.shutdown().await; +} diff --git a/desktop/src-tauri/src/archive/journal_authority.rs b/desktop/src-tauri/src/archive/journal_authority.rs new file mode 100644 index 00000000000..30a4d777fc8 --- /dev/null +++ b/desktop/src-tauri/src/archive/journal_authority.rs @@ -0,0 +1,639 @@ +//! Signed, durable authority records for Activity Ledger journals. +//! +//! Observer frames are evidence, not authority. These records let the active +//! owner explicitly override a journal summary or independently attest that a +//! receipt verifies a journal. The complete Nostr event is stored locally and +//! its id, signature, signer, schema, tags, and content bindings are validated +//! both before insertion and on every read. + +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +pub const KIND_JOURNAL_AUTHORITY: u16 = 24201; +const ARTIFACT_SCHEMA: &str = "buzz.activity-journal-authority/v1"; +const ARTIFACT_MARKER: &str = "buzz-activity-journal"; +const MAX_JOURNAL_ID_CHARS: usize = 512; +const MAX_CORRELATION_ID_CHARS: usize = 512; +const MAX_TEXT_CHARS: usize = 20_000; +const MAX_RECEIPT_REF_CHARS: usize = 2_048; +const MAX_SOURCE_EVENTS: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JournalAuthorityArtifactType { + OwnerOverride, + Verification, +} + +impl JournalAuthorityArtifactType { + fn as_str(self) -> &'static str { + match self { + Self::OwnerOverride => "owner_override", + Self::Verification => "verification", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SignedArtifactContent { + schema: String, + artifact_type: JournalAuthorityArtifactType, + journal_id: String, + correlation_id: String, + revision: i64, + summary: Option, + note: Option, + receipt_ref: Option, + source_event_ids: Vec, +} + +/// Validated wire response. Secret key material is never serialized. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JournalAuthorityArtifact { + pub owner_pubkey: String, + pub event_id: String, + pub signature: String, + pub created_at: i64, + pub artifact_type: JournalAuthorityArtifactType, + pub journal_id: String, + pub correlation_id: String, + pub revision: i64, + pub summary: Option, + pub note: Option, + pub receipt_ref: Option, + pub source_event_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OwnerJournalOverrideInput { + pub journal_id: String, + pub correlation_id: String, + pub summary: String, + pub note: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JournalVerificationInput { + pub journal_id: String, + pub correlation_id: String, + pub receipt_ref: String, + pub source_event_ids: Vec, +} + +#[derive(Debug)] +struct StoredArtifactRow { + identity_pubkey: String, + journal_id: String, + artifact_type: String, + event_id: String, + created_at: i64, + revision: i64, + raw_json: String, +} + +fn checked_nonempty(value: &str, label: &str, max_chars: usize) -> Result { + let value = value.trim(); + let len = value.chars().count(); + if len == 0 || len > max_chars { + return Err(format!( + "{label} must contain between 1 and {max_chars} characters" + )); + } + Ok(value.to_owned()) +} + +fn checked_optional_text( + value: Option<&str>, + label: &str, + max_chars: usize, +) -> Result, String> { + value + .map(|text| checked_nonempty(text, label, max_chars)) + .transpose() +} + +fn normalize_source_event_ids(values: &[String]) -> Result, String> { + if values.is_empty() || values.len() > MAX_SOURCE_EVENTS { + return Err(format!( + "verification must bind between 1 and {MAX_SOURCE_EVENTS} source event IDs" + )); + } + let mut normalized = Vec::with_capacity(values.len()); + for value in values { + let value = value.trim().to_ascii_lowercase(); + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("source event IDs must be 64-character hexadecimal Nostr IDs".into()); + } + normalized.push(value); + } + normalized.sort(); + normalized.dedup(); + if normalized.len() != values.len() { + return Err("verification source event IDs must be unique".into()); + } + Ok(normalized) +} + +fn single_tag(event: &Event, name: &str) -> Result { + let values = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() == 2 && parts[0] == name).then(|| parts[1].clone()) + }) + .collect::>(); + if values.len() != 1 { + return Err(format!( + "journal authority event must contain exactly one {name:?} tag" + )); + } + Ok(values[0].clone()) +} + +fn repeated_tags(event: &Event, name: &str) -> Vec { + event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() == 2 && parts[0] == name).then(|| parts[1].clone()) + }) + .collect() +} + +fn validate_content(content: &SignedArtifactContent) -> Result<(), String> { + if content.schema != ARTIFACT_SCHEMA { + return Err("unsupported journal authority artifact schema".into()); + } + checked_nonempty(&content.journal_id, "journalId", MAX_JOURNAL_ID_CHARS)?; + checked_nonempty( + &content.correlation_id, + "correlationId", + MAX_CORRELATION_ID_CHARS, + )?; + if content.revision < 1 { + return Err("journal authority revision must be positive".into()); + } + match content.artifact_type { + JournalAuthorityArtifactType::OwnerOverride => { + let summary = content + .summary + .as_deref() + .ok_or_else(|| "owner override is missing summary".to_string())?; + checked_nonempty(summary, "summary", MAX_TEXT_CHARS)?; + checked_optional_text(content.note.as_deref(), "note", MAX_TEXT_CHARS)?; + if content.receipt_ref.is_some() || !content.source_event_ids.is_empty() { + return Err("owner override cannot contain verification evidence".into()); + } + } + JournalAuthorityArtifactType::Verification => { + if content.summary.is_some() || content.note.is_some() { + return Err("verification artifact cannot override owner text".into()); + } + let receipt_ref = content + .receipt_ref + .as_deref() + .ok_or_else(|| "verification artifact is missing receiptRef".to_string())?; + checked_nonempty(receipt_ref, "receiptRef", MAX_RECEIPT_REF_CHARS)?; + let normalized = normalize_source_event_ids(&content.source_event_ids)?; + if normalized != content.source_event_ids { + return Err("verification source event IDs must be sorted and normalized".into()); + } + } + } + Ok(()) +} + +fn artifact_from_event(event: &Event, content: SignedArtifactContent) -> JournalAuthorityArtifact { + JournalAuthorityArtifact { + owner_pubkey: event.pubkey.to_hex(), + event_id: event.id.to_hex(), + signature: event.sig.to_string(), + created_at: event.created_at.as_secs() as i64, + artifact_type: content.artifact_type, + journal_id: content.journal_id, + correlation_id: content.correlation_id, + revision: content.revision, + summary: content.summary, + note: content.note, + receipt_ref: content.receipt_ref, + source_event_ids: content.source_event_ids, + } +} + +/// Parse and verify every signed field. This is called on insert and read. +pub fn validate_signed_artifact( + raw_json: &str, + expected_owner_pubkey: &str, +) -> Result { + let event = Event::from_json(raw_json) + .map_err(|error| format!("parse journal authority event: {error}"))?; + event + .verify() + .map_err(|error| format!("journal authority signature verification failed: {error}"))?; + if event.kind.as_u16() != KIND_JOURNAL_AUTHORITY { + return Err(format!( + "journal authority event must use kind {KIND_JOURNAL_AUTHORITY}" + )); + } + if event.pubkey.to_hex() != expected_owner_pubkey { + return Err("journal authority event signer is not the active owner identity".into()); + } + + let content: SignedArtifactContent = serde_json::from_str(&event.content) + .map_err(|error| format!("parse journal authority content: {error}"))?; + validate_content(&content)?; + + if single_tag(&event, "t")? != ARTIFACT_MARKER + || single_tag(&event, "artifact_type")? != content.artifact_type.as_str() + || single_tag(&event, "journal_id")? != content.journal_id + || single_tag(&event, "correlation_id")? != content.correlation_id + || single_tag(&event, "revision")? != content.revision.to_string() + { + return Err("journal authority tags do not match signed content".into()); + } + + match content.artifact_type { + JournalAuthorityArtifactType::OwnerOverride => { + if !repeated_tags(&event, "receipt_ref").is_empty() + || !repeated_tags(&event, "source_event").is_empty() + { + return Err("owner override contains verification-only tags".into()); + } + } + JournalAuthorityArtifactType::Verification => { + if single_tag(&event, "receipt_ref")? != content.receipt_ref.as_deref().unwrap_or("") { + return Err("verification receipt tag does not match signed content".into()); + } + let mut tagged_sources = repeated_tags(&event, "source_event"); + tagged_sources.sort(); + if tagged_sources != content.source_event_ids { + return Err("verification source-event tags do not match signed content".into()); + } + } + } + + Ok(artifact_from_event(&event, content)) +} + +fn tag(name: &str, value: &str) -> Result { + Tag::parse([name, value]).map_err(|error| format!("build {name} tag: {error}")) +} + +fn build_signed_artifact(keys: &Keys, content: SignedArtifactContent) -> Result { + validate_content(&content)?; + let mut tags = vec![ + tag("t", ARTIFACT_MARKER)?, + tag("artifact_type", content.artifact_type.as_str())?, + tag("journal_id", &content.journal_id)?, + tag("correlation_id", &content.correlation_id)?, + tag("revision", &content.revision.to_string())?, + ]; + if let Some(receipt_ref) = &content.receipt_ref { + tags.push(tag("receipt_ref", receipt_ref)?); + } + for source_event_id in &content.source_event_ids { + tags.push(tag("source_event", source_event_id)?); + } + let content_json = serde_json::to_string(&content) + .map_err(|error| format!("serialize journal authority content: {error}"))?; + EventBuilder::new(Kind::Custom(KIND_JOURNAL_AUTHORITY), content_json) + .tags(tags) + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign journal authority event: {error}")) +} + +pub fn build_owner_override_event( + keys: &Keys, + input: &OwnerJournalOverrideInput, + revision: i64, +) -> Result { + let content = SignedArtifactContent { + schema: ARTIFACT_SCHEMA.to_string(), + artifact_type: JournalAuthorityArtifactType::OwnerOverride, + journal_id: checked_nonempty(&input.journal_id, "journalId", MAX_JOURNAL_ID_CHARS)?, + correlation_id: checked_nonempty( + &input.correlation_id, + "correlationId", + MAX_CORRELATION_ID_CHARS, + )?, + revision, + summary: Some(checked_nonempty(&input.summary, "summary", MAX_TEXT_CHARS)?), + note: checked_optional_text(input.note.as_deref(), "note", MAX_TEXT_CHARS)?, + receipt_ref: None, + source_event_ids: Vec::new(), + }; + build_signed_artifact(keys, content) +} + +pub fn build_verification_event( + keys: &Keys, + input: &JournalVerificationInput, + revision: i64, +) -> Result { + let content = SignedArtifactContent { + schema: ARTIFACT_SCHEMA.to_string(), + artifact_type: JournalAuthorityArtifactType::Verification, + journal_id: checked_nonempty(&input.journal_id, "journalId", MAX_JOURNAL_ID_CHARS)?, + correlation_id: checked_nonempty( + &input.correlation_id, + "correlationId", + MAX_CORRELATION_ID_CHARS, + )?, + revision, + summary: None, + note: None, + receipt_ref: Some(checked_nonempty( + &input.receipt_ref, + "receiptRef", + MAX_RECEIPT_REF_CHARS, + )?), + source_event_ids: normalize_source_event_ids(&input.source_event_ids)?, + }; + build_signed_artifact(keys, content) +} + +pub fn next_revision( + conn: &Connection, + identity_pubkey: &str, + journal_id: &str, + artifact_type: JournalAuthorityArtifactType, +) -> Result { + let current = conn + .query_row( + "SELECT revision FROM journal_authority_artifacts + WHERE identity_pubkey = ?1 AND journal_id = ?2 AND artifact_type = ?3", + params![identity_pubkey, journal_id, artifact_type.as_str()], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(|error| format!("read journal authority revision: {error}"))?; + current + .unwrap_or(0) + .checked_add(1) + .ok_or_else(|| "journal authority revision overflow".to_string()) +} + +fn rollback(conn: &Connection) { + let _ = conn.execute_batch("ROLLBACK"); +} + +/// Insert a first revision or replace the current row with exactly revision+1. +/// Re-inserting the exact same signed event is an idempotent success; a stale +/// but valid event is rejected so it cannot replay over newer authority state. +pub fn upsert_signed_artifact( + conn: &Connection, + identity_pubkey: &str, + raw_json: &str, + stored_at: i64, +) -> Result { + let artifact = validate_signed_artifact(raw_json, identity_pubkey)?; + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|error| format!("begin journal authority upsert: {error}"))?; + let result = (|| -> Result { + let current = conn + .query_row( + "SELECT event_id, revision FROM journal_authority_artifacts + WHERE identity_pubkey = ?1 AND journal_id = ?2 AND artifact_type = ?3", + params![ + identity_pubkey, + artifact.journal_id, + artifact.artifact_type.as_str() + ], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional() + .map_err(|error| format!("read current journal authority artifact: {error}"))?; + + if let Some((current_event_id, current_revision)) = current { + if current_event_id == artifact.event_id { + conn.execute( + "UPDATE journal_authority_artifacts SET raw_json = ?1, stored_at = ?2 + WHERE identity_pubkey = ?3 AND journal_id = ?4 AND artifact_type = ?5", + params![ + raw_json, + stored_at, + identity_pubkey, + artifact.journal_id, + artifact.artifact_type.as_str() + ], + ) + .map_err(|error| format!("refresh journal authority artifact: {error}"))?; + return Ok(artifact.clone()); + } + let expected = current_revision + .checked_add(1) + .ok_or_else(|| "journal authority revision overflow".to_string())?; + if artifact.revision != expected { + return Err(format!( + "stale journal authority replay: expected revision {expected}, got {}", + artifact.revision + )); + } + } else if artifact.revision != 1 { + return Err(format!( + "first journal authority revision must be 1, got {}", + artifact.revision + )); + } + + conn.execute( + "INSERT INTO journal_authority_artifacts + (identity_pubkey, journal_id, artifact_type, event_id, created_at, + revision, raw_json, stored_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (identity_pubkey, journal_id, artifact_type) DO UPDATE SET + event_id = excluded.event_id, + created_at = excluded.created_at, + revision = excluded.revision, + raw_json = excluded.raw_json, + stored_at = excluded.stored_at", + params![ + identity_pubkey, + artifact.journal_id, + artifact.artifact_type.as_str(), + artifact.event_id, + artifact.created_at, + artifact.revision, + raw_json, + stored_at + ], + ) + .map_err(|error| format!("persist journal authority artifact: {error}"))?; + Ok(artifact.clone()) + })(); + + match result { + Ok(artifact) => { + if let Err(error) = conn.execute_batch("COMMIT") { + rollback(conn); + return Err(format!("commit journal authority artifact: {error}")); + } + Ok(artifact) + } + Err(error) => { + rollback(conn); + Err(error) + } + } +} + +fn validate_stored_row( + row: StoredArtifactRow, + expected_identity: &str, +) -> Result { + let artifact = validate_signed_artifact(&row.raw_json, expected_identity)?; + if row.identity_pubkey != expected_identity + || row.journal_id != artifact.journal_id + || row.artifact_type != artifact.artifact_type.as_str() + || row.event_id != artifact.event_id + || row.created_at != artifact.created_at + || row.revision != artifact.revision + { + return Err("stored journal authority columns do not match signed event".into()); + } + Ok(artifact) +} + +fn row_from_sql(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(StoredArtifactRow { + identity_pubkey: row.get(0)?, + journal_id: row.get(1)?, + artifact_type: row.get(2)?, + event_id: row.get(3)?, + created_at: row.get(4)?, + revision: row.get(5)?, + raw_json: row.get(6)?, + }) +} + +pub fn get_journal_authority_artifacts( + conn: &Connection, + identity_pubkey: &str, + journal_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT identity_pubkey, journal_id, artifact_type, event_id, + created_at, revision, raw_json + FROM journal_authority_artifacts + WHERE identity_pubkey = ?1 AND journal_id = ?2 + ORDER BY artifact_type ASC", + ) + .map_err(|error| format!("prepare journal authority read: {error}"))?; + let rows = stmt + .query_map(params![identity_pubkey, journal_id], row_from_sql) + .map_err(|error| format!("query journal authority artifacts: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("read journal authority artifact row: {error}"))? + .into_iter() + .map(|row| validate_stored_row(row, identity_pubkey)) + .collect() +} + +/// Bounded range query suitable for the owner Today surface. It returns only +/// decoded public fields after revalidating every signed event; no secret keys +/// or raw key material cross the Tauri boundary. +pub fn query_journal_authority_artifacts( + conn: &Connection, + identity_pubkey: &str, + start_created_at: i64, + end_created_at: i64, + limit: i64, +) -> Result, String> { + if start_created_at >= end_created_at { + return Err("journal authority range must be half-open and non-empty".into()); + } + if !(1..=500).contains(&limit) { + return Err("journal authority query limit must be between 1 and 500".into()); + } + let mut stmt = conn + .prepare( + "SELECT identity_pubkey, journal_id, artifact_type, event_id, + created_at, revision, raw_json + FROM journal_authority_artifacts + WHERE identity_pubkey = ?1 AND created_at >= ?2 AND created_at < ?3 + ORDER BY created_at DESC, event_id DESC + LIMIT ?4", + ) + .map_err(|error| format!("prepare journal authority range query: {error}"))?; + let rows = stmt + .query_map( + params![identity_pubkey, start_created_at, end_created_at, limit], + row_from_sql, + ) + .map_err(|error| format!("query journal authority range: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("read journal authority range row: {error}"))? + .into_iter() + .map(|row| validate_stored_row(row, identity_pubkey)) + .collect() +} + +/// Revalidate every source event referenced by a verification artifact against +/// the current owner's archive. This prevents an otherwise well-signed owner +/// artifact from yielding VERIFIED when it cites absent, cross-identity, or +/// tampered observer evidence. +pub fn validate_archived_verification_sources( + conn: &Connection, + identity_pubkey: &str, + artifact: &JournalAuthorityArtifact, +) -> Result<(), String> { + if artifact.artifact_type != JournalAuthorityArtifactType::Verification { + return Ok(()); + } + for source_event_id in &artifact.source_event_ids { + let mut stmt = conn + .prepare( + "SELECT kind, raw_json FROM archived_events + WHERE identity_pubkey = ?1 AND id = ?2", + ) + .map_err(|error| format!("prepare verification source read: {error}"))?; + let rows = stmt + .query_map(params![identity_pubkey, source_event_id], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| format!("read verification source event: {error}"))? + .collect::, _>>() + .map_err(|error| format!("read verification source row: {error}"))?; + if rows.is_empty() { + return Err(format!( + "verification source event {source_event_id} is not archived" + )); + } + let mut validation_errors = Vec::new(); + let mut valid = false; + for (kind, raw_json) in rows { + if kind != 24200 { + validation_errors.push("not an observer event".to_string()); + continue; + } + match Event::from_json(&raw_json) { + Ok(event) if event.id.to_hex() == *source_event_id && event.verify().is_ok() => { + valid = true; + break; + } + Ok(_) => validation_errors.push("signed ID or signature mismatch".to_string()), + Err(error) => validation_errors.push(format!("parse failed: {error}")), + } + } + if !valid { + return Err(format!( + "verification source event {source_event_id} failed validation: {}", + validation_errors.join("; ") + )); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "journal_authority_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/archive/journal_authority_tests.rs b/desktop/src-tauri/src/archive/journal_authority_tests.rs new file mode 100644 index 00000000000..8bf89eb9061 --- /dev/null +++ b/desktop/src-tauri/src/archive/journal_authority_tests.rs @@ -0,0 +1,276 @@ +use super::*; +use crate::archive::store::{open_archive_db, SCHEMA}; +use rusqlite::Connection; + +fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn +} + +fn override_input(summary: &str) -> OwnerJournalOverrideInput { + OwnerJournalOverrideInput { + journal_id: "agent:channel:turn-1".into(), + correlation_id: "tool-call-1".into(), + summary: summary.into(), + note: Some("Owner corrected the narrative.".into()), + } +} + +fn verification_input() -> JournalVerificationInput { + JournalVerificationInput { + journal_id: "agent:channel:turn-1".into(), + correlation_id: "tool-call-1".into(), + receipt_ref: "receipt://archive/tool-call-1".into(), + source_event_ids: vec!["a".repeat(64), "b".repeat(64)], + } +} + +#[test] +fn owner_override_is_signed_persisted_and_idempotent() { + let conn = in_memory(); + let owner = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let raw = + build_owner_override_event(&owner, &override_input("Observed owner result."), 1).unwrap(); + + let first = upsert_signed_artifact(&conn, &owner_pk, &raw, 10).unwrap(); + let replay = upsert_signed_artifact(&conn, &owner_pk, &raw, 11).unwrap(); + assert_eq!(first, replay); + assert_eq!( + first.artifact_type, + JournalAuthorityArtifactType::OwnerOverride + ); + assert_eq!(first.summary.as_deref(), Some("Observed owner result.")); + assert_eq!( + get_journal_authority_artifacts(&conn, &owner_pk, &first.journal_id) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn stale_valid_revision_cannot_replay_over_current_state() { + let conn = in_memory(); + let owner = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let first = build_owner_override_event(&owner, &override_input("First"), 1).unwrap(); + let second = build_owner_override_event(&owner, &override_input("Second"), 2).unwrap(); + let stale = build_owner_override_event(&owner, &override_input("Stale rewrite"), 1).unwrap(); + upsert_signed_artifact(&conn, &owner_pk, &first, 10).unwrap(); + upsert_signed_artifact(&conn, &owner_pk, &second, 11).unwrap(); + let error = upsert_signed_artifact(&conn, &owner_pk, &stale, 12).unwrap_err(); + assert!(error.contains("stale journal authority replay")); + + let rows = get_journal_authority_artifacts(&conn, &owner_pk, "agent:channel:turn-1").unwrap(); + assert_eq!(rows[0].revision, 2); + assert_eq!(rows[0].summary.as_deref(), Some("Second")); +} + +#[test] +fn first_insert_must_start_at_revision_one() { + let conn = in_memory(); + let owner = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let raw = build_owner_override_event(&owner, &override_input("Skipped"), 2).unwrap(); + let error = upsert_signed_artifact(&conn, &owner_pk, &raw, 10).unwrap_err(); + assert!(error.contains("first journal authority revision must be 1")); +} + +#[test] +fn wrong_signer_fails_closed_and_identity_rows_are_isolated() { + let conn = in_memory(); + let owner = Keys::generate(); + let other = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let other_pk = other.public_key().to_hex(); + let raw = build_owner_override_event(&owner, &override_input("Owner only"), 1).unwrap(); + assert!(upsert_signed_artifact(&conn, &other_pk, &raw, 10) + .unwrap_err() + .contains("signer is not the active owner")); + + upsert_signed_artifact(&conn, &owner_pk, &raw, 10).unwrap(); + assert!( + get_journal_authority_artifacts(&conn, &other_pk, "agent:channel:turn-1") + .unwrap() + .is_empty() + ); +} + +#[test] +fn tampered_signature_and_tampered_database_columns_fail_closed() { + let conn = in_memory(); + let owner = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let raw = build_owner_override_event(&owner, &override_input("Untampered"), 1).unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&raw).unwrap(); + value["content"] = serde_json::Value::String("{}".into()); + let tampered = serde_json::to_string(&value).unwrap(); + assert!(upsert_signed_artifact(&conn, &owner_pk, &tampered, 10) + .unwrap_err() + .contains("signature verification failed")); + + let artifact = upsert_signed_artifact(&conn, &owner_pk, &raw, 10).unwrap(); + conn.execute( + "UPDATE journal_authority_artifacts SET revision = 99 + WHERE identity_pubkey = ?1 AND journal_id = ?2", + params![owner_pk, artifact.journal_id], + ) + .unwrap(); + assert!( + get_journal_authority_artifacts(&conn, &owner_pk, "agent:channel:turn-1") + .unwrap_err() + .contains("columns do not match signed event") + ); +} + +#[test] +fn verification_binds_receipt_correlation_and_source_events() { + let conn = in_memory(); + let owner = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let raw = build_verification_event(&owner, &verification_input(), 1).unwrap(); + let artifact = upsert_signed_artifact(&conn, &owner_pk, &raw, 10).unwrap(); + assert_eq!( + artifact.artifact_type, + JournalAuthorityArtifactType::Verification + ); + assert_eq!(artifact.correlation_id, "tool-call-1"); + assert_eq!( + artifact.receipt_ref.as_deref(), + Some("receipt://archive/tool-call-1") + ); + assert_eq!(artifact.source_event_ids, ["a".repeat(64), "b".repeat(64)]); +} + +#[test] +fn verification_missing_receipt_or_source_event_fails_closed() { + let owner = Keys::generate(); + let mut no_receipt = verification_input(); + no_receipt.receipt_ref = " ".into(); + assert!(build_verification_event(&owner, &no_receipt, 1) + .unwrap_err() + .contains("receiptRef")); + + let mut no_source = verification_input(); + no_source.source_event_ids.clear(); + assert!(build_verification_event(&owner, &no_source, 1) + .unwrap_err() + .contains("must bind between")); +} + +#[test] +fn verification_rejects_duplicate_and_malformed_source_ids() { + let owner = Keys::generate(); + let mut duplicate = verification_input(); + duplicate.source_event_ids = vec!["a".repeat(64), "a".repeat(64)]; + assert!(build_verification_event(&owner, &duplicate, 1) + .unwrap_err() + .contains("must be unique")); + + let mut malformed = verification_input(); + malformed.source_event_ids = vec!["not-an-event".into()]; + assert!(build_verification_event(&owner, &malformed, 1) + .unwrap_err() + .contains("64-character hexadecimal")); +} + +#[test] +fn verification_sources_must_exist_and_remain_valid_in_owner_archive() { + let conn = in_memory(); + let owner = Keys::generate(); + let agent = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(24200), "signed observer") + .sign_with_keys(&agent) + .unwrap(); + let event_id = event.id.to_hex(); + let input = JournalVerificationInput { + source_event_ids: vec![event_id.clone()], + ..verification_input() + }; + let raw = build_verification_event(&owner, &input, 1).unwrap(); + let artifact = upsert_signed_artifact(&conn, &owner_pk, &raw, 10).unwrap(); + assert!( + validate_archived_verification_sources(&conn, &owner_pk, &artifact) + .unwrap_err() + .contains("is not archived") + ); + + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, 'wss://r', ?2, 24200, ?3, 1, ?4, 1)", + params![ + owner_pk, + event_id, + agent.public_key().to_hex(), + event.as_json() + ], + ) + .unwrap(); + validate_archived_verification_sources(&conn, &owner_pk, &artifact).unwrap(); + + conn.execute( + "UPDATE archived_events SET raw_json = '{}' WHERE identity_pubkey = ?1 AND id = ?2", + params![owner_pk, event_id], + ) + .unwrap(); + assert!( + validate_archived_verification_sources(&conn, &owner_pk, &artifact) + .unwrap_err() + .contains("failed validation") + ); +} + +#[test] +fn durable_artifact_survives_close_and_reopen() { + let db_file = tempfile::NamedTempFile::new().unwrap(); + let owner = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let raw = build_verification_event(&owner, &verification_input(), 1).unwrap(); + { + let conn = open_archive_db(db_file.path()).unwrap(); + upsert_signed_artifact(&conn, &owner_pk, &raw, 10).unwrap(); + } + let reopened = open_archive_db(db_file.path()).unwrap(); + let rows = + get_journal_authority_artifacts(&reopened, &owner_pk, "agent:channel:turn-1").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].receipt_ref.as_deref(), + Some("receipt://archive/tool-call-1") + ); +} + +#[test] +fn bounded_today_query_is_owner_scoped_and_returns_public_fields_only() { + let conn = in_memory(); + let owner = Keys::generate(); + let other = Keys::generate(); + let owner_pk = owner.public_key().to_hex(); + let other_pk = other.public_key().to_hex(); + let raw = build_owner_override_event(&owner, &override_input("Today"), 1).unwrap(); + let artifact = upsert_signed_artifact(&conn, &owner_pk, &raw, 10).unwrap(); + + let rows = query_journal_authority_artifacts( + &conn, + &owner_pk, + artifact.created_at - 1, + artifact.created_at + 1, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 1); + assert!(query_journal_authority_artifacts( + &conn, + &other_pk, + artifact.created_at - 1, + artifact.created_at + 1, + 10, + ) + .unwrap() + .is_empty()); + assert!(query_journal_authority_artifacts(&conn, &owner_pk, 1, 2, 501).is_err()); +} diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index afb00ffca39..3660fbfb249 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -18,11 +18,13 @@ //! == agent) is applied fail-closed. mod agent_usage; +mod journal_authority; mod metric_store; mod pipeline; pub mod store; mod store_migrations; pub mod sync; +mod today_snapshot; use pipeline::{commit_archive, plan_archive, query_buckets}; @@ -35,6 +37,11 @@ use crate::app_state::AppState; use crate::managed_agents::nest_dir; use crate::relay::{query_relay, relay_ws_url_with_override}; +pub use journal_authority::{ + JournalAuthorityArtifact, JournalVerificationInput, OwnerJournalOverrideInput, +}; +pub use today_snapshot::TodaySnapshotReceipt; + // ── Constants ─────────────────────────────────────────────────────────────── const KIND_AGENT_OBSERVER_FRAME: u16 = 24200; @@ -840,6 +847,113 @@ pub async fn get_agent_usage_series( .await } +// ── Activity Ledger owner authority ───────────────────────────────────────── + +/// Persist an owner-authenticated journal summary override. The backend signs +/// the artifact with the active identity; callers never receive key material. +#[tauri::command] +pub fn upsert_owner_journal_override( + state: State<'_, AppState>, + input: OwnerJournalOverrideInput, +) -> Result { + let keys = state.signing_keys()?; + let identity_pk = keys.public_key().to_hex(); + let conn = open_db()?; + let revision = journal_authority::next_revision( + &conn, + &identity_pk, + input.journal_id.trim(), + journal_authority::JournalAuthorityArtifactType::OwnerOverride, + )?; + let raw = journal_authority::build_owner_override_event(&keys, &input, revision)?; + journal_authority::upsert_signed_artifact(&conn, &identity_pk, &raw, now_secs()) +} + +/// Persist an independent owner verification. It cannot be created without a +/// receipt reference and one or more source observer event IDs that are +/// currently present and signature-valid in this owner's archive. +#[tauri::command] +pub fn upsert_journal_verification( + state: State<'_, AppState>, + input: JournalVerificationInput, +) -> Result { + let keys = state.signing_keys()?; + let identity_pk = keys.public_key().to_hex(); + let conn = open_db()?; + let revision = journal_authority::next_revision( + &conn, + &identity_pk, + input.journal_id.trim(), + journal_authority::JournalAuthorityArtifactType::Verification, + )?; + let raw = journal_authority::build_verification_event(&keys, &input, revision)?; + let artifact = journal_authority::validate_signed_artifact(&raw, &identity_pk)?; + journal_authority::validate_archived_verification_sources(&conn, &identity_pk, &artifact)?; + journal_authority::upsert_signed_artifact(&conn, &identity_pk, &raw, now_secs()) +} + +/// Read the current owner override and/or verification for one journal. Every +/// signed artifact and every verification source is revalidated fail-closed. +#[tauri::command] +pub fn get_journal_authority_artifacts( + state: State<'_, AppState>, + journal_id: String, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let conn = open_db()?; + let artifacts = + journal_authority::get_journal_authority_artifacts(&conn, &identity_pk, journal_id.trim())?; + for artifact in &artifacts { + journal_authority::validate_archived_verification_sources(&conn, &identity_pk, artifact)?; + } + Ok(artifacts) +} + +/// Bounded owner-only authority query used by Today surfaces and local +/// read-only consumers. No signing or secret key data is returned. +#[tauri::command] +pub fn query_journal_authority_artifacts( + state: State<'_, AppState>, + start_created_at: i64, + end_created_at: i64, + limit: Option, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let conn = open_db()?; + let artifacts = journal_authority::query_journal_authority_artifacts( + &conn, + &identity_pk, + start_created_at, + end_created_at, + limit.unwrap_or(200), + )?; + for artifact in &artifacts { + journal_authority::validate_archived_verification_sources(&conn, &identity_pk, artifact)?; + } + Ok(artifacts) +} + +/// Atomically publish the frontend's canonical Today projection to a private, +/// owner-scoped local JSON snapshot. The envelope is validated against the +/// active identity and no identity secret is accepted or serialized. +#[tauri::command] +pub fn write_owner_today_snapshot( + state: State<'_, AppState>, + snapshot_json: String, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let nest = nest_dir().ok_or("cannot resolve nest directory for Today snapshot")?; + today_snapshot::write_owner_today_snapshot(&nest, &identity_pk, &snapshot_json, now_secs()) +} + +/// Read and revalidate the current owner's unexpired Today snapshot. +#[tauri::command] +pub fn read_owner_today_snapshot(state: State<'_, AppState>) -> Result { + let identity_pk = identity_pubkey(&state)?; + let nest = nest_dir().ok_or("cannot resolve nest directory for Today snapshot")?; + today_snapshot::read_owner_today_snapshot(&nest, &identity_pk, now_secs()) +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs index e466f7b63fc..953b38f44bf 100644 --- a/desktop/src-tauri/src/archive/store.rs +++ b/desktop/src-tauri/src/archive/store.rs @@ -138,6 +138,28 @@ CREATE INDEX IF NOT EXISTS idx_agent_metric_reported -- reported_at, so their window membership is judged by event_created_at). CREATE INDEX IF NOT EXISTS idx_agent_metric_created ON agent_metric_index (identity_pubkey, relay_url, event_created_at, parse_status); + +-- Owner-authorized Activity Ledger artifacts. Each row contains the complete +-- signed Nostr event and is re-verified on every read. `revision` prevents an +-- older but otherwise valid owner event from replaying over newer journal +-- authority state. +CREATE TABLE IF NOT EXISTS journal_authority_artifacts ( + identity_pubkey TEXT NOT NULL, + journal_id TEXT NOT NULL, + artifact_type TEXT NOT NULL, + event_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + revision INTEGER NOT NULL, + raw_json TEXT NOT NULL, + stored_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, journal_id, artifact_type), + UNIQUE (identity_pubkey, event_id), + CHECK (artifact_type IN ('owner_override', 'verification')), + CHECK (revision > 0) +); +CREATE INDEX IF NOT EXISTS idx_journal_authority_created + ON journal_authority_artifacts + (identity_pubkey, created_at DESC, event_id DESC); "; // ── Open / init ───────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/archive/today_snapshot.rs b/desktop/src-tauri/src/archive/today_snapshot.rs new file mode 100644 index 00000000000..b0f8ecdd7c1 --- /dev/null +++ b/desktop/src-tauri/src/archive/today_snapshot.rs @@ -0,0 +1,341 @@ +//! Owner-scoped, read-only Activity Ledger snapshot for local consumers. +//! +//! The frontend already owns the canonical journal projection. This module is +//! deliberately only the secure persistence seam: it validates the projection +//! envelope against the active owner, writes it atomically with mode 0600 on +//! Unix, and revalidates it on read. It never receives or serializes secret +//! identity key material. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub const TODAY_SNAPSHOT_SCHEMA: &str = "buzz.activity-ledger.today/v1"; +pub const TODAY_SNAPSHOT_CAPABILITY: &str = "buzz.activity-ledger.today.read/v1"; +const MAX_SNAPSHOT_BYTES: usize = 8 * 1024 * 1024; +const MAX_RAW_EVENTS: usize = 10_000; +const MAX_LIFETIME_SECS: i64 = 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OwnerTodaySnapshot { + pub schema: String, + pub owner_pubkey: String, + pub generated_at: i64, + pub expires_at: i64, + pub capability: String, + pub surface: serde_json::Value, + pub raw_events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodaySnapshotReceipt { + pub path: String, + pub owner_pubkey: String, + pub generated_at: i64, + pub expires_at: i64, + pub byte_length: usize, + pub sha256: String, +} + +fn snapshot_path(nest_dir: &Path, owner_pubkey: &str) -> PathBuf { + nest_dir + .join("archive") + .join(format!("activity-ledger-today-{owner_pubkey}.json")) +} + +fn validate_owner_pubkey(owner_pubkey: &str) -> Result<(), String> { + if owner_pubkey.len() != 64 + || !owner_pubkey + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err("Today snapshot ownerPubkey must be lowercase 64-character hex".into()); + } + Ok(()) +} + +fn reject_identity_secret_material(value: &serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::Object(object) => { + for (key, child) in object { + let normalized_key = key + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::(); + if matches!( + normalized_key.as_str(), + "nsec" | "secretkey" | "privatekey" | "nostrsecretkey" + ) { + return Err("Today snapshot cannot contain identity secret fields".into()); + } + reject_identity_secret_material(child)?; + } + } + serde_json::Value::Array(values) => { + for child in values { + reject_identity_secret_material(child)?; + } + } + serde_json::Value::String(text) => { + let lowercase = text.to_ascii_lowercase(); + if lowercase.contains("nsec1") || lowercase.contains("nostr_secret_key=") { + return Err("Today snapshot cannot contain identity secret material".into()); + } + } + _ => {} + } + Ok(()) +} + +fn parse_and_validate( + snapshot_json: &str, + expected_owner_pubkey: &str, + now: i64, + require_unexpired: bool, +) -> Result { + if snapshot_json.is_empty() || snapshot_json.len() > MAX_SNAPSHOT_BYTES { + return Err(format!( + "Today snapshot must contain between 1 and {MAX_SNAPSHOT_BYTES} bytes" + )); + } + validate_owner_pubkey(expected_owner_pubkey)?; + let snapshot: OwnerTodaySnapshot = serde_json::from_str(snapshot_json) + .map_err(|error| format!("parse Today snapshot: {error}"))?; + if snapshot.schema != TODAY_SNAPSHOT_SCHEMA { + return Err("unsupported Today snapshot schema".into()); + } + if snapshot.capability != TODAY_SNAPSHOT_CAPABILITY { + return Err("unsupported Today snapshot capability".into()); + } + if snapshot.owner_pubkey != expected_owner_pubkey { + return Err("Today snapshot owner does not match the active identity".into()); + } + if snapshot.generated_at > now + 300 { + return Err("Today snapshot generatedAt is too far in the future".into()); + } + if snapshot.expires_at <= snapshot.generated_at + || snapshot.expires_at - snapshot.generated_at > MAX_LIFETIME_SECS + { + return Err("Today snapshot lifetime must be positive and at most 24 hours".into()); + } + if require_unexpired && snapshot.expires_at <= now { + return Err("Today snapshot has expired".into()); + } + if !snapshot.surface.is_object() { + return Err("Today snapshot surface must be a JSON object".into()); + } + if snapshot.raw_events.len() > MAX_RAW_EVENTS { + return Err(format!( + "Today snapshot rawEvents exceeds {MAX_RAW_EVENTS} records" + )); + } + reject_identity_secret_material(&snapshot.surface)?; + for event in &snapshot.raw_events { + reject_identity_secret_material(event)?; + } + Ok(snapshot) +} + +#[cfg(unix)] +fn secure_open(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(|error| format!("create Today snapshot temp file: {error}")) +} + +#[cfg(not(unix))] +fn secure_open(path: &Path) -> Result { + std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|error| format!("create Today snapshot temp file: {error}")) +} + +#[cfg(unix)] +fn enforce_private_permissions(path: &Path, directory: bool) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + let mode = if directory { 0o700 } else { 0o600 }; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .map_err(|error| format!("set private Today snapshot permissions: {error}")) +} + +#[cfg(not(unix))] +fn enforce_private_permissions(_path: &Path, _directory: bool) -> Result<(), String> { + Ok(()) +} + +pub fn write_owner_today_snapshot( + nest_dir: &Path, + expected_owner_pubkey: &str, + snapshot_json: &str, + now: i64, +) -> Result { + let snapshot = parse_and_validate(snapshot_json, expected_owner_pubkey, now, true)?; + // Canonicalize the bytes that local readers hash and consume. + let canonical_json = serde_json::to_string(&snapshot) + .map_err(|error| format!("serialize canonical Today snapshot: {error}"))?; + let archive_dir = nest_dir.join("archive"); + std::fs::create_dir_all(&archive_dir) + .map_err(|error| format!("create Today snapshot directory: {error}"))?; + enforce_private_permissions(&archive_dir, true)?; + + let destination = snapshot_path(nest_dir, expected_owner_pubkey); + let temp_path = archive_dir.join(format!( + ".activity-ledger-today-{}.{}.tmp", + expected_owner_pubkey, + uuid::Uuid::new_v4() + )); + let write_result = (|| -> Result<(), String> { + let mut file = secure_open(&temp_path)?; + file.write_all(canonical_json.as_bytes()) + .map_err(|error| format!("write Today snapshot: {error}"))?; + file.sync_all() + .map_err(|error| format!("sync Today snapshot: {error}"))?; + drop(file); + enforce_private_permissions(&temp_path, false)?; + std::fs::rename(&temp_path, &destination) + .map_err(|error| format!("atomically publish Today snapshot: {error}"))?; + enforce_private_permissions(&destination, false)?; + Ok(()) + })(); + if write_result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + write_result?; + + let sha256 = hex::encode(Sha256::digest(canonical_json.as_bytes())); + Ok(TodaySnapshotReceipt { + path: destination.to_string_lossy().into_owned(), + owner_pubkey: snapshot.owner_pubkey, + generated_at: snapshot.generated_at, + expires_at: snapshot.expires_at, + byte_length: canonical_json.len(), + sha256, + }) +} + +pub fn read_owner_today_snapshot( + nest_dir: &Path, + expected_owner_pubkey: &str, + now: i64, +) -> Result { + let path = snapshot_path(nest_dir, expected_owner_pubkey); + let raw = std::fs::read_to_string(&path) + .map_err(|error| format!("read owner Today snapshot: {error}"))?; + parse_and_validate(&raw, expected_owner_pubkey, now, true)?; + Ok(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot(owner: &str, generated_at: i64) -> String { + serde_json::json!({ + "schema": TODAY_SNAPSHOT_SCHEMA, + "ownerPubkey": owner, + "generatedAt": generated_at, + "expiresAt": generated_at + 3600, + "capability": TODAY_SNAPSHOT_CAPABILITY, + "surface": {"date": "2026-08-21", "journals": []}, + "rawEvents": [{"journalId": "j-1", "proofState": "OBSERVED"}] + }) + .to_string() + } + + #[test] + fn snapshot_is_atomic_private_and_owner_scoped() { + let dir = tempfile::tempdir().unwrap(); + let owner = "a".repeat(64); + let receipt = + write_owner_today_snapshot(dir.path(), &owner, &snapshot(&owner, 1000), 1000).unwrap(); + assert_eq!(receipt.owner_pubkey, owner); + assert_eq!(receipt.sha256.len(), 64); + let raw = read_owner_today_snapshot(dir.path(), &owner, 1001).unwrap(); + assert!(raw.contains(TODAY_SNAPSHOT_CAPABILITY)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&receipt.path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + + #[test] + fn snapshot_wrong_owner_expiry_and_capability_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + let owner = "a".repeat(64); + let other = "b".repeat(64); + assert!( + write_owner_today_snapshot(dir.path(), &other, &snapshot(&owner, 1000), 1000) + .unwrap_err() + .contains("owner does not match") + ); + + let mut value: serde_json::Value = serde_json::from_str(&snapshot(&owner, 1000)).unwrap(); + value["capability"] = "write-anything".into(); + assert!( + write_owner_today_snapshot(dir.path(), &owner, &value.to_string(), 1000) + .unwrap_err() + .contains("capability") + ); + + assert!( + write_owner_today_snapshot(dir.path(), &owner, &snapshot(&owner, 1000), 5000) + .unwrap_err() + .contains("expired") + ); + } + + #[test] + fn snapshot_read_revalidates_tampering_and_replacement() { + let dir = tempfile::tempdir().unwrap(); + let owner = "a".repeat(64); + let first = + write_owner_today_snapshot(dir.path(), &owner, &snapshot(&owner, 1000), 1000).unwrap(); + let second = + write_owner_today_snapshot(dir.path(), &owner, &snapshot(&owner, 1001), 1001).unwrap(); + assert_eq!(first.path, second.path); + let mut value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&second.path).unwrap()).unwrap(); + value["ownerPubkey"] = "b".repeat(64).into(); + std::fs::write(&second.path, value.to_string()).unwrap(); + assert!(read_owner_today_snapshot(dir.path(), &owner, 1002) + .unwrap_err() + .contains("owner does not match")); + } + + #[test] + fn snapshot_rejects_identity_secret_fields_and_nsec_values() { + let dir = tempfile::tempdir().unwrap(); + let owner = "a".repeat(64); + let mut field: serde_json::Value = serde_json::from_str(&snapshot(&owner, 1000)).unwrap(); + field["surface"]["secretKey"] = "do-not-export".into(); + assert!( + write_owner_today_snapshot(dir.path(), &owner, &field.to_string(), 1000) + .unwrap_err() + .contains("identity secret") + ); + + let mut value: serde_json::Value = serde_json::from_str(&snapshot(&owner, 1000)).unwrap(); + value["rawEvents"][0]["detail"] = "nsec1should-never-leave-the-owner-boundary".into(); + assert!( + write_owner_today_snapshot(dir.path(), &owner, &value.to_string(), 1000) + .unwrap_err() + .contains("identity secret") + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b7f4b331d29..4a8d39af24d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -846,6 +846,12 @@ pub fn run() { archive::sync::announce_archive_sync_epoch, archive::sync::start_archive_sync, archive::sync::stop_archive_sync, + archive::upsert_owner_journal_override, + archive::upsert_journal_verification, + archive::get_journal_authority_artifacts, + archive::query_journal_authority_artifacts, + archive::write_owner_today_snapshot, + archive::read_owner_today_snapshot, is_auto_update_supported, set_window_vibrancy, #[cfg(target_os = "macos")] diff --git a/desktop/src-tauri/src/managed_agents/activity_ledger_env.rs b/desktop/src-tauri/src/managed_agents/activity_ledger_env.rs new file mode 100644 index 00000000000..b87ef2a3acc --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/activity_ledger_env.rs @@ -0,0 +1,67 @@ +use std::path::{Path, PathBuf}; + +const TODAY_CAPABILITY: &str = "buzz.activity-ledger.today.read/v1"; +const TODAY_PATH_ENV: &str = "BUZZ_ACTIVITY_LEDGER_TODAY_PATH"; +const TODAY_CAPABILITY_ENV: &str = "BUZZ_ACTIVITY_LEDGER_TODAY_CAPABILITY"; + +fn honey_today_env( + persona_id: Option<&str>, + owner_hex: Option<&str>, + nest: Option<&Path>, +) -> Option<(PathBuf, &'static str)> { + if persona_id != Some("builtin:honey") { + return None; + } + let owner = owner_hex?; + if owner.len() != 64 + || !owner + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return None; + } + Some(( + nest? + .join("archive") + .join(format!("activity-ledger-today-{owner}.json")), + TODAY_CAPABILITY, + )) +} + +pub fn configure( + command: &mut std::process::Command, + record: &super::ManagedAgentRecord, + owner_hex: Option<&str>, +) { + command.env_remove(TODAY_PATH_ENV); + command.env_remove(TODAY_CAPABILITY_ENV); + let nest = super::nest_dir(); + if let Some((path, capability)) = + honey_today_env(record.persona_id.as_deref(), owner_hex, nest.as_deref()) + { + command.env(TODAY_PATH_ENV, path); + command.env(TODAY_CAPABILITY_ENV, capability); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn today_env_is_owner_scoped_and_honey_only() { + let owner = "a".repeat(64); + let nest = Path::new("/private/buzz-nest"); + let (path, capability) = + honey_today_env(Some("builtin:honey"), Some(&owner), Some(nest)).unwrap(); + assert_eq!( + path, + nest.join("archive") + .join(format!("activity-ledger-today-{owner}.json")) + ); + assert_eq!(capability, TODAY_CAPABILITY); + assert!(honey_today_env(Some("builtin:fizz"), Some(&owner), Some(nest)).is_none()); + assert!(honey_today_env(Some("builtin:honey"), Some("bad"), Some(nest)).is_none()); + assert!(honey_today_env(Some("builtin:honey"), Some(&owner), None).is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 272c03348b9..9c90def2fac 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod access_policy; +mod activity_ledger_env; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index afaaa2b4eb3..95a5d8c1135 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -56,6 +56,11 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Stable agent identity used for git attribution and private-conversation // provenance must come from the managed-agent record, not user overrides. "BUZZ_ACP_DISPLAY_NAME", + // Owner Activity Ledger read capability. Desktop derives these from the + // active owner and private nest path for Honey; saved persona config may + // not redirect the reader to another file or weaken the contract marker. + "BUZZ_ACTIVITY_LEDGER_TODAY_PATH", + "BUZZ_ACTIVITY_LEDGER_TODAY_CAPABILITY", // Remote lifetime/presence policy: user env must not disable the // desktop/provider-owned bounds while the saved record still promises them. "BUZZ_ACP_EXIT_AFTER_INACTIVITY", diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0ce5ca7b219..2f461330538 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -400,7 +400,6 @@ pub(crate) fn configure_runtime_cli( /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. -/// /// `owner_hex`: the workspace owner's pubkey, used as a fallback for legacy /// records that have no NIP-OA `auth_tag`. See `build_respond_to_env`. pub fn spawn_agent_child( @@ -764,6 +763,7 @@ pub fn spawn_agent_child( command.env_remove("BUZZ_AUTH_TAG"); } + super::activity_ledger_env::configure(&mut command, record, owner_hex); // Inbound author gate: who is this agent allowed to respond to? // Validation is strict here — a malformed allowlist on disk fails before // we spawn anything (the harness would also reject it, but we'd rather diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0e..cb8098b8db6 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -49,6 +49,7 @@ import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManag import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy"; import { usePersonaSync } from "@/features/agents/lib/usePersonaSync"; import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion"; +import { useActivityLedgerTodaySnapshot } from "@/features/agents/useActivityLedgerTodaySnapshot"; import { AgentManagementDialogs } from "@/features/agents/ui/AgentManagementDialogs"; import { RequestedAgentCreateDialogs } from "@/features/agents/ui/RequestedAgentCreateDialogs"; import { @@ -214,6 +215,7 @@ export function AppShell() { // The archive batch now persists in Rust, so the agent-metrics invalidation // signal arrives as a Tauri event rather than an in-process call. useArchiveAgentMetricsBridge(); + useActivityLedgerTodaySnapshot(); // Kind 44200 is relay-persisted (durable) and stays deferred: missed // startup frames can be replayed, so there's no ordering constraint. const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; diff --git a/desktop/src/features/agents/activityLedgerAuthority.test.mjs b/desktop/src/features/agents/activityLedgerAuthority.test.mjs new file mode 100644 index 00000000000..78709573ea1 --- /dev/null +++ b/desktop/src/features/agents/activityLedgerAuthority.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildMissionJournal, + normalizeActivityEvents, +} from "./activityLedger.ts"; +import { applyValidatedJournalAuthority } from "./activityLedgerAuthority.ts"; + +const sourceId = "a".repeat(64); + +function observedJournal() { + return buildMissionJournal( + normalizeActivityEvents([ + { + seq: 1, + timestamp: "2026-08-21T14:00:00.000Z", + kind: "turn_started", + sourceEventId: sourceId, + sourcePubkey: "agent-a", + sourceKind: 24200, + sourceCreatedAt: 1_787_319_600, + sourceSignature: "agent-signature", + origin: "historical_backfill", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { triggeringEventIds: ["message-1"] }, + }, + { + seq: 2, + timestamp: "2026-08-21T14:01:00.000Z", + kind: "turn_completed", + sourceEventId: "b".repeat(64), + sourcePubkey: "agent-a", + sourceKind: 24200, + sourceCreatedAt: 1_787_319_660, + sourceSignature: "agent-signature-2", + origin: "historical_backfill", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: {}, + }, + ]), + ); +} + +function artifact(overrides = {}) { + const journal = observedJournal(); + return { + ownerPubkey: "owner-a", + eventId: "c".repeat(64), + signature: "owner-signature", + createdAt: 1_787_319_720, + artifactType: "verification", + journalId: journal.id, + correlationId: journal.correlationId, + revision: 1, + summary: null, + note: null, + receiptRef: "receipt:independent-check-1", + sourceEventIds: [sourceId], + ...overrides, + }; +} + +test("owner-signed verification only promotes evidence bound to the journal", () => { + const journal = observedJournal(); + assert.notEqual(journal.proofState, "VERIFIED"); + + const verified = applyValidatedJournalAuthority(journal, [artifact()]); + assert.equal(verified.proofState, "VERIFIED"); + assert.equal(verified.status, "completed"); + assert.equal(verified.events.at(-1).title, "Owner verification"); + assert.deepEqual(verified.events.at(-1).provenance.triggeringEventIds, [ + sourceId, + ]); + assert.equal( + verified.events.at(-1).provenance.sourceSignature, + "owner-signature", + ); + + const crossJournal = applyValidatedJournalAuthority(journal, [ + artifact({ sourceEventIds: ["f".repeat(64)] }), + ]); + assert.notEqual(crossJournal.proofState, "VERIFIED"); +}); + +test("latest owner override changes summary without changing proof", () => { + const journal = observedJournal(); + const base = artifact({ + artifactType: "owner_override", + eventId: "d".repeat(64), + summary: "First owner summary", + note: "clarified", + receiptRef: null, + sourceEventIds: [], + }); + const latest = { + ...base, + eventId: "e".repeat(64), + revision: 2, + createdAt: base.createdAt + 10, + summary: "Corrected owner summary", + }; + + const updated = applyValidatedJournalAuthority(journal, [latest, base]); + assert.equal(updated.summary, "Corrected owner summary"); + assert.equal(updated.summarySource, "owner"); + assert.equal(updated.ownerModifiedBy, "owner-a"); + assert.equal(updated.proofState, journal.proofState); +}); diff --git a/desktop/src/features/agents/activityLedgerAuthority.ts b/desktop/src/features/agents/activityLedgerAuthority.ts new file mode 100644 index 00000000000..b448cde9bf9 --- /dev/null +++ b/desktop/src/features/agents/activityLedgerAuthority.ts @@ -0,0 +1,139 @@ +import { + applyOwnerJournalOverride, + type MissionJournal, + type NormalizedActivityEvent, +} from "./activityLedger"; + +/** A signature-verified owner artifact returned by the Tauri authority store. */ +export type ValidatedJournalAuthorityArtifact = { + ownerPubkey: string; + eventId: string; + signature: string; + createdAt: number; + artifactType: "owner_override" | "verification"; + journalId: string; + correlationId: string; + revision: number; + summary: string | null; + note: string | null; + receiptRef: string | null; + sourceEventIds: string[]; +}; + +/** + * Overlay owner authority without rewriting the observed source journal. + * + * The backend verifies artifact ids, signatures, signer identity, tags, and + * revision ordering before returning these values. The frontend additionally + * requires every verification source id to belong to this exact journal, so a + * valid owner signature for one turn cannot promote a different turn. + */ +export function applyValidatedJournalAuthority( + journal: MissionJournal, + artifacts: readonly ValidatedJournalAuthorityArtifact[], +): MissionJournal { + const matching = artifacts + .filter( + (artifact) => + artifact.journalId === journal.id && + artifact.correlationId === journal.correlationId, + ) + .sort( + (left, right) => + left.revision - right.revision || + left.createdAt - right.createdAt || + left.eventId.localeCompare(right.eventId), + ); + + let result = journal; + const latestOverride = matching + .filter( + (artifact) => + artifact.artifactType === "owner_override" && + typeof artifact.summary === "string" && + artifact.summary.trim().length > 0, + ) + .at(-1); + if (latestOverride?.summary) { + result = applyOwnerJournalOverride(result, { + summary: latestOverride.summary, + modifiedAt: new Date(latestOverride.createdAt * 1_000).toISOString(), + modifiedBy: latestOverride.ownerPubkey, + }); + } + + const sourceEventIds = new Set( + journal.events + .map((event) => event.provenance.sourceEventId) + .filter((id): id is string => Boolean(id)), + ); + const latestVerification = matching + .filter( + (artifact) => + artifact.artifactType === "verification" && + Boolean(artifact.receiptRef?.trim()) && + artifact.sourceEventIds.length > 0 && + artifact.sourceEventIds.every((id) => sourceEventIds.has(id)), + ) + .at(-1); + if (!latestVerification) return result; + + const timestamp = new Date( + latestVerification.createdAt * 1_000, + ).toISOString(); + const verificationEvent: NormalizedActivityEvent = { + id: latestVerification.eventId, + journalKey: journal.journalKey, + correlationId: journal.correlationId, + category: "status", + title: "Owner verification", + detail: latestVerification.receiptRef, + status: "completed", + proofState: "VERIFIED", + timestamp, + channelId: journal.channelId, + sessionId: journal.sessionId, + turnId: journal.turnId, + toolCallId: null, + messageId: null, + provenance: { + sourceEventId: latestVerification.eventId, + sourcePubkey: latestVerification.ownerPubkey, + sourceKind: 24201, + sourceCreatedAt: latestVerification.createdAt, + sourceSignature: latestVerification.signature, + origin: "unknown", + observerKind: "owner_verification", + method: null, + sessionUpdate: null, + seq: + Math.max(0, ...journal.events.map((event) => event.provenance.seq)) + 1, + timestamp, + channelId: journal.channelId, + sessionId: journal.sessionId, + turnId: journal.turnId, + toolCallId: null, + messageId: null, + triggeringEventIds: latestVerification.sourceEventIds, + }, + tags: ["owner-signed", "receipt-bound"], + ownerModifiedAt: timestamp, + ownerModifiedBy: latestVerification.ownerPubkey, + }; + + return { + ...result, + proofState: "VERIFIED", + status: + result.status === "ended_unverified" || result.status === "observed" + ? "completed" + : result.status, + claimedCompletionWithoutEvidence: false, + endedAt: + Date.parse(timestamp) > Date.parse(result.endedAt) + ? timestamp + : result.endedAt, + eventCount: result.eventCount + 1, + events: [...result.events, verificationEvent], + }; +} diff --git a/desktop/src/features/agents/activityLedgerToday.test.mjs b/desktop/src/features/agents/activityLedgerToday.test.mjs new file mode 100644 index 00000000000..a835d3afec1 --- /dev/null +++ b/desktop/src/features/agents/activityLedgerToday.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + activityLedgerDayRange, + applyAuthorityToTodayActivity, + buildTodayActivityFromArchivedEvents, +} from "./activityLedgerToday.ts"; + +function relayEvent({ id, pubkey = "agent-a", agent = pubkey, decoded }) { + return { + id, + pubkey, + created_at: Math.floor(Date.parse(decoded.timestamp) / 1000), + kind: 24200, + tags: [["agent", agent]], + content: "encrypted", + sig: `sig-${id}`, + decoded, + }; +} + +test("Today reconstruction trusts only managed self-authored observer frames", async () => { + const timestamp = "2026-08-21T14:00:00.000Z"; + const valid = relayEvent({ + id: "valid", + decoded: { + seq: 1, + timestamp, + kind: "turn_started", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { triggeringEventIds: ["message-1"] }, + }, + }); + const forged = relayEvent({ + id: "forged", + pubkey: "attacker", + agent: "agent-a", + decoded: { ...valid.decoded, seq: 2 }, + }); + const unknown = relayEvent({ + id: "unknown", + pubkey: "agent-z", + decoded: { ...valid.decoded, seq: 3 }, + }); + + const surface = await buildTodayActivityFromArchivedEvents({ + day: "2026-08-21", + agents: [{ pubkey: "agent-a", name: "Honey" }], + events: [valid, forged, unknown], + decrypt: async (event) => event.decoded, + }); + + assert.equal(surface.counts.journals, 1); + assert.equal(surface.journals[0].agentName, "Honey"); + assert.equal(surface.journals[0].events.length, 1); + assert.deepEqual(surface.journals[0].events[0].provenance, { + ...surface.journals[0].events[0].provenance, + sourceEventId: "valid", + sourcePubkey: "agent-a", + sourceKind: 24200, + sourceCreatedAt: valid.created_at, + sourceSignature: "sig-valid", + origin: "historical_backfill", + }); +}); + +test("Today reconstruction skips decrypt failures without admitting bad proof", async () => { + const event = relayEvent({ + id: "bad-ciphertext", + decoded: { + seq: 1, + timestamp: "2026-08-21T14:00:00.000Z", + kind: "turn_started", + payload: {}, + }, + }); + const surface = await buildTodayActivityFromArchivedEvents({ + day: "2026-08-21", + agents: [{ pubkey: "agent-a", name: "Honey" }], + events: [event], + decrypt: async () => { + throw new Error("decrypt failed"); + }, + }); + assert.equal(surface.counts.journals, 0); +}); + +test("day range is half-open and rejects impossible dates", () => { + const range = activityLedgerDayRange("2026-08-21"); + assert.equal(range.endCreatedAt - range.startCreatedAt, 24 * 60 * 60); + assert.throws(() => activityLedgerDayRange("2026-02-30")); + assert.throws(() => activityLedgerDayRange("08/21/2026")); +}); + +test("Today authority overlay recomputes evidence-gap counts", async () => { + const event = relayEvent({ + id: "a".repeat(64), + decoded: { + seq: 1, + timestamp: "2026-08-21T14:00:00.000Z", + kind: "turn_started", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: {}, + }, + }); + const ended = relayEvent({ + id: "b".repeat(64), + decoded: { ...event.decoded, seq: 2, kind: "turn_completed" }, + }); + const surface = await buildTodayActivityFromArchivedEvents({ + day: "2026-08-21", + agents: [{ pubkey: "agent-a", name: "Honey" }], + events: [event, ended], + decrypt: async (candidate) => candidate.decoded, + }); + const journal = surface.journals[0]; + const updated = applyAuthorityToTodayActivity(surface, [ + { + ownerPubkey: "owner-a", + eventId: "c".repeat(64), + signature: "owner-signature", + createdAt: ended.created_at + 1, + artifactType: "verification", + journalId: journal.id, + correlationId: journal.correlationId, + revision: 1, + summary: null, + note: null, + receiptRef: "receipt:owner-check", + sourceEventIds: [event.id], + }, + ]); + + assert.equal(updated.journals[0].proofState, "VERIFIED"); + assert.equal(updated.counts.claimedWithoutEvidence, 0); + assert.equal(updated.channels[0].lastActivityAt, "2026-08-21T14:00:01.000Z"); +}); diff --git a/desktop/src/features/agents/activityLedgerToday.ts b/desktop/src/features/agents/activityLedgerToday.ts new file mode 100644 index 00000000000..25651a843cc --- /dev/null +++ b/desktop/src/features/agents/activityLedgerToday.ts @@ -0,0 +1,185 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { decryptObserverEvent } from "@/shared/api/tauriObserver"; +import { applyValidatedJournalAuthority } from "./activityLedgerAuthority"; +import type { ValidatedJournalAuthorityArtifact } from "./activityLedgerAuthority"; +import { + buildTodayActivitySurface, + normalizeActivityEvents, + type TodayActivityJournal, + type TodayActivitySurface, +} from "./activityLedger"; +import type { ObserverEvent } from "./ui/agentSessionTypes"; + +export type ActivityLedgerAgentIdentity = { + pubkey: string; + name: string; +}; + +type DecryptObserverEvent = (event: RelayEvent) => Promise; + +function observerAgentPubkey(event: RelayEvent): string | null { + const tag = event.tags.find( + (candidate) => candidate[0] === "agent" && candidate[1]?.length > 0, + ); + return tag?.[1] ?? null; +} + +function isObserverEvent(value: unknown): value is ObserverEvent { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + Number.isFinite(candidate.seq) && + typeof candidate.timestamp === "string" && + candidate.timestamp.length > 0 && + typeof candidate.kind === "string" && + candidate.kind.length > 0 && + "payload" in candidate + ); +} + +/** Return the local-time half-open Unix range used by the owner Today view. */ +export function activityLedgerDayRange(day: string): { + startCreatedAt: number; + endCreatedAt: number; +} { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!match) throw new Error("Activity Ledger day must use YYYY-MM-DD."); + const year = Number(match[1]); + const monthIndex = Number(match[2]) - 1; + const date = Number(match[3]); + const start = new Date(year, monthIndex, date); + if ( + start.getFullYear() !== year || + start.getMonth() !== monthIndex || + start.getDate() !== date + ) { + throw new Error("Activity Ledger day is not a valid calendar date."); + } + const end = new Date(year, monthIndex, date + 1); + return { + startCreatedAt: Math.floor(start.getTime() / 1_000), + endCreatedAt: Math.floor(end.getTime() / 1_000), + }; +} + +/** + * Decrypt and normalize owner-archived observer frames into the Today surface. + * + * The signed outer pubkey and `agent` tag must agree with a managed agent. A + * frame that fails that authority check, fails decryption, or is malformed is + * excluded instead of being allowed to mint activity or proof. + */ +export async function buildTodayActivityFromArchivedEvents(input: { + day: string; + agents: readonly ActivityLedgerAgentIdentity[]; + events: readonly RelayEvent[]; + decrypt?: DecryptObserverEvent; +}): Promise { + const decrypt = input.decrypt ?? decryptObserverEvent; + const trustedAgents = new Map( + input.agents.map((agent) => [agent.pubkey, agent] as const), + ); + const observerEvents = new Map(); + + await Promise.all( + input.events.map(async (relayEvent) => { + const agentPubkey = observerAgentPubkey(relayEvent); + if ( + !agentPubkey || + relayEvent.pubkey !== agentPubkey || + !trustedAgents.has(agentPubkey) + ) { + return; + } + + try { + const decoded = await decrypt(relayEvent); + if (!isObserverEvent(decoded)) return; + const enriched: ObserverEvent = { + ...decoded, + sourceEventId: relayEvent.id, + sourcePubkey: relayEvent.pubkey, + sourceKind: relayEvent.kind, + sourceCreatedAt: relayEvent.created_at, + sourceSignature: relayEvent.sig, + origin: "historical_backfill", + }; + const bucket = observerEvents.get(agentPubkey) ?? []; + bucket.push(enriched); + observerEvents.set(agentPubkey, bucket); + } catch { + // Archive reconciliation is fail-closed: one bad ciphertext cannot + // suppress the rest of the owner's durable activity surface. + } + }), + ); + + return buildTodayActivitySurface( + input.agents.map((agent) => ({ + agentPubkey: agent.pubkey, + agentName: agent.name, + events: normalizeActivityEvents(observerEvents.get(agent.pubkey) ?? []), + })), + { day: input.day }, + ); +} + +/** Apply backend-validated owner artifacts and recompute every derived count. */ +export function applyAuthorityToTodayActivity( + surface: TodayActivitySurface, + artifacts: readonly ValidatedJournalAuthorityArtifact[], +): TodayActivitySurface { + const journals: TodayActivityJournal[] = surface.journals.map((journal) => ({ + ...applyValidatedJournalAuthority(journal, artifacts), + agentPubkey: journal.agentPubkey, + agentName: journal.agentName, + })); + const channels = new Map< + string, + { + journalIds: string[]; + agentPubkeys: Set; + agentNames: Set; + lastActivityAt: string; + } + >(); + for (const journal of journals) { + if (!journal.channelId) continue; + const bucket = channels.get(journal.channelId) ?? { + journalIds: [], + agentPubkeys: new Set(), + agentNames: new Set(), + lastActivityAt: journal.endedAt, + }; + bucket.journalIds.push(journal.id); + bucket.agentPubkeys.add(journal.agentPubkey); + bucket.agentNames.add(journal.agentName); + if (Date.parse(journal.endedAt) > Date.parse(bucket.lastActivityAt)) { + bucket.lastActivityAt = journal.endedAt; + } + channels.set(journal.channelId, bucket); + } + + return { + ...surface, + journals, + channels: [...channels.entries()] + .map(([channelId, bucket]) => ({ + channelId, + journalIds: bucket.journalIds, + agentPubkeys: [...bucket.agentPubkeys], + agentNames: [...bucket.agentNames], + lastActivityAt: bucket.lastActivityAt, + })) + .sort((left, right) => left.channelId.localeCompare(right.channelId)), + counts: { + journals: journals.length, + failed: journals.filter((journal) => journal.status === "failed").length, + inProgress: journals.filter((journal) => journal.status === "in_progress") + .length, + claimedWithoutEvidence: journals.filter( + (journal) => journal.claimedCompletionWithoutEvidence, + ).length, + }, + }; +} diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 0d7875ec1a8..64716004edf 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -1,8 +1,10 @@ import * as React from "react"; import { + CheckCircle2, CircleAlert, CircleDot, Clock3, + Pencil, TerminalSquare, XCircle, } from "lucide-react"; @@ -13,13 +15,23 @@ import { normalizeActivityEvents, type MissionJournal, } from "@/features/agents/activityLedger"; +import { applyValidatedJournalAuthority } from "@/features/agents/activityLedgerAuthority"; +import { + getJournalAuthorityArtifacts, + upsertJournalVerification, + upsertOwnerJournalOverride, + type JournalAuthorityArtifact, +} from "@/shared/api/tauriArchive"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; import { Skeleton } from "@/shared/ui/skeleton"; import { Spinner } from "@/shared/ui/spinner"; +import { Textarea } from "@/shared/ui/textarea"; import { AgentSessionTranscriptList, type AgentSessionTranscriptEmptyState, @@ -206,19 +218,201 @@ function journalStatusLabel(status: MissionJournal["status"]): string { } function MissionJournalSummary({ journal }: { journal: MissionJournal }) { + const [artifacts, setArtifacts] = React.useState( + [], + ); + const [mode, setMode] = React.useState<"summary" | "verify" | null>(null); + const [summary, setSummary] = React.useState(journal.summary); + const [receiptRef, setReceiptRef] = React.useState(""); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(null); + + const reloadAuthority = React.useCallback(async () => { + const current = await getJournalAuthorityArtifacts(journal.id); + setArtifacts(current); + }, [journal.id]); + + React.useEffect(() => { + let cancelled = false; + setArtifacts([]); + setMode(null); + setSummary(journal.summary); + setReceiptRef(""); + setError(null); + getJournalAuthorityArtifacts(journal.id) + .then((current) => { + if (!cancelled) setArtifacts(current); + }) + .catch((loadError) => { + if (!cancelled) { + setError( + loadError instanceof Error + ? loadError.message + : "Owner journal proof could not be loaded.", + ); + } + }); + return () => { + cancelled = true; + }; + }, [journal.id, journal.summary]); + + const authorizedJournal = React.useMemo( + () => applyValidatedJournalAuthority(journal, artifacts), + [artifacts, journal], + ); + const receiptedSourceIds = React.useMemo( + () => [ + ...new Set( + journal.events + .filter((event) => event.proofState === "RECEIPTED") + .map((event) => event.provenance.sourceEventId) + .filter( + (id): id is string => + typeof id === "string" && /^[0-9a-f]{64}$/i.test(id), + ), + ), + ], + [journal.events], + ); + + const saveSummary = async () => { + if (!summary.trim() || saving) return; + setSaving(true); + setError(null); + try { + await upsertOwnerJournalOverride({ + journalId: journal.id, + correlationId: journal.correlationId, + summary: summary.trim(), + }); + await reloadAuthority(); + setMode(null); + } catch (saveError) { + setError( + saveError instanceof Error + ? saveError.message + : "The owner summary was not saved.", + ); + } finally { + setSaving(false); + } + }; + + const saveVerification = async () => { + if (!receiptRef.trim() || receiptedSourceIds.length === 0 || saving) return; + setSaving(true); + setError(null); + try { + await upsertJournalVerification({ + journalId: journal.id, + correlationId: journal.correlationId, + receiptRef: receiptRef.trim(), + sourceEventIds: receiptedSourceIds, + }); + await reloadAuthority(); + setMode(null); + setReceiptRef(""); + } catch (saveError) { + setError( + saveError instanceof Error + ? saveError.message + : "The verification was not saved.", + ); + } finally { + setSaving(false); + } + }; + return (
Mission journal - {journalStatusLabel(journal.status)} - {journal.proofState} - {journal.claimedCompletionWithoutEvidence ? ( + + {journalStatusLabel(authorizedJournal.status)} + + {authorizedJournal.proofState} + {authorizedJournal.claimedCompletionWithoutEvidence ? ( Evidence gap ) : null}
-

{journal.summary}

+

+ {authorizedJournal.summary} +

+ {authorizedJournal.summarySource === "owner" ? ( +

Owner edited

+ ) : null} + + {mode === "summary" ? ( +
+