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/// `signal_wait_started`: a run that sent `signal` with `wait` stopped
119/// at its safe point until a reply from `on` or the deadline
120/// (`crates/kernel/spec/Event/Record.lean` D32). `deadline_ms` is a
121/// reading of the injected clock, not the wall: replay orders by seq,
122/// and the wire turns it into a wall-clock instant (wire D34).
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct SignalWaitStarted {
125    pub on: Address,
126    pub signal: SignalId,
127    pub deadline_ms: u64,
128}
129
130/// `signal_wait_ended`: the one line that pairs with a
131/// `signal_wait_started`, and what ended the wait.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct SignalWaitEnded {
134    pub signal: SignalId,
135    pub by: WaitEnd,
136}
137
138/// What ended a reply wait. Three arms and no fourth: the model's
139/// `waits_end` holds every wait to one of them.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case", tag = "end")]
142pub enum WaitEnd {
143    /// The room waited on sent a signal to the waiting run's room.
144    Reply { reply: SignalId },
145    /// The injected clock passed the deadline.
146    Timeout,
147    /// The run left its room while it waited: cancelled, failed, or
148    /// frozen after the process died.
149    Left,
150}
151
152/// `signal_landed`: where the accounting thread put the signal
153/// `signal` at the moment it delivered it, written right after its
154/// `signal_enqueued` (`crates/kernel/spec/Event/Record.lean` D38).
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct SignalLanded {
157    pub signal: SignalId,
158    pub landing: Landing,
159}
160
161/// Where one signal landed. Three arms and no fourth: the model's
162/// `one_landing_per_signal` gives every signal exactly one of them.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166pub enum Landing {
167    /// A run was working in the room and reads it at its next safe point.
168    Delivered,
169    /// Nobody was working there and no new run was knocked for it: a
170    /// knock was already waiting, or the room has no resident.
171    Queued,
172    /// Nobody was working there, and the delivery knocked for a new run.
173    Knocked,
174}
175
176/// `worktree_opened`: the tree a resident was given to work a claimed
177/// node in, by name and measured size. It carries no path: an absolute
178/// path is a fact about one machine, and a history that holds one does
179/// not survive being moved to another.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct WorktreeOpened {
182    pub name: String,
183    pub disk_bytes: ByteLen,
184}
185
186/// Which line a signal waits in.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum Lane {
190    Urgent,
191    Ordinary,
192}
193
194/// A signal's identity, and the thing duplicates are recognised by.
195///
196/// It serializes as the string [`SignalId::parse`] accepted and reads
197/// back through that same parse, so a ledger line carrying an id meets
198/// the grammar once rather than once per reader.
199#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
200#[serde(try_from = "String", into = "String")]
201pub struct SignalId(String);
202
203impl TryFrom<String> for SignalId {
204    type Error = AxError;
205
206    fn try_from(raw: String) -> Result<SignalId, AxError> {
207        SignalId::parse(&raw)
208    }
209}
210
211impl From<SignalId> for String {
212    fn from(id: SignalId) -> String {
213        id.0
214    }
215}
216
217impl SignalId {
218    /// # Errors
219    /// Refuses an empty id and one carrying whitespace: an id is
220    /// compared, logged and replayed, and all three go wrong quietly
221    /// when it can contain a space.
222    pub fn parse(raw: &str) -> Result<SignalId, AxError> {
223        if raw.is_empty() || raw.chars().any(char::is_whitespace) {
224            return Err(AxError::failure(
225                AxCode::InvalidArgs,
226                "read a signal id",
227                format!("{raw:?}"),
228            )
229            .with_recovery(
230                "use a non-empty id with no whitespace, such as a run id and a counter",
231            ));
232        }
233        Ok(SignalId(raw.to_owned()))
234    }
235
236    #[must_use]
237    pub fn as_str(&self) -> &str {
238        &self.0
239    }
240}
241
242/// What kind of communication a signal is. `Steer` is a fourth kind
243/// rather than a flag beside the other three: it is the only one that
244/// overtakes, and urgency has to belong to the signal for one id to
245/// always take one lane.
246///
247/// Its serde form goes through [`SignalKind::as_str`] and
248/// [`SignalKind::parse`] rather than through a derived renaming, so the
249/// four wire words are spelled in one place.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(try_from = "String", into = "String")]
252pub enum SignalKind {
253    Mention,
254    Thread,
255    Broadcast,
256    Steer,
257}
258
259impl SignalKind {
260    #[must_use]
261    pub fn as_str(self) -> &'static str {
262        match self {
263            SignalKind::Mention => "mention",
264            SignalKind::Thread => "thread",
265            SignalKind::Broadcast => "broadcast",
266            SignalKind::Steer => "steer",
267        }
268    }
269
270    /// # Errors
271    /// Refuses a kind this version does not know.
272    pub fn parse(raw: &str) -> Result<SignalKind, AxError> {
273        match raw {
274            "mention" => Ok(SignalKind::Mention),
275            "thread" => Ok(SignalKind::Thread),
276            "broadcast" => Ok(SignalKind::Broadcast),
277            "steer" => Ok(SignalKind::Steer),
278            other => {
279                Err(
280                    AxError::failure(AxCode::InvalidArgs, "read a signal kind", other.to_owned())
281                        .with_recovery("mention, thread, broadcast or steer"),
282                )
283            }
284        }
285    }
286}
287
288impl TryFrom<String> for SignalKind {
289    type Error = AxError;
290
291    fn try_from(raw: String) -> Result<SignalKind, AxError> {
292        SignalKind::parse(&raw)
293    }
294}
295
296impl From<SignalKind> for String {
297    fn from(kind: SignalKind) -> String {
298        kind.as_str().to_owned()
299    }
300}
301
302#[cfg(test)]
303#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
304mod tests {
305    use super::*;
306
307    /// The bytes `accounting::worker::plans` wrote by hand: `step` always,
308    /// `goal` only while a pursuit is still held.
309    #[test]
310    fn a_pursuit_step_writes_the_keys_the_hand_written_map_wrote() {
311        let hand = |json: serde_json::Value| {
312            serde_json::to_string(&Payload::new(json.as_object().unwrap().clone()).unwrap())
313                .unwrap()
314        };
315        let typed =
316            |record: &PursuitChanged| serde_json::to_string(&Payload::of(record).unwrap()).unwrap();
317        let paused = PursuitChanged {
318            step: PursuitMove::Pause,
319            goal: Some("read the meter".to_owned()),
320        };
321        let cleared = PursuitChanged {
322            step: PursuitMove::Clear,
323            goal: None,
324        };
325        assert_eq!(
326            (typed(&paused), typed(&cleared)),
327            (
328                hand(serde_json::json!({ "step": "pause", "goal": "read the meter" })),
329                hand(serde_json::json!({ "step": "clear" })),
330            )
331        );
332        assert_eq!(
333            (paused.held().unwrap(), cleared.held().unwrap()),
334            (
335                Some(("read the meter".to_owned(), PursuitState::Paused)),
336                None
337            )
338        );
339    }
340
341    /// The bytes `storage`'s worktree lease wrote by hand: a name and a
342    /// size, and no path.
343    #[test]
344    fn a_worktree_line_writes_the_keys_the_hand_written_map_wrote() {
345        let opened = WorktreeOpened {
346            name: "node-1".to_owned(),
347            disk_bytes: ByteLen::new(6),
348        };
349        assert_eq!(
350            serde_json::to_string(&Payload::of(&opened).unwrap()).unwrap(),
351            r#"{"disk_bytes":6,"name":"node-1"}"#
352        );
353    }
354
355    /// The bytes `collab::arbiter::conflict_payload` wrote by hand, and
356    /// the `goal_registered` entry, which serde wrote from `GoalEntry`.
357    #[test]
358    fn a_goal_line_writes_the_keys_the_hand_written_map_wrote() {
359        let conflict = GoalConflict {
360            goal: GoalId::new("b").unwrap(),
361            with: GoalId::new("a").unwrap(),
362            level: ConflictLevel::Arbitrate,
363        };
364        let entry = crate::GoalEntry {
365            id: GoalId::new("a").unwrap(),
366            owner: "lab/a".to_owned(),
367            resources: vec![crate::GoalResource::External("the printer".to_owned())],
368            statement: "print".to_owned(),
369            standing: true,
370        };
371        let bytes = |payload: Payload| serde_json::to_string(&payload).unwrap();
372        assert_eq!(
373            (
374                bytes(Payload::of(&conflict).unwrap()),
375                bytes(Payload::of(&entry).unwrap()),
376            ),
377            (
378                r#"{"goal":"b","level":"arbitrate","with":"a"}"#.to_owned(),
379                r#"{"id":"a","owner":"lab/a","resources":[{"external":"the printer"}],"standing":true,"statement":"print"}"#.to_owned(),
380            )
381        );
382        assert_eq!(
383            Payload::of(&conflict)
384                .unwrap()
385                .read::<GoalConflict>()
386                .unwrap(),
387            conflict
388        );
389    }
390}