1use std::collections::BTreeMap;
45use std::path::Path;
46
47use schemars::JsonSchema;
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50
51use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
52use crate::capability::handler::{CapabilityError, Context};
53use crate::capability::model::{Exposure, Stability};
54use crate::capability::module::ModuleDescriptor;
55use crate::{capability, module};
56
57use super::{get, mcp, Empty};
58
59pub const RECORDING: &str = ".ai/repo/observatory/recording.json";
61
62pub const RECORDING_SCHEMA: &str = "observatory-recording/v1";
64
65pub const RECORDED: &[&str] = &["mesh", "activity", "coop"];
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
79pub struct ObservatoryRecording {
80 pub schema: String,
82 pub recorded_at: String,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub revision: Option<String>,
87 pub scenes: BTreeMap<String, Value>,
90 pub manifest: RecordingManifest,
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
105pub struct RecordingManifest {
106 pub sources: Vec<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub window: Option<RecordingWindow>,
112 pub removed: Vec<String>,
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
124pub struct RecordingWindow {
125 pub events: usize,
127 pub from_at: Option<u64>,
129 pub to_at: Option<u64>,
131 pub from_lamport: Option<u64>,
133 pub to_lamport: Option<u64>,
135}
136
137pub const REMOVED: &[&str] = &[
140 "machine, runtime and session identities: replaced by ordinals, because a node id names a machine and a session key names a person's work",
141 "machine names: replaced by `machine N` in the order the mesh listed them",
142 "event bodies: an intent, a path or a message is the capability's to show to a reader of the repository, not a public page's",
143 "event identities (stream and sequence): replaced by ordinals in the recorded order",
144];
145
146fn manifest(scenes: &BTreeMap<String, Value>) -> RecordingManifest {
148 let sources = ["mesh.peers", "mesh.events", "mesh.state"]
149 .into_iter()
150 .zip(RECORDED)
151 .filter(|(_, scene)| scenes.contains_key(**scene))
152 .map(|(capability, _)| capability.to_string())
153 .collect();
154 let events: Vec<&Value> = scenes
155 .get("activity")
156 .and_then(|a| a.get("events"))
157 .and_then(Value::as_array)
158 .map(|v| v.iter().collect())
159 .unwrap_or_default();
160 let of = |key: &str| {
161 events
162 .iter()
163 .filter_map(|e| e.get(key).and_then(Value::as_u64))
164 .collect::<Vec<_>>()
165 };
166 let (at, lamport) = (of("at"), of("lamport"));
167 let window = (!events.is_empty()).then(|| RecordingWindow {
168 events: events.len(),
169 from_at: at.iter().min().copied(),
170 to_at: at.iter().max().copied(),
171 from_lamport: lamport.iter().min().copied(),
172 to_lamport: lamport.iter().max().copied(),
173 });
174 RecordingManifest {
175 sources,
176 window,
177 removed: REMOVED.iter().map(|r| r.to_string()).collect(),
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
191pub struct ObservatoryReplay {
192 pub present: bool,
194 pub path: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub recording: Option<ObservatoryRecording>,
199}
200
201pub fn redact(scenes: &BTreeMap<String, Value>) -> BTreeMap<String, Value> {
219 let list = |v: &Value, key: &str| {
220 v.get(key)
221 .and_then(Value::as_array)
222 .cloned()
223 .unwrap_or_default()
224 };
225 let prefix = |id: &str| id.chars().take(12).collect::<String>();
226 let mut machines: BTreeMap<String, String> = BTreeMap::new();
228 let mut out = BTreeMap::new();
229 if let Some(mesh) = scenes.get("mesh") {
230 let listed = list(mesh, "machines");
231 let redacted: Vec<Value> = listed
232 .iter()
233 .enumerate()
234 .map(|(i, m)| {
235 let label = format!("machine {}", i + 1);
236 let id = m.get("id").and_then(Value::as_str).unwrap_or_default();
237 machines.insert(prefix(id), label.clone());
238 let runtimes: Vec<Value> = list(m, "runtimes")
239 .iter()
240 .enumerate()
241 .map(|(j, r)| {
242 serde_json::json!({
243 "id": format!("m{}-r{}", i + 1, j + 1),
244 "liveness": r.get("liveness").cloned().unwrap_or(Value::Null),
245 "this": r.get("this").cloned().unwrap_or(Value::Bool(false)),
246 "sessions": r.get("sessions").cloned().unwrap_or(Value::Null),
247 })
248 })
249 .collect();
250 serde_json::json!({
251 "id": format!("m{}", i + 1),
252 "label": label,
253 "local": m.get("local").cloned().unwrap_or(Value::Null),
254 "runtimes": runtimes,
255 })
256 })
257 .collect();
258 out.insert(
259 "mesh".to_string(),
260 serde_json::json!({
261 "machines": redacted,
262 "known": mesh.get("known").cloned().unwrap_or(Value::Null),
263 "refused": mesh.get("refused").cloned().unwrap_or(Value::Null),
264 }),
265 );
266 }
267 let machine = |node: Option<&str>| match node.map(prefix).and_then(|p| machines.get(&p)) {
268 Some(label) => Value::String(label.clone()),
269 None => Value::String("another machine".into()),
270 };
271 if let Some(activity) = scenes.get("activity") {
272 let events: Vec<Value> = list(activity, "events")
273 .iter()
274 .enumerate()
275 .map(|(k, e)| {
276 serde_json::json!({
277 "id": format!("e{}", k + 1),
278 "kind": e.get("kind").cloned().unwrap_or(Value::Null),
279 "node": machine(e.get("node").and_then(Value::as_str)),
280 "lamport": e.get("lamport").cloned().unwrap_or(Value::Null),
281 "at": e.get("at").cloned().unwrap_or(Value::Null),
282 })
283 })
284 .collect();
285 out.insert(
286 "activity".to_string(),
287 serde_json::json!({
288 "events": events,
289 "lamport": activity.get("lamport").cloned().unwrap_or(Value::Null),
290 }),
291 );
292 }
293 if let Some(coop) = scenes.get("coop") {
294 let sessions: Vec<Value> = list(coop, "sessions")
295 .iter()
296 .enumerate()
297 .map(|(k, s)| {
298 serde_json::json!({
299 "id": format!("s{}", k + 1),
300 "node": machine(s.get("node").and_then(Value::as_str)),
301 "state": s.get("state").cloned().unwrap_or(Value::Null),
302 "claims": s.get("claims").cloned().unwrap_or(Value::Null),
303 "reviews": s.get("reviews").cloned().unwrap_or(Value::Null),
304 })
305 })
306 .collect();
307 let mut model = serde_json::json!({ "sessions": sessions });
308 for key in ["claims", "overlaps", "handovers", "reviews"] {
309 model[key] = coop.get(key).cloned().unwrap_or(Value::Null);
310 }
311 out.insert("coop".to_string(), model);
312 }
313 out
314}
315
316fn capture(ctx: &Context, _: Empty) -> Result<ObservatoryRecording, CapabilityError> {
317 let (projection, _) = crate::ui::observatory::project(ctx);
318 let live: BTreeMap<String, Value> = projection
320 .scenes
321 .iter()
322 .filter(|s| RECORDED.contains(&s.id) && s.available && s.replay.is_none())
323 .map(|s| (s.id.to_string(), s.model.clone()))
324 .collect();
325 let scenes = redact(&live);
326 Ok(ObservatoryRecording {
327 schema: RECORDING_SCHEMA.into(),
328 recorded_at: projection.observed_at,
329 revision: projection.revision,
330 manifest: manifest(&scenes),
331 scenes,
332 })
333}
334
335pub fn replay_at(root: &Path) -> Result<ObservatoryReplay, CapabilityError> {
347 let path = root.join(RECORDING);
348 let text = match std::fs::read_to_string(&path) {
349 Ok(text) => text,
350 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
351 return Ok(ObservatoryReplay {
352 present: false,
353 path: RECORDING.into(),
354 recording: None,
355 })
356 }
357 Err(e) => {
358 return Err(CapabilityError::Internal(format!(
359 "{RECORDING} could not be read: {e}"
360 )))
361 }
362 };
363 let recording: ObservatoryRecording = serde_json::from_str(&text)
364 .map_err(|e| CapabilityError::Internal(format!("{RECORDING} is not a recording: {e}")))?;
365 if recording.schema != RECORDING_SCHEMA {
366 return Err(CapabilityError::Internal(format!(
367 "{RECORDING} declares schema {}, not {RECORDING_SCHEMA}",
368 recording.schema
369 )));
370 }
371 if let Some(other) = recording
372 .scenes
373 .keys()
374 .find(|k| !RECORDED.contains(&k.as_str()))
375 {
376 return Err(CapabilityError::Internal(format!(
377 "{RECORDING} carries the scene {other}, which is not one a recording may carry"
378 )));
379 }
380 let events = recording
383 .scenes
384 .get("activity")
385 .and_then(|a| a.get("events"))
386 .and_then(Value::as_array)
387 .cloned()
388 .unwrap_or_default();
389 for (i, e) in events.iter().enumerate() {
390 let whole = e.get("kind").is_some_and(Value::is_string)
391 && e.get("lamport").is_some_and(Value::is_u64)
392 && e.get("at").is_some_and(Value::is_u64);
393 if !whole {
394 let id = e
395 .get("id")
396 .and_then(Value::as_str)
397 .map(str::to_string)
398 .unwrap_or_else(|| format!("#{}", i + 1));
399 return Err(CapabilityError::Internal(format!(
400 "{RECORDING} holds the event {id} without its kind, logical clock and time: it was not recorded from a source record"
401 )));
402 }
403 }
404 Ok(ObservatoryReplay {
405 present: true,
406 path: RECORDING.into(),
407 recording: Some(recording),
408 })
409}
410
411#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
420#[serde(deny_unknown_fields)]
421pub struct ObservatoryEventsInput {
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub after: Option<u64>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub limit: Option<u64>,
428}
429
430impl BenchmarkCases for ObservatoryEventsInput {
431 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
432 vec![
433 NamedCase::new("newest", ObservatoryEventsInput::default()),
434 NamedCase::new(
435 "after a cursor",
436 ObservatoryEventsInput {
437 after: Some(0),
438 limit: Some(50),
439 },
440 ),
441 ]
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
455pub struct ObservatoryEvents {
456 pub active: bool,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub after: Option<u64>,
461 pub cursor: u64,
464 pub expired: bool,
467 pub events: Vec<Value>,
470}
471
472fn events(
473 ctx: &Context,
474 input: ObservatoryEventsInput,
475) -> Result<ObservatoryEvents, CapabilityError> {
476 let limit = input.limit.unwrap_or(200).clamp(1, 1000);
477 let newest = ctx.execute(
478 "mesh.events",
479 serde_json::json!({ "newest": true, "limit": limit }),
480 )?;
481 let active = newest.get("active").and_then(Value::as_bool) == Some(true);
482 let head = newest.get("lamport").and_then(Value::as_u64).unwrap_or(0);
483 let list = |v: &Value| {
484 v.get("events")
485 .and_then(Value::as_array)
486 .cloned()
487 .unwrap_or_default()
488 };
489 let Some(after) = input.after else {
490 return Ok(ObservatoryEvents {
491 active,
492 after: None,
493 cursor: head,
494 expired: false,
495 events: crate::ui::observatory::event_marks(&list(&newest)),
496 });
497 };
498 if !active || after > head {
499 return Ok(ObservatoryEvents {
500 active,
501 after: Some(after),
502 cursor: head,
503 expired: active,
504 events: Vec::new(),
505 });
506 }
507 let page = ctx.execute(
508 "mesh.events",
509 serde_json::json!({ "after": after, "limit": limit }),
510 )?;
511 Ok(ObservatoryEvents {
512 active,
513 after: Some(after),
514 cursor: page.get("lamport").and_then(Value::as_u64).unwrap_or(after),
515 expired: false,
516 events: crate::ui::observatory::event_marks(&list(&page)),
517 })
518}
519
520fn replay(ctx: &Context, _: Empty) -> Result<ObservatoryReplay, CapabilityError> {
521 replay_at(Path::new(&ctx.index.repository.root))
522}
523
524pub fn module() -> ModuleDescriptor {
537 module! {
538 id: "observatory",
539 title: "Observatory",
540 description: "What the Observatory may carry to a place where no mesh runs: the mesh scenes this process has live, pseudonymized for publication, and the recording the repository carries, which the published Observatory replays and labels as a replay.",
541 stability: Stability::BehaviorallyVerified,
542 capabilities: [
543 capability! {
544 id: "observatory.capture",
545 title: "Observatory capture",
546 description: "The mesh scenes of the Observatory as this process has them live, fit for publication: machines, runtimes, sessions and events by ordinal, an event by its kind, logical clock and time only. Writes nothing; committing the answer to .ai/repo/observatory/recording.json is what publishes it.",
547 input: Empty,
548 output: ObservatoryRecording,
549 stability: Stability::BehaviorallyVerified,
550 exposure: Exposure { mcp: mcp("majordomus_observatory_capture"), http: get("/api/v1/observatory/capture"), cli: None },
551 tags: ["observatory", "mesh", "ui"],
552 handler: capture,
553 },
554 capability! {
555 id: "observatory.replay",
556 title: "Observatory replay",
557 description: "The recording of the mesh scenes the repository carries at .ai/repo/observatory/recording.json, which the Observatory draws, labelled as a replay, where no mesh runs. Absent is present: false; a file that cannot be read is an error.",
558 input: Empty,
559 output: ObservatoryReplay,
560 stability: Stability::BehaviorallyVerified,
561 exposure: Exposure { mcp: mcp("majordomus_observatory_replay"), http: get("/api/v1/observatory/replay"), cli: None },
562 tags: ["observatory", "mesh", "ui"],
563 handler: replay,
564 },
565 capability! {
566 id: "observatory.events",
567 title: "Observatory events",
568 description: "The events of the mesh journal above a cursor, as the Observatory's activity scene draws them: identity, kind, node, logical clock and time, newest first, at most a page. The answer's cursor is the next request's `after`; a cursor this journal never reached is expired, and the reader takes a fresh projection. Without a cursor, the newest page.",
569 input: ObservatoryEventsInput,
570 output: ObservatoryEvents,
571 stability: Stability::BehaviorallyVerified,
572 exposure: Exposure { mcp: mcp("majordomus_observatory_events"), http: get("/api/v1/observatory/events"), cli: None },
573 tags: ["observatory", "mesh", "ui"],
574 handler: events,
575 },
576 ],
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use serde_json::json;
584
585 fn scenes() -> BTreeMap<String, Value> {
586 [
587 (
588 "mesh".to_string(),
589 json!({ "machines": [
590 { "id": "aaaaaaaaaaaa1111", "label": "aaaaaaaa", "local": true,
591 "runtimes": [{ "id": "rt-secret-1", "liveness": "live", "this": true, "sessions": 1 }] },
592 { "id": "bbbbbbbbbbbb2222", "label": "bbbbbbbb", "local": false, "runtimes": [] },
593 ], "known": 2, "refused": 1 }),
594 ),
595 (
596 "activity".to_string(),
597 json!({ "events": [
598 { "id": "aaaaaaaaaaaa1111-x:7", "kind": "claim_acquired", "node": "bbbbbbbbbbbb", "lamport": 9, "at": 100 },
599 { "id": "cccc:1", "kind": "session_opened", "node": "cccccccccccc", "lamport": 10, "at": 101 },
600 ], "lamport": 10 }),
601 ),
602 (
603 "coop".to_string(),
604 json!({ "sessions": [{ "id": "sess-secret", "node": "aaaaaaaaaaaa", "state": "active", "claims": 3, "reviews": 0 }],
605 "claims": 3, "overlaps": 0, "handovers": 1, "reviews": 0 }),
606 ),
607 ("heartbeat".to_string(), json!({ "checks": [] })),
608 ]
609 .into_iter()
610 .collect()
611 }
612
613 #[test]
617 fn a_recording_names_nothing_by_its_identity() {
618 let out = redact(&scenes());
619 let text = serde_json::to_string(&out).unwrap();
620 for secret in ["aaaaaaaa", "bbbbbbbb", "cccc", "rt-secret", "sess-secret"] {
621 assert!(!text.contains(secret), "{secret} survived: {text}");
622 }
623 assert_eq!(out.keys().collect::<Vec<_>>(), ["activity", "coop", "mesh"]);
624 assert_eq!(out["mesh"]["machines"][1]["label"], "machine 2");
625 assert_eq!(out["mesh"]["machines"][0]["runtimes"][0]["id"], "m1-r1");
626 assert_eq!(out["activity"]["events"][0]["node"], "machine 2");
627 assert_eq!(out["activity"]["events"][1]["node"], "another machine");
628 assert_eq!(out["activity"]["events"][0]["kind"], "claim_acquired");
629 assert_eq!(out["activity"]["events"][1]["lamport"], 10);
630 assert_eq!(out["coop"]["sessions"][0]["node"], "machine 1");
631 assert_eq!(out["coop"]["handovers"], 1);
632 }
633
634 #[test]
637 fn the_replay_is_the_file_or_says_why_not() {
638 let dir = tempfile::tempdir().unwrap();
639 let absent = replay_at(dir.path()).unwrap();
640 assert!(!absent.present && absent.recording.is_none());
641 let path = dir.path().join(RECORDING);
642 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
643 std::fs::write(&path, "not json").unwrap();
644 assert!(replay_at(dir.path()).is_err());
645 let redacted = redact(&scenes());
646 let mut recording = ObservatoryRecording {
647 schema: RECORDING_SCHEMA.into(),
648 recorded_at: "2026-10-14T05:00:00Z".into(),
649 revision: Some("abc".into()),
650 manifest: manifest(&redacted),
651 scenes: redacted,
652 };
653 assert_eq!(
655 recording.manifest.sources,
656 ["mesh.peers", "mesh.events", "mesh.state"]
657 );
658 let window = recording.manifest.window.clone().expect("a window");
659 assert_eq!(
660 (window.events, window.from_lamport, window.to_lamport),
661 (2, Some(9), Some(10))
662 );
663 assert_eq!((window.from_at, window.to_at), (Some(100), Some(101)));
664 assert_eq!(recording.manifest.removed.len(), REMOVED.len());
665 std::fs::write(&path, serde_json::to_string(&recording).unwrap()).unwrap();
666 let read = replay_at(dir.path()).unwrap();
667 assert!(read.present);
668 assert_eq!(read.recording.as_ref(), Some(&recording));
669 let mut partial = recording.clone();
671 partial.scenes.get_mut("activity").unwrap()["events"][1]
672 .as_object_mut()
673 .unwrap()
674 .remove("lamport");
675 std::fs::write(&path, serde_json::to_string(&partial).unwrap()).unwrap();
676 match replay_at(dir.path()) {
677 Err(CapabilityError::Internal(m)) => assert!(m.contains("the event e2 "), "{m}"),
678 other => panic!("expected a refusal naming e2, got {other:?}"),
679 }
680 recording.scenes.insert("heartbeat".into(), json!({}));
681 std::fs::write(&path, serde_json::to_string(&recording).unwrap()).unwrap();
682 assert!(
683 replay_at(dir.path()).is_err(),
684 "a recording carries mesh scenes only"
685 );
686 }
687}