Skip to main content

agentmux_srv/backend/
mps_events.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Broker events with a typed payload (docs/specs/SPEC_TYPED_EVENT_PAYLOADS_2026_10_13.md).
5//!
6//! Each event is a marker type naming its wire name and its payload type, declared once in the
7//! `events!` list below. That list is also the schema: `schema()` (exported to
8//! `frontend/types/rpc/event-schema.json`) feeds the generated `MuxEventPayloads` map, so the
9//! frontend's `onMuxEvent(WpsEvent.X, (data) => …)` gets `data` as the Rust type. Publish one with
10//! [`Broker::publish_event`](super::mps::Broker::publish_event). An event not listed here is still
11//! published as a plain `MuxEvent` with untyped data.
12//!
13//! An event sent under a name per id (`agent:memory:changed:<agent id>`) is a [`PerIdEvent`],
14//! declared in the `per_id_events!` list by the name before the id, and published with
15//! [`Broker::publish_event_for`](super::mps::Broker::publish_event_for).
16
17use serde::Serialize;
18
19#[cfg(test)]
20use crate::backend::rpc::schema::{ts_type, TsType};
21
22/// A broker event with a typed payload.
23pub trait Event {
24    /// The wire name (`MuxEvent::event`).
25    const NAME: &'static str;
26    type Payload: Serialize + ts_rs::TS + 'static;
27}
28
29/// A broker event sent under a name per id: its prefix, then the id.
30pub trait PerIdEvent {
31    /// The wire name before the id (`agent:memory:changed:`).
32    const PREFIX: &'static str;
33    type Payload: Serialize + ts_rs::TS + 'static;
34}
35
36macro_rules! per_id_events {
37    ($($(#[$doc:meta])* $marker:ident: $prefix:expr => $payload:ty,)*) => {
38        $(
39            $(#[$doc])*
40            pub struct $marker;
41            impl PerIdEvent for $marker {
42                const PREFIX: &'static str = $prefix;
43                type Payload = $payload;
44            }
45        )*
46        /// Every per-id event: its prefix and its payload as TypeScript.
47        #[cfg(test)]
48        pub fn per_id_schema() -> Vec<(&'static str, TsType)> {
49            vec![$(($prefix, ts_type::<$payload>())),*]
50        }
51        /// Whether `name` is a per-id event's name: one of the prefixes above, then an id.
52        pub fn is_per_id_event(name: &str) -> bool {
53            $(name.starts_with(<$marker as PerIdEvent>::PREFIX) ||)* false
54        }
55    };
56}
57
58macro_rules! events {
59    ($($(#[$doc:meta])* $marker:ident: $name:expr => $payload:ty,)*) => {
60        $(
61            $(#[$doc])*
62            pub struct $marker;
63            impl Event for $marker {
64                const NAME: &'static str = $name;
65                type Payload = $payload;
66            }
67        )*
68        /// Every typed event: its wire name and its payload as TypeScript (the export test reads it).
69        #[cfg(test)]
70        pub fn schema() -> Vec<(&'static str, TsType)> {
71            vec![$(($name, ts_type::<$payload>())),*]
72        }
73        /// Whether `name` is one of the events above (not a per-id one: [`is_per_id_event`]).
74        pub fn is_typed_event(name: &str) -> bool {
75            $(name == <$marker as Event>::NAME ||)* false
76        }
77    };
78}
79
80events! {
81    /// A block controller's runtime status (`publish_controller_status`).
82    ControllerStatus: super::mps::EVENT_CONTROLLER_STATUS => crate::backend::blockcontroller::BlockControllerRuntimeStatus,
83    /// One sysinfo sample for a connection (`sysinfo::run_sysinfo_loop`).
84    SysInfo: super::mps::EVENT_SYS_INFO => crate::backend::rpc_types::TimeSeriesData,
85    /// An agent's turn ledger changed (`blockcontroller::health::ledger_publisher`).
86    AgentTurn: super::mps::EVENT_AGENT_TURN => crate::backend::blockcontroller::health::AgentTurnEvent,
87    /// An agent failed, classified (`agents::failure`).
88    AgentFailed: super::mps::EVENT_AGENT_FAILURE => crate::agents::failure::AgentFailure,
89    /// What an agent process runs with, and whether a restart is pending.
90    AgentRuntime: super::mps::EVENT_AGENT_RUNTIME => crate::backend::agent_runtime::AgentRuntimeEvent,
91    /// Where an agent's CLI auto-compacts.
92    AgentContextUsageChanged: super::mps::EVENT_AGENT_CONTEXT_USAGE => crate::backend::agent_context_usage::AgentContextUsageEvent,
93    /// A queued message reached the agent.
94    AgentMessageAccepted: super::mps::EVENT_AGENT_MESSAGE_ACCEPTED => crate::backend::blockcontroller::MessageAcceptedEvent,
95    /// A stale `--resume` is being retried, or the retry resolved.
96    AgentResumeRetry: super::mps::EVENT_AGENT_RESUME_RETRY => ResumeRetryEvent,
97    /// One line of a closing agent pane's shutdown log (`sagas::close_pane::publish_shutdown`).
98    AgentShutdown: crate::sagas::close_pane::EVENT_AGENT_SHUTDOWN => crate::sagas::close_pane::ShutdownLineEvent,
99    /// A call to action srv holds open started, or ended (`backend::user_attention`).
100    UserAttention: super::mps::EVENT_USER_ATTENTION => crate::backend::user_attention::UserAttentionEvent,
101    /// New events from one subagent (`subagent_watcher`).
102    SubagentActivity: "subagent:activity" => crate::backend::subagent_watcher::events::SubagentActivityEvent,
103    /// A workflow dispatch's coalesced new events.
104    DispatchActivity: "dispatch:activity" => crate::backend::subagent_watcher::events::DispatchActivityEvent,
105    SubagentSpawned: "subagent:spawned" => crate::backend::subagent_watcher::events::SubagentSpawnedEvent,
106    SubagentCompleted: "subagent:completed" => crate::backend::subagent_watcher::events::SubagentCompletedEvent,
107    SubagentNamed: "subagent:named" => crate::backend::subagent_watcher::events::SubagentNamedEvent,
108    SubagentUpdated: "subagent:updated" => crate::backend::subagent_watcher::events::SubagentUpdatedEvent,
109    DispatchUpdated: "dispatch:updated" => crate::backend::subagent_watcher::events::DispatchUpdatedEvent,
110    SubagentAbandoned: "subagent:abandoned" => crate::backend::subagent_watcher::events::SubagentAbandonedEvent,
111    SubagentBlockPruned: "subagent:block_pruned" => crate::backend::subagent_watcher::events::SubagentBlockPrunedEvent,
112    /// A resumed pane's subagent backfill started or finished.
113    SubagentBackfillStatus: super::mps::EVENT_SUBAGENT_BACKFILL_STATUS => crate::backend::subagent_watcher::events::SubagentBackfillStatusEvent,
114    /// A block file changed: terminal output, an agent transcript append (`blockcontroller`).
115    BlockFile: super::mps::EVENT_BLOCK_FILE => super::mps::WSFileEventData,
116    /// One pane's process-tree stats sample (`sysinfo`).
117    BlockStats: super::mps::EVENT_BLOCK_STATS => crate::backend::rpc_types::TimeSeriesData,
118    /// A wave object changed (`wcore::event`).
119    MuxObjUpdated: super::mps::EVENT_MUX_OBJ_UPDATE => crate::backend::obj::MuxObjUpdate,
120    /// A file an editor pane shows changed on disk.
121    EditorFileChanged: crate::backend::editor_file_watcher::EVENT_EDITOR_FILE_CHANGED => PathChangedEvent,
122    /// A file a media pane shows changed on disk.
123    MediaFileChanged: crate::backend::media_file_watcher::EVENT_MEDIA_FILE_CHANGED => PathChangedEvent,
124    /// A folder a Files pane lists changed.
125    FilesChanged: crate::backend::files_watcher::EVENT_FILES_CHANGED => FilesChangedEvent,
126    /// A copy/move/delete job's progress (`fs_ops::jobs`).
127    FilesOp: crate::backend::fs_ops::jobs::EVENT_FILES_OP => crate::backend::rpc_types::FsOpEvent,
128    /// One line of an install's output, or its end (`install.start`, `system.install`).
129    InstallChunk: "install_chunk" => crate::server::install_handlers::InstallChunkEvent,
130    /// A Shell command started (`server::http_shell`).
131    ShellNodeCreate: super::mps::EVENT_SHELL_NODE_CREATE => crate::backend::shell_node::ShellNodeCreateEvent,
132    /// One line a Shell command wrote, or its exit (`backend::shell_node`).
133    ShellChunk: super::mps::EVENT_SHELL_CHUNK => crate::backend::shell_node::ShellChunkEvent,
134    /// An attachment batch's progress, per item (`backend::attachments`).
135    AttachmentProgress: super::mps::EVENT_ATTACHMENT_PROGRESS => crate::backend::rpc_types::AttachmentProgressEvent,
136    AttachmentReady: super::mps::EVENT_ATTACHMENT_READY => crate::backend::rpc_types::AttachmentReadyEvent,
137    AttachmentFailed: super::mps::EVENT_ATTACHMENT_FAILED => crate::backend::rpc_types::AttachmentFailedEvent,
138    AttachmentBatchDone: super::mps::EVENT_ATTACHMENT_BATCH_DONE => crate::backend::rpc_types::AttachmentBatchDoneEvent,
139    /// A connection's status changed (`backend::remote::status`).
140    ConnChange: super::mps::EVENT_CONN_CHANGE => crate::backend::rpc_types::ConnStatus,
141    /// A pane's background tasks changed; the dashboard reads the list again.
142    BackgroundTaskUpdated: "background-task-updated" => BlockRefEvent,
143    /// A line of narration for an agent pane (`continued_note`, the narrate route).
144    AmbientNarration: "ambient-narration" => AmbientNarrationEvent,
145    /// A pane's reported MCP servers changed (`mcp_observed`).
146    McpObserved: "mcp:observed" => BlockRefEvent,
147    /// What the notification tray shows (`notify::router`).
148    NotificationState: crate::backend::notify::router::EVENT_NOTIFICATION_STATE => crate::backend::notify::policy::TrayState,
149    /// An agent's checklist and current tool (`reactive::progress_watcher`).
150    AgentProgress: crate::backend::reactive::progress_watcher::EVENT_AGENT_PROGRESS => AgentProgressEvent,
151    /// The installed widget packages.
152    WidgetPackages: super::mps::EVENT_WIDGET_PACKAGES => WidgetPackagesEvent,
153    /// A widget package's storage changed: which keys, never their values.
154    WidgetStorage: super::mps::EVENT_WIDGET_STORAGE => WidgetStorageEvent,
155    /// The agents' widget install requests waiting for the user.
156    WidgetRequests: super::mps::EVENT_WIDGET_REQUESTS => WidgetRequestsEvent,
157    /// A pane's process status changed (`broker::process`).
158    ProcessStatusChanged: crate::broker::process::EVENT_STATUS_CHANGED => crate::broker::process::ProcessStatus,
159    /// An agent instance's identity has no direct links (`identity::resolver::inject`); a diagnostic.
160    IdentityNoDirectLinks: "identity:no-direct-links" => NoDirectLinksEvent,
161    /// One streamed piece of a side question's answer (`agent_handlers::side_question`).
162    BtwAnswerChunk: super::mps::EVENT_BTW_ANSWER_CHUNK => BtwAnswerChunkEvent,
163    /// `muxspect dock clear` removed a node from a pane's dock.
164    DockClear: super::mps::EVENT_DOCK_CLEAR => DockClearEvent,
165    /// A reactive agent pane registered, or went (the Swarm refreshes).
166    AgentReactiveRegistered: "agent:reactive-registered" => BlockRefEvent,
167    AgentReactiveUnregistered: "agent:reactive-unregistered" => BlockRefEvent,
168    /// One protocol frame from an app-server agent (`app_server_controller`).
169    AppServerOutput: "output" => ProtocolFrame,
170    /// srv's version and machine, sent once on every WebSocket connect.
171    SrvInfo: "srvinfo" => crate::srv_info::SrvInfo,
172    /// The full config, redacted for the renderer: on connect, and when it changes.
173    Config: super::mps::EVENT_CONFIG => ConfigEvent,
174    /// What one ambient model call spent (`ambient::spend`).
175    AmbientSpent: crate::ambient::spend::EVENT_AMBIENT_SPENT => crate::ambient::spend::AmbientSpentEvent,
176    /// A process joined an agent pane's tree, or left it (`process_tracker::registry`).
177    AgentProcessAdded: "agent:process-added" => ProcessAddedEvent,
178    AgentProcessExited: "agent:process-exited" => ProcessExitedEvent,
179    /// A shutdown waiting for the user to keep the agent, and its end (`sagas::pending_shutdown`).
180    AgentShutdownPending: crate::sagas::pending_shutdown::EVENT_SHUTDOWN_PENDING => crate::sagas::pending_shutdown::PendingView,
181    AgentShutdownPendingCleared: crate::sagas::pending_shutdown::EVENT_SHUTDOWN_PENDING_CLEARED => crate::sagas::pending_shutdown::PendingClearedEvent,
182    /// An OS notification to show, retract or act on (`notify::router`).
183    Notification: crate::backend::notify::router::EVENT_NOTIFICATION => crate::backend::notify::policy::Notification,
184    NotificationRetract: crate::backend::notify::router::EVENT_NOTIFICATION_RETRACT => crate::backend::notify::router::NotificationRetractEvent,
185    NotificationActivate: crate::backend::notify::router::EVENT_NOTIFICATION_ACTIVATE => crate::backend::notify::router::NotificationActivateEvent,
186    /// Ask one window to open a pane as its own floating OS window (`app_api::pane`).
187    OpenFloatingPane: "openfloatingpane" => OpenFloatingPaneEvent,
188    /// The LAN peers this instance sees (`lan_discovery`).
189    LanInstances: "laninstances" => crate::backend::lan_discovery::LanInstanceList,
190    /// LAN discovery couldn't start, or a daemon rebuild failed.
191    LanInstancesError: "laninstances:error" => crate::backend::lan_discovery::LanDiscoveryError,
192    /// Whether this instance is discoverable on every LAN interface (the mDNS watchdog).
193    LanInstancesHealth: "laninstances:health" => crate::backend::lan_discovery::LanDiscoverability,
194    /// The OS firewall's verdict on LAN peers reaching this machine (`lan_firewall`).
195    LanInstancesFirewall: "laninstances:firewall" => crate::backend::lan_firewall::LanFirewall,
196    /// Several object updates from one atomic transition, applied in one flush (`EventBus::broadcast_mux_obj_updates`).
197    MuxObjBatchedUpdates: super::eventbus::WS_EVENT_MUX_OBJ_BATCHED_UPDATES => crate::backend::obj::MuxObjBatch,
198    /// A line of an agent CLI's install progress, for its pane (`mps::publish_install_progress`).
199    InstallProgress: super::mps::EVENT_INSTALL_PROGRESS => InstallProgressEvent,
200    /// An agent pane's activity, from its terminal title (`mps::publish_block_activity`).
201    BlockActivity: super::mps::EVENT_BLOCK_ACTIVITY => BlockActivityEvent,
202    /// One message from a language server (`lsp::supervisor`).
203    LspMessage: "lsp:message" => LspMessageEvent,
204    /// Cloud delivery's state changed (`muxbus::delivery_status`).
205    MuxbusStatus: crate::muxbus::delivery_status::EVENT_MUXBUS_STATUS => crate::backend::rpc_types::MuxBusDeliveryStatus,
206    /// A mobile viewer paired with this machine (`http_viewer`).
207    ViewerPaired: "viewer:paired" => ViewerPairedEvent,
208    /// Tell a window's renderer to reveal a block (`notify::router`).
209    BlockReveal: crate::backend::notify::router::EVENT_BLOCK_REVEAL => crate::backend::notify::router::BlockRevealEvent,
210
211    // Published by a client through the HTTP relay, which checks the payload (`client_events!`).
212    /// A line of a Bash call's output, its exit, its pid or which call it is (`agentmux-bashwrap`).
213    ToolChunk: "tool_chunk" => agentmux_common::client_events::ToolChunk,
214    /// Claude Code began compacting (`agentmux-bashwrap precompact`).
215    CompactionStarted: "compaction_started" => agentmux_common::client_events::CompactionStarted,
216    /// A window claimed or released a singleton modal (`frontend/app/store/singleton-modal.ts`).
217    SingletonClaim: "singleton:claim" => SingletonClaimEvent,
218
219    // Signals with no payload: something changed, and a subscriber refetches what it shows.
220    /// The agents list changed.
221    AgentsChanged: "agents:changed" => (),
222    /// An agent's stored content changed (`setagentcontent`).
223    AgentContentChanged: "agentcontent:changed" => (),
224    /// An entry was appended to an agent's history.
225    AgentHistoryChanged: "agenthistory:changed" => (),
226    /// A named agent was hidden.
227    NamedAgentsChanged: "namedagents:changed" => (),
228    /// The skills installed for an agent changed.
229    AgentSkillsChanged: "agentskills:changed" => (),
230    /// Memory entries changed.
231    MemoriesChanged: "memories:changed" => (),
232    /// The skill library changed.
233    SkillsChanged: "skills:changed" => (),
234    /// The MCP servers changed.
235    McpChanged: "mcp:changed" => (),
236    /// The provider accounts changed.
237    IdentityAccountsChanged: "identityaccounts:changed" => (),
238    /// Scheduled jobs changed.
239    CronChanged: super::mps::EVENT_CRON_CHANGED => (),
240    /// The drones changed.
241    DronesChanged: "drones:changed" => (),
242    /// The configured remotes changed.
243    RemotesChanged: super::mps::EVENT_REMOTES_CHANGE => (),
244    /// The process broker's set of tracked blocks changed.
245    TrackedBlocksChanged: crate::broker::process::EVENT_TRACKED_BLOCKS_CHANGED => (),
246    /// The work queue changed.
247    WorkQueueChanged: "workqueue:changed" => (),
248}
249
250per_id_events! {
251    /// An agent's native memory files changed (`agent:memory:changed:<agent id>`).
252    AgentMemoryChanged: "agent:memory:changed:" => (),
253    /// An agent's identity links changed (`agentidentities:changed:<agent id>`).
254    AgentIdentitiesChanged: "agentidentities:changed:" => (),
255    /// A definition's instances changed (`agentinstances:changed:<definition id>`).
256    AgentInstancesChanged: "agentinstances:changed:" => (),
257    /// An agent's provider login was unlinked (`agentcredentials:revoked:<agent id>`).
258    AgentCredentialsRevoked: "agentcredentials:revoked:" => CredentialsRevokedEvent,
259    /// One step of a drone run (`dronerun:<run id>`).
260    DroneRun: "dronerun:" => crate::drone::executor::engine::RunEvent,
261    /// An agent pane's output, translated (`agent_event:<block id>`).
262    AgentEventStream: "agent_event:" => crate::agents::types::AgentEvent,
263}
264
265macro_rules! client_events {
266    ($($marker:ident,)*) => {
267        /// Publishes an event a process outside srv sent (`POST /agentmux/wps/publish`) if it is a
268        /// client event: `None` if `name` isn't one, an error if `data` isn't its payload type.
269        pub fn publish_client_event(
270            broker: &super::mps::Broker,
271            name: &str,
272            data: &serde_json::Value,
273            scopes: &[String],
274            persist: usize,
275        ) -> Option<Result<(), String>> {
276            $(
277                if name == <$marker as Event>::NAME {
278                    return Some(match <<$marker as Event>::Payload as serde::Deserialize>::deserialize(data) {
279                        Ok(payload) => {
280                            broker.publish_event::<$marker>(&payload, scopes.to_vec(), persist);
281                            Ok(())
282                        }
283                        Err(e) => Err(format!("{name}: {e}")),
284                    });
285                }
286            )*
287            None
288        }
289        /// Whether `name` is one of the client events.
290        pub fn is_client_event(name: &str) -> bool {
291            $(name == <$marker as Event>::NAME ||)* false
292        }
293    };
294}
295
296// The events, declared in `events!`, that a client publishes: the relay refuses one whose data
297// isn't its payload. It refuses every other declared event too, since only srv publishes those
298// (`is_srv_event`), and relays an undeclared one as sent.
299client_events! {
300    ToolChunk,
301    CompactionStarted,
302    SingletonClaim,
303}
304
305/// Whether only srv publishes `name`: a declared event, or a per-id one, that isn't a client event.
306/// Its subscribers read it as its payload type, so the relay doesn't take it from outside.
307pub fn is_srv_event(name: &str) -> bool {
308    (is_typed_event(name) || is_per_id_event(name)) && !is_client_event(name)
309}
310
311/// An event's `data`: its payload as JSON, or none for an event that carries none (`()`, a
312/// pure "something changed" signal), so such an event goes out with no `data` field at all.
313pub fn payload_data<P: Serialize>(payload: &P) -> Option<serde_json::Value> {
314    serde_json::to_value(payload).ok().filter(|v| !v.is_null())
315}
316
317/// `singleton:claim`, to `singleton:<kind>` with `persist: 1`: the latest one names the window
318/// holding that kind of singleton modal.
319#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize, ts_rs::TS)]
320#[ts(export, export_to = "../../../frontend/types/rpc/")]
321pub struct SingletonClaimEvent {
322    pub kind: String,
323    /// The holding window's label, or `null` once released.
324    pub holder: Option<String>,
325    /// The publishing window's claim counter.
326    #[ts(type = "number")]
327    pub epoch: u64,
328}
329
330/// `editor:file_changed` / `media:file_changed`: the file that changed.
331#[derive(Debug, Clone, Serialize, ts_rs::TS)]
332#[ts(export, export_to = "../../../frontend/types/rpc/")]
333pub struct PathChangedEvent {
334    pub path: String,
335}
336
337/// An event about one pane: `background-task-updated`, `mcp:observed`, `agent:reactive-(un)registered`.
338#[derive(Debug, Clone, Serialize, ts_rs::TS)]
339#[ts(export, export_to = "../../../frontend/types/rpc/")]
340pub struct BlockRefEvent {
341    pub block_id: String,
342}
343
344/// `ambient-narration`: a line to show in an agent pane.
345#[derive(Debug, Clone, Serialize, ts_rs::TS)]
346#[ts(export, export_to = "../../../frontend/types/rpc/")]
347pub struct AmbientNarrationEvent {
348    pub block_id: String,
349    pub kind: String,
350    pub text: String,
351}
352
353/// `agent:progress`: an agent's checklist and the tool it is running.
354#[derive(Debug, Clone, Serialize, ts_rs::TS)]
355#[ts(export, export_to = "../../../frontend/types/rpc/")]
356#[serde(rename_all = "camelCase")]
357pub struct AgentProgressEvent {
358    pub agent_id: String,
359    pub block_id: String,
360    pub todos: Vec<crate::backend::reactive::progress_watcher::TodoItem>,
361    /// How many rows the cap dropped.
362    pub todos_truncated: usize,
363    pub todos_partial: bool,
364    pub current_tool: Option<String>,
365    #[ts(type = "number")]
366    pub ts: u64,
367}
368
369/// `widgetpackages`: every installed widget package.
370#[derive(Debug, Clone, Serialize, ts_rs::TS)]
371#[ts(export, export_to = "../../../frontend/types/rpc/")]
372pub struct WidgetPackagesEvent {
373    pub packages: Vec<crate::backend::widget_packages::WidgetPackageInfo>,
374}
375
376/// `widgetstorage`: the keys of a widget package's storage that changed.
377#[derive(Debug, Clone, Serialize, ts_rs::TS)]
378#[ts(export, export_to = "../../../frontend/types/rpc/")]
379pub struct WidgetStorageEvent {
380    pub id: String,
381    pub keys: Vec<String>,
382}
383
384/// `widgetrequests`: the install requests waiting for the user.
385#[derive(Debug, Clone, Serialize, ts_rs::TS)]
386#[ts(export, export_to = "../../../frontend/types/rpc/")]
387pub struct WidgetRequestsEvent {
388    pub requests: Vec<crate::backend::widget_requests::WidgetInstallRequest>,
389}
390
391/// `identity:no-direct-links`: the instance and the identity that has no direct links.
392#[derive(Debug, Clone, Serialize, ts_rs::TS)]
393#[ts(export, export_to = "../../../frontend/types/rpc/")]
394pub struct NoDirectLinksEvent {
395    pub definition_id: String,
396    pub identity_id: String,
397}
398
399/// `btw_answer_chunk`: one event of a side question's answer. `done` (not `event.type`) says
400/// the answer is complete.
401#[derive(Debug, Clone, Serialize, ts_rs::TS)]
402#[ts(export, export_to = "../../../frontend/types/rpc/")]
403#[serde(rename_all = "camelCase")]
404pub struct BtwAnswerChunkEvent {
405    pub block_id: String,
406    pub request_id: String,
407    pub event: crate::agents::types::AgentEvent,
408    pub done: bool,
409}
410
411/// `dock:clear`: the dock node to remove.
412#[derive(Debug, Clone, Serialize, ts_rs::TS)]
413#[ts(export, export_to = "../../../frontend/types/rpc/")]
414pub struct DockClearEvent {
415    pub node_id: String,
416}
417
418/// `config`: the full config, redacted for the renderer.
419#[derive(Debug, Clone, Serialize, ts_rs::TS)]
420#[ts(export, export_to = "../../../frontend/types/rpc/")]
421pub struct ConfigEvent {
422    #[ts(type = "FullConfigType")]
423    pub fullconfig: serde_json::Value,
424}
425
426/// `install_progress`: one line of install progress.
427#[derive(Debug, Clone, Serialize, ts_rs::TS)]
428#[ts(export, export_to = "../../../frontend/types/rpc/")]
429pub struct InstallProgressEvent {
430    pub message: String,
431}
432
433/// `block:activity`: what an agent pane is doing, from its terminal title.
434#[derive(Debug, Clone, Serialize, ts_rs::TS)]
435#[ts(export, export_to = "../../../frontend/types/rpc/")]
436#[serde(rename_all = "camelCase")]
437pub struct BlockActivityEvent {
438    pub block_id: String,
439    pub activity: String,
440}
441
442/// `lsp:message`: one JSON-RPC message from a language server, as it sent it.
443#[derive(Debug, Clone, Serialize, ts_rs::TS)]
444#[ts(export, export_to = "../../../frontend/types/rpc/")]
445pub struct LspMessageEvent {
446    pub server_id: String,
447    #[ts(type = "unknown")]
448    pub message: serde_json::Value,
449}
450
451/// `viewer:paired`: the device that just paired.
452#[derive(Debug, Clone, Serialize, ts_rs::TS)]
453#[ts(export, export_to = "../../../frontend/types/rpc/")]
454pub struct ViewerPairedEvent {
455    pub device_id: String,
456    pub device_name: String,
457}
458
459/// `openfloatingpane`: the pane to float, now alone in its own workspace.
460#[derive(Debug, Clone, Serialize, ts_rs::TS)]
461#[ts(export, export_to = "../../../frontend/types/rpc/")]
462pub struct OpenFloatingPaneEvent {
463    pub block_id: String,
464    pub workspace_id: String,
465}
466
467/// `agent:process-added`: a process joined an agent pane's tree.
468#[derive(Debug, Clone, Serialize, ts_rs::TS)]
469#[ts(export, export_to = "../../../frontend/types/rpc/")]
470pub struct ProcessAddedEvent {
471    pub block_id: String,
472    pub process: crate::backend::process_tracker::TrackedProcess,
473}
474
475/// `agent:process-exited`: a process left an agent pane's tree.
476#[derive(Debug, Clone, Serialize, ts_rs::TS)]
477#[ts(export, export_to = "../../../frontend/types/rpc/")]
478pub struct ProcessExitedEvent {
479    pub block_id: String,
480    pub pid: u32,
481}
482
483/// `output`: one app-server protocol frame, as the agent sent it.
484#[derive(Debug, Clone, Serialize, ts_rs::TS)]
485#[ts(export, export_to = "../../../frontend/types/rpc/")]
486pub struct ProtocolFrame(#[ts(type = "unknown")] pub serde_json::Value);
487
488/// `agentcredentials:revoked:<agent id>`: the agent still holds the provider's tokens until it restarts.
489#[derive(Debug, Clone, Serialize, ts_rs::TS)]
490#[ts(export, export_to = "../../../frontend/types/rpc/")]
491#[serde(rename_all = "camelCase")]
492pub struct CredentialsRevokedEvent {
493    /// Always `true`.
494    #[ts(type = "true")]
495    pub credentials_revoked: bool,
496    pub provider: String,
497    /// The account unlinked, when one account was.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    #[ts(optional)]
500    pub account_id: Option<String>,
501}
502
503/// `files:changed`: the folder that changed, as the Files pane shows it.
504#[derive(Debug, Clone, Serialize, ts_rs::TS)]
505#[ts(export, export_to = "../../../frontend/types/rpc/")]
506pub struct FilesChangedEvent {
507    pub dir: String,
508}
509
510/// The `agent-resume-retry` event's payload (`mps_events::AgentResumeRetry`).
511#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
512#[ts(export, export_to = "../../../frontend/types/rpc/")]
513pub struct ResumeRetryEvent {
514    #[ts(type = r#""retrying" | "resolved""#)]
515    pub status: String,
516    /// When the retry began; only on `retrying`.
517    #[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")]
518    #[ts(rename = "startedAt", optional)]
519    pub started_at: Option<String>,
520}
521
522/// `schema()` and `per_id_schema()` as the JSON the event-schema export writes: one row per event,
523/// sorted by name. A per-id event's row names its prefix, with `perId: true`.
524#[cfg(test)]
525pub fn schema_json() -> serde_json::Value {
526    let mut rows: Vec<(&str, TsType, bool)> = schema().into_iter().map(|(n, t)| (n, t, false)).collect();
527    rows.extend(per_id_schema().into_iter().map(|(n, t)| (n, t, true)));
528    rows.sort_by(|a, b| a.0.cmp(b.0));
529    serde_json::Value::Array(
530        rows.into_iter()
531            .map(|(name, ts, per_id)| {
532                let mut row = serde_json::json!({ "event": name, "payloadTs": { "name": ts.name, "imports": ts.imports } });
533                if per_id {
534                    row["perId"] = true.into();
535                }
536                row
537            })
538            .collect(),
539    )
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn each_event_is_listed_once() {
548        let names: Vec<&str> = schema().into_iter().chain(per_id_schema()).map(|(n, _)| n).collect();
549        let mut sorted = names.clone();
550        sorted.sort();
551        sorted.dedup();
552        assert_eq!(sorted.len(), names.len(), "an event listed twice: {names:?}");
553    }
554
555    #[test]
556    fn payloads_that_replaced_json_serialize_the_same() {
557        let progress = AgentProgressEvent {
558            agent_id: "a1".into(),
559            block_id: "b1".into(),
560            todos: vec![],
561            todos_truncated: 2,
562            todos_partial: false,
563            current_tool: None,
564            ts: 5,
565        };
566        assert_eq!(
567            serde_json::to_value(&progress).unwrap(),
568            serde_json::json!({ "agentId": "a1", "blockId": "b1", "todos": [], "todosTruncated": 2, "todosPartial": false, "currentTool": null, "ts": 5 })
569        );
570        let chunk = BtwAnswerChunkEvent {
571            block_id: "b1".into(),
572            request_id: "r1".into(),
573            event: crate::agents::types::AgentEvent::Error { message: "no".into() },
574            done: true,
575        };
576        assert_eq!(
577            serde_json::to_value(&chunk).unwrap(),
578            serde_json::json!({ "blockId": "b1", "requestId": "r1", "event": { "type": "error", "message": "no" }, "done": true })
579        );
580        let frame = ProtocolFrame(serde_json::json!({ "method": "turn/started" }));
581        assert_eq!(serde_json::to_value(&frame).unwrap(), serde_json::json!({ "method": "turn/started" }));
582    }
583
584    #[test]
585    fn lan_payloads_serialize_as_the_json_they_replace() {
586        use crate::backend::eventbus::EventBus;
587        use crate::backend::lan_discovery::{LanDiscoverability, LanDiscoveryError, LanInstanceList};
588        let empty = EventBus::typed_ws_event::<LanInstances>(&LanInstanceList(Vec::new()), String::new());
589        assert_eq!((empty.eventtype.as_str(), empty.data), ("laninstances", Some(serde_json::json!([]))));
590        let error = LanDiscoveryError { error: "bind failed".into() };
591        assert_eq!(serde_json::to_value(&error).unwrap(), serde_json::json!({ "error": "bind failed" }));
592        let health = LanDiscoverability { state: "degraded".into(), missing: vec!["192.0.2.7".into()], rebuilds: 1 };
593        assert_eq!(serde_json::to_value(&health).unwrap(), serde_json::json!({ "state": "degraded", "missing": ["192.0.2.7"], "rebuilds": 1 }));
594    }
595
596    #[test]
597    fn raw_event_payloads_serialize_as_the_json_they_replace() {
598        use crate::backend::eventbus::EventBus;
599        let progress = EventBus::typed_ws_event::<InstallProgress>(&InstallProgressEvent { message: "npm i".into() }, "block:b1");
600        assert_eq!((progress.eventtype.as_str(), progress.oref.as_str()), ("install_progress", "block:b1"));
601        assert_eq!(progress.data, Some(serde_json::json!({ "message": "npm i" })));
602        let activity = BlockActivityEvent { block_id: "b1".into(), activity: "auth refactor".into() };
603        assert_eq!(serde_json::to_value(&activity).unwrap(), serde_json::json!({ "blockId": "b1", "activity": "auth refactor" }));
604        let lsp = LspMessageEvent { server_id: "rust".into(), message: serde_json::json!({ "id": 1 }) };
605        assert_eq!(serde_json::to_value(&lsp).unwrap(), serde_json::json!({ "server_id": "rust", "message": { "id": 1 } }));
606        let paired = ViewerPairedEvent { device_id: "d1".into(), device_name: "Phone".into() };
607        assert_eq!(serde_json::to_value(&paired).unwrap(), serde_json::json!({ "device_id": "d1", "device_name": "Phone" }));
608        // The batch goes out as the bare array, as it did before.
609        let update = crate::backend::obj::MuxObjUpdate { updatetype: "delete".into(), otype: "tab".into(), oid: "t1".into(), obj: None };
610        let batch = EventBus::typed_ws_event::<MuxObjBatchedUpdates>(&crate::backend::obj::MuxObjBatch(vec![update]), String::new());
611        assert_eq!(batch.eventtype, "waveobj:batchedupdates");
612        assert_eq!(batch.data, Some(serde_json::json!([{ "updatetype": "delete", "otype": "tab", "oid": "t1" }])));
613    }
614
615    #[test]
616    fn a_typed_frame_is_the_eventrecv_frame_srv_wrote_by_hand() {
617        let frame = crate::backend::eventbus::EventBus::typed_frame::<Config>(&ConfigEvent { fullconfig: serde_json::json!({ "settings": {} }) });
618        assert_eq!(
619            frame,
620            serde_json::json!({ "eventtype": "rpc", "data": { "command": "eventrecv", "data": { "event": "config", "data": { "fullconfig": { "settings": {} } } } } })
621        );
622        let activate = crate::backend::notify::router::NotificationActivateEvent {
623            id: "n1".into(),
624            block_id: "b1".into(),
625            tab_id: "t1".into(),
626            window_id: "w1".into(),
627            workspace_id: None,
628            at_ms: 7,
629        };
630        assert_eq!(
631            serde_json::to_value(&activate).unwrap(),
632            serde_json::json!({ "id": "n1", "block_id": "b1", "tab_id": "t1", "window_id": "w1", "workspace_id": null, "at_ms": 7 })
633        );
634    }
635
636    #[test]
637    fn a_client_event_is_published_only_with_its_payload() {
638        use super::super::mps::Broker;
639        use serde_json::json;
640        let broker = Broker::new();
641        let scopes = vec!["block:b1".to_string()];
642        let chunk = json!({ "op": "chunk", "tool_id": "toolu_1", "kind": "stdout", "content": "hi\n", "timestamp": 5 });
643        assert_eq!(publish_client_event(&broker, "tool_chunk", &chunk, &scopes, 8), Some(Ok(())));
644        let stored = broker.read_event_history("tool_chunk", "block:b1", 8);
645        assert_eq!(stored.len(), 1);
646        assert_eq!(stored[0].data, Some(chunk), "goes out as it came in");
647
648        let wrong = json!({ "op": "chunk", "tool_id": "toolu_1", "kind": "tty", "content": "", "timestamp": 0 });
649        let refused = publish_client_event(&broker, "tool_chunk", &wrong, &scopes, 8).expect("a client event");
650        assert!(refused.unwrap_err().starts_with("tool_chunk: unknown variant `tty`"));
651        let claim = json!({ "kind": "bundles", "holder": "main", "epoch": "1" });
652        assert!(publish_client_event(&broker, "singleton:claim", &claim, &["singleton:bundles".to_string()], 1).unwrap().is_err());
653        assert_eq!(broker.read_event_history("tool_chunk", "block:b1", 8).len(), 1, "a refused event isn't stored");
654
655        assert_eq!(publish_client_event(&broker, "someones:event", &json!({ "any": 1 }), &scopes, 1), None, "not a client event");
656    }
657
658    #[test]
659    fn srv_events_are_the_declared_ones_clients_dont_publish() {
660        assert!(is_srv_event("controllerstatus"));
661        assert!(is_srv_event("agents:changed"), "a signal with no payload too");
662        assert!(is_srv_event("agent_event:b1"), "a per-id event, by its prefix");
663        assert!(is_srv_event("dronerun:r1"));
664        for client in ["tool_chunk", "compaction_started", "singleton:claim"] {
665            assert!(is_typed_event(client) && !is_srv_event(client), "{client} is a client's");
666        }
667        assert!(!is_srv_event("someones:event"), "an undeclared event is relayed");
668        assert!(!is_srv_event("agent_event"), "a prefix needs its id");
669    }
670
671    #[test]
672    fn a_released_singleton_claim_names_no_holder() {
673        let release = SingletonClaimEvent { kind: "bundles".into(), holder: None, epoch: 3 };
674        assert_eq!(serde_json::to_value(&release).unwrap(), serde_json::json!({ "kind": "bundles", "holder": null, "epoch": 3 }));
675    }
676
677    #[test]
678    fn payloads_name_their_generated_types() {
679        let rows = schema();
680        let sys = rows.iter().find(|(n, _)| *n == "sysinfo").expect("sysinfo");
681        assert_eq!(sys.1.name, "TimeSeriesData");
682        assert_eq!(sys.1.imports, vec!["TimeSeriesData".to_string()]);
683    }
684}