Skip to main content

kernel/event/record/
collaboration.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What residents did together: the goals a building pursues, the
7//! ground two goals met on, and the signals they send one another.
8//!
9//! `goal_registered` has no struct here: its payload is the
10//! [`GoalEntry`](crate::GoalEntry) the goal register holds, written and
11//! read through `Payload::of` and `Payload::read` like every record.
12
13use serde::{Deserialize, Serialize};
14
15use crate::{Address, AxCode, AxError, ByteLen, GoalId, Payload, PursuitState, TimeMs, Version};
16
17/// `goal_conflict`: a goal that asked for ground another goal holds,
18/// and how far up the settling had to go.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21pub struct GoalConflict {
22    pub goal: GoalId,
23    pub with: GoalId,
24    pub level: ConflictLevel,
25}
26
27/// Which level settles a `goal_conflict`: `serialize` runs the goal
28/// after the one it met, `arbitrate` hands both statements to a
29/// resident.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33pub enum ConflictLevel {
34    Serialize,
35    Arbitrate,
36}
37
38/// `pursuit_changed`: one step a person took on a building's pursuit,
39/// and the goal the building holds after it.
40///
41/// `goal` is absent exactly when the step left no pursuit, which is a
42/// `clear`; every other step leaves one and writes its goal.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45pub struct PursuitChanged {
46    pub step: PursuitMove,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub goal: Option<String>,
49}
50
51/// Which step a `pursuit_changed` line records.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55pub enum PursuitMove {
56    Set,
57    Pause,
58    Resume,
59    Clear,
60}
61
62impl PursuitChanged {
63    /// The goal and state the building holds after this step, or `None`
64    /// when the step cleared it.
65    ///
66    /// # Errors
67    /// Refuses a step that leaves a pursuit but names no goal: a reader
68    /// that invented an empty goal for it would hold a pursuit nobody
69    /// declared.
70    pub fn held(self) -> Result<Option<(String, PursuitState)>, AxError> {
71        let state = match self.step {
72            PursuitMove::Set | PursuitMove::Resume => PursuitState::Running,
73            PursuitMove::Pause => PursuitState::Paused,
74            PursuitMove::Clear => return Ok(None),
75        };
76        let goal = self.goal.ok_or_else(|| {
77            AxError::failure(
78                AxCode::WireMismatch,
79                "read a pursuit_changed line",
80                "a step that leaves a pursuit names no goal",
81            )
82            .with_recovery("replay with the build that wrote this record")
83        })?;
84        Ok(Some((goal, state)))
85    }
86}
87
88/// `signal_enqueued`: the signal itself, and the line it waits in.
89///
90/// `collab::Signal` writes it and reads it back; the queue rebuilt after
91/// a restart and every view of what is waiting read the same struct, so
92/// the seven keys have one spelling.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct SignalEnqueued {
95    pub id: SignalId,
96    pub kind: SignalKind,
97    pub from: String,
98    pub room: Address,
99    pub room_version: Version,
100    pub payload: Payload,
101    pub at: TimeMs,
102    /// Written for a reader outside collab and never trusted on the way
103    /// back: the lane is derived from `kind`, and reading a stored copy
104    /// would let a line say which lane it took while the derivation says
105    /// another. Absent on a line written before the key existed.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub lane: Option<Lane>,
108}
109
110/// `signal_consumed`: which signal was taken, and by whom. The content
111/// is already in the enqueue line, and history does not need it twice.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct SignalConsumed {
114    pub id: SignalId,
115    pub by: String,
116}
117
118/// `worktree_opened`: the tree a resident was given to work a claimed
119/// node in, by name and measured size. It carries no path: an absolute
120/// path is a fact about one machine, and a history that holds one does
121/// not survive being moved to another.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct WorktreeOpened {
124    pub name: String,
125    pub disk_bytes: ByteLen,
126}
127
128/// Which line a signal waits in.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum Lane {
132    Urgent,
133    Ordinary,
134}
135
136/// A signal's identity, and the thing duplicates are recognised by.
137///
138/// It serializes as the string [`SignalId::parse`] accepted and reads
139/// back through that same parse, so a ledger line carrying an id meets
140/// the grammar once rather than once per reader.
141#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
142#[serde(try_from = "String", into = "String")]
143pub struct SignalId(String);
144
145impl TryFrom<String> for SignalId {
146    type Error = AxError;
147
148    fn try_from(raw: String) -> Result<SignalId, AxError> {
149        SignalId::parse(&raw)
150    }
151}
152
153impl From<SignalId> for String {
154    fn from(id: SignalId) -> String {
155        id.0
156    }
157}
158
159impl SignalId {
160    /// # Errors
161    /// Refuses an empty id and one carrying whitespace: an id is
162    /// compared, logged and replayed, and all three go wrong quietly
163    /// when it can contain a space.
164    pub fn parse(raw: &str) -> Result<SignalId, AxError> {
165        if raw.is_empty() || raw.chars().any(char::is_whitespace) {
166            return Err(AxError::failure(
167                AxCode::InvalidArgs,
168                "read a signal id",
169                format!("{raw:?}"),
170            )
171            .with_recovery(
172                "use a non-empty id with no whitespace, such as a run id and a counter",
173            ));
174        }
175        Ok(SignalId(raw.to_owned()))
176    }
177
178    #[must_use]
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182}
183
184/// What kind of communication a signal is. `Steer` is a fourth kind
185/// rather than a flag beside the other three: it is the only one that
186/// overtakes, and urgency has to belong to the signal for one id to
187/// always take one lane.
188///
189/// Its serde form goes through [`SignalKind::as_str`] and
190/// [`SignalKind::parse`] rather than through a derived renaming, so the
191/// four wire words are spelled in one place.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(try_from = "String", into = "String")]
194pub enum SignalKind {
195    Mention,
196    Thread,
197    Broadcast,
198    Steer,
199}
200
201impl SignalKind {
202    #[must_use]
203    pub fn as_str(self) -> &'static str {
204        match self {
205            SignalKind::Mention => "mention",
206            SignalKind::Thread => "thread",
207            SignalKind::Broadcast => "broadcast",
208            SignalKind::Steer => "steer",
209        }
210    }
211
212    /// # Errors
213    /// Refuses a kind this version does not know.
214    pub fn parse(raw: &str) -> Result<SignalKind, AxError> {
215        match raw {
216            "mention" => Ok(SignalKind::Mention),
217            "thread" => Ok(SignalKind::Thread),
218            "broadcast" => Ok(SignalKind::Broadcast),
219            "steer" => Ok(SignalKind::Steer),
220            other => {
221                Err(
222                    AxError::failure(AxCode::InvalidArgs, "read a signal kind", other.to_owned())
223                        .with_recovery("mention, thread, broadcast or steer"),
224                )
225            }
226        }
227    }
228}
229
230impl TryFrom<String> for SignalKind {
231    type Error = AxError;
232
233    fn try_from(raw: String) -> Result<SignalKind, AxError> {
234        SignalKind::parse(&raw)
235    }
236}
237
238impl From<SignalKind> for String {
239    fn from(kind: SignalKind) -> String {
240        kind.as_str().to_owned()
241    }
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
246mod tests {
247    use super::*;
248
249    /// The bytes `accounting::worker::plans` wrote by hand: `step` always,
250    /// `goal` only while a pursuit is still held.
251    #[test]
252    fn a_pursuit_step_writes_the_keys_the_hand_written_map_wrote() {
253        let hand = |json: serde_json::Value| {
254            serde_json::to_string(&Payload::new(json.as_object().unwrap().clone()).unwrap())
255                .unwrap()
256        };
257        let typed =
258            |record: &PursuitChanged| serde_json::to_string(&Payload::of(record).unwrap()).unwrap();
259        let paused = PursuitChanged {
260            step: PursuitMove::Pause,
261            goal: Some("read the meter".to_owned()),
262        };
263        let cleared = PursuitChanged {
264            step: PursuitMove::Clear,
265            goal: None,
266        };
267        assert_eq!(
268            (typed(&paused), typed(&cleared)),
269            (
270                hand(serde_json::json!({ "step": "pause", "goal": "read the meter" })),
271                hand(serde_json::json!({ "step": "clear" })),
272            )
273        );
274        assert_eq!(
275            (paused.held().unwrap(), cleared.held().unwrap()),
276            (
277                Some(("read the meter".to_owned(), PursuitState::Paused)),
278                None
279            )
280        );
281    }
282
283    /// The bytes `storage`'s worktree lease wrote by hand: a name and a
284    /// size, and no path.
285    #[test]
286    fn a_worktree_line_writes_the_keys_the_hand_written_map_wrote() {
287        let opened = WorktreeOpened {
288            name: "node-1".to_owned(),
289            disk_bytes: ByteLen::new(6),
290        };
291        assert_eq!(
292            serde_json::to_string(&Payload::of(&opened).unwrap()).unwrap(),
293            r#"{"disk_bytes":6,"name":"node-1"}"#
294        );
295    }
296
297    /// The bytes `collab::arbiter::conflict_payload` wrote by hand, and
298    /// the `goal_registered` entry, which serde wrote from `GoalEntry`.
299    #[test]
300    fn a_goal_line_writes_the_keys_the_hand_written_map_wrote() {
301        let conflict = GoalConflict {
302            goal: GoalId::new("b").unwrap(),
303            with: GoalId::new("a").unwrap(),
304            level: ConflictLevel::Arbitrate,
305        };
306        let entry = crate::GoalEntry {
307            id: GoalId::new("a").unwrap(),
308            owner: "lab/a".to_owned(),
309            resources: vec![crate::GoalResource::External("the printer".to_owned())],
310            statement: "print".to_owned(),
311            standing: true,
312        };
313        let bytes = |payload: Payload| serde_json::to_string(&payload).unwrap();
314        assert_eq!(
315            (
316                bytes(Payload::of(&conflict).unwrap()),
317                bytes(Payload::of(&entry).unwrap()),
318            ),
319            (
320                r#"{"goal":"b","level":"arbitrate","with":"a"}"#.to_owned(),
321                r#"{"id":"a","owner":"lab/a","resources":[{"external":"the printer"}],"standing":true,"statement":"print"}"#.to_owned(),
322            )
323        );
324        assert_eq!(
325            Payload::of(&conflict)
326                .unwrap()
327                .read::<GoalConflict>()
328                .unwrap(),
329            conflict
330        );
331    }
332}