Skip to main content

systemprompt_models/documents/
events.rs

1//! Payloads of the AG-UI custom events the document store emits.
2//!
3//! Each payload carries its event name as `NAME`; [`DocumentEventName`]
4//! enumerates them for routing and subscription filters.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use systemprompt_identifiers::{
12    ContentHash, DocumentCommentId, DocumentEditSessionId, DocumentId, DocumentNodeId,
13    DocumentProposalId, DocumentRevisionId, DriveDeviceId, UserId,
14};
15
16use super::access::{AccessLevel, ActorKind, ProposalStatus, RevisionOriginKind};
17use super::drive::{PresenceSource, PresenceState};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
20pub enum DocumentEventName {
21    #[serde(rename = "document.revision.created")]
22    RevisionCreated,
23    #[serde(rename = "document.presence")]
24    Presence,
25    #[serde(rename = "document.lock")]
26    Lock,
27    #[serde(rename = "document.conflict")]
28    Conflict,
29    #[serde(rename = "document.collab.update")]
30    CollabUpdate,
31    #[serde(rename = "document.comment.created")]
32    CommentCreated,
33    #[serde(rename = "document.comment.resolved")]
34    CommentResolved,
35    #[serde(rename = "document.shared")]
36    Shared,
37    #[serde(rename = "document.ownership_transferred")]
38    OwnershipTransferred,
39    #[serde(rename = "document.proposal.opened")]
40    ProposalOpened,
41    #[serde(rename = "document.proposal.accepted")]
42    ProposalAccepted,
43    #[serde(rename = "document.proposal.rejected")]
44    ProposalRejected,
45    #[serde(rename = "document.proposal.superseded")]
46    ProposalSuperseded,
47}
48
49impl DocumentEventName {
50    pub const ALL: [Self; 13] = [
51        Self::RevisionCreated,
52        Self::Presence,
53        Self::Lock,
54        Self::Conflict,
55        Self::CollabUpdate,
56        Self::CommentCreated,
57        Self::CommentResolved,
58        Self::Shared,
59        Self::OwnershipTransferred,
60        Self::ProposalOpened,
61        Self::ProposalAccepted,
62        Self::ProposalRejected,
63        Self::ProposalSuperseded,
64    ];
65
66    #[must_use]
67    pub const fn as_str(self) -> &'static str {
68        match self {
69            Self::RevisionCreated => DocumentRevisionCreatedEvent::NAME,
70            Self::Presence => DocumentPresenceEvent::NAME,
71            Self::Lock => DocumentLockEvent::NAME,
72            Self::Conflict => DocumentConflictEvent::NAME,
73            Self::CollabUpdate => DocumentCollabUpdateEvent::NAME,
74            Self::CommentCreated => DocumentCommentCreatedEvent::NAME,
75            Self::CommentResolved => DocumentCommentResolvedEvent::NAME,
76            Self::Shared => DocumentSharedEvent::NAME,
77            Self::OwnershipTransferred => DocumentOwnershipTransferredEvent::NAME,
78            Self::ProposalOpened => DocumentProposalEvent::OPENED,
79            Self::ProposalAccepted => DocumentProposalEvent::ACCEPTED,
80            Self::ProposalRejected => DocumentProposalEvent::REJECTED,
81            Self::ProposalSuperseded => DocumentProposalEvent::SUPERSEDED,
82        }
83    }
84
85    #[must_use]
86    pub fn from_name(name: &str) -> Option<Self> {
87        Self::ALL.into_iter().find(|event| event.as_str() == name)
88    }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92pub struct DocumentRevisionCreatedEvent {
93    pub document_id: DocumentId,
94    pub revision_id: DocumentRevisionId,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub parent_revision_id: Option<DocumentRevisionId>,
97    pub content_sha256: ContentHash,
98    pub author_id: UserId,
99    pub actor_kind: ActorKind,
100    pub origin_kind: RevisionOriginKind,
101}
102
103impl DocumentRevisionCreatedEvent {
104    pub const NAME: &'static str = "document.revision.created";
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108pub struct DocumentPresenceEvent {
109    pub document_id: DocumentId,
110    pub user_id: UserId,
111    pub state: PresenceState,
112    pub source_kind: PresenceSource,
113    pub source_ref: String,
114    // JSON: editor cursor position; the shape belongs to the client's editor.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub cursor: Option<serde_json::Value>,
117}
118
119impl DocumentPresenceEvent {
120    pub const NAME: &'static str = "document.presence";
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
124pub struct DocumentLockEvent {
125    pub document_id: DocumentId,
126    pub locked: bool,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub holder_id: Option<UserId>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub edit_session_id: Option<DocumentEditSessionId>,
131}
132
133impl DocumentLockEvent {
134    pub const NAME: &'static str = "document.lock";
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138pub struct DocumentConflictEvent {
139    pub document_id: DocumentId,
140    pub path: String,
141    pub head_revision_id: DocumentRevisionId,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub base_revision_id: Option<DocumentRevisionId>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub device_id: Option<DriveDeviceId>,
146}
147
148impl DocumentConflictEvent {
149    pub const NAME: &'static str = "document.conflict";
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
153pub struct DocumentCollabUpdateEvent {
154    pub document_id: DocumentId,
155    pub edit_session_id: DocumentEditSessionId,
156    pub seq: i64,
157    pub update_b64: String,
158    pub author_id: UserId,
159}
160
161impl DocumentCollabUpdateEvent {
162    pub const NAME: &'static str = "document.collab.update";
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
166#[expect(
167    clippy::struct_field_names,
168    reason = "the document event wire vocabulary names every reference document_id, comment_id, \
169              node_id, parent_id and author_id, as the sibling events do"
170)]
171pub struct DocumentCommentCreatedEvent {
172    pub document_id: DocumentId,
173    pub comment_id: DocumentCommentId,
174    pub node_id: DocumentNodeId,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub parent_id: Option<DocumentCommentId>,
177    pub author_id: UserId,
178}
179
180impl DocumentCommentCreatedEvent {
181    pub const NAME: &'static str = "document.comment.created";
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
185pub struct DocumentCommentResolvedEvent {
186    pub document_id: DocumentId,
187    pub comment_id: DocumentCommentId,
188    pub resolved_by: UserId,
189}
190
191impl DocumentCommentResolvedEvent {
192    pub const NAME: &'static str = "document.comment.resolved";
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
196pub struct DocumentSharedEvent {
197    pub document_id: DocumentId,
198    pub subject_kind: String,
199    pub subject_value: String,
200    pub level: AccessLevel,
201    pub by: UserId,
202}
203
204impl DocumentSharedEvent {
205    pub const NAME: &'static str = "document.shared";
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
209pub struct DocumentOwnershipTransferredEvent {
210    pub document_id: DocumentId,
211    pub from: UserId,
212    pub to: UserId,
213}
214
215impl DocumentOwnershipTransferredEvent {
216    pub const NAME: &'static str = "document.ownership_transferred";
217}
218
219/// The payload of every `document.proposal.*` event; the event name says
220/// which transition the proposal made and `status` repeats it.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
222pub struct DocumentProposalEvent {
223    pub document_id: DocumentId,
224    pub proposal_id: DocumentProposalId,
225    pub status: ProposalStatus,
226    pub proposer_id: UserId,
227    pub base_revision_id: DocumentRevisionId,
228    pub proposed_revision_id: DocumentRevisionId,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub reviewer_id: Option<UserId>,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub landed_revision_id: Option<DocumentRevisionId>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub message: Option<String>,
235}
236
237impl DocumentProposalEvent {
238    pub const OPENED: &'static str = "document.proposal.opened";
239    pub const ACCEPTED: &'static str = "document.proposal.accepted";
240    pub const REJECTED: &'static str = "document.proposal.rejected";
241    pub const SUPERSEDED: &'static str = "document.proposal.superseded";
242
243    #[must_use]
244    pub const fn name_of(status: ProposalStatus) -> &'static str {
245        match status {
246            ProposalStatus::Open => Self::OPENED,
247            ProposalStatus::Accepted => Self::ACCEPTED,
248            ProposalStatus::Rejected => Self::REJECTED,
249            ProposalStatus::Superseded => Self::SUPERSEDED,
250        }
251    }
252}