1use std::collections::VecDeque;
8use std::fs::{File, OpenOptions};
9use std::io::{BufRead, BufReader, BufWriter, Write};
10use std::path::{Path, PathBuf};
11use std::sync::mpsc::{self, Receiver, Sender};
12
13use serde::{Deserialize, Serialize};
14
15pub mod agents;
16pub mod collaboration;
17pub mod contract;
18pub mod details;
19pub mod history;
20pub mod launcher;
21pub mod persistence;
22pub mod policy;
23pub mod relay;
24pub mod resources;
25pub mod settings;
26pub mod trace;
27
28pub type RosterSlot = usize;
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32pub struct Mode {
33 pub id: String,
34 pub label: String,
35}
36
37#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
38pub struct AgentCapabilities {
39 pub supports_cancel: bool,
40 pub supports_modes: bool,
41 pub supports_permissions: bool,
42 pub supports_terminals: bool,
43 pub supports_session_load: bool,
44}
45
46#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
47pub struct ToolUpdate {
48 pub id: String,
49 pub title: String,
50 pub status: ToolStatus,
51 pub detail: Option<String>,
52}
53
54#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59pub struct AgentCommand {
60 pub name: String,
61}
62
63#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
65pub struct UsageUpdate {
66 pub used: u64,
67 pub size: u64,
68}
69
70#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
71pub enum ToolStatus {
72 Pending,
73 Running,
74 Completed,
75 Failed,
76}
77
78#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
79pub struct PermissionRequest {
80 pub id: String,
81 pub title: String,
82 pub options: Vec<String>,
83 #[serde(default)]
86 pub option_ids: Vec<String>,
87}
88
89#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92pub enum PermissionAnswer {
93 Selected { option_id: String },
94 Cancelled,
95}
96
97#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
98pub enum TerminalEvent {
99 Created { id: String, command: String },
100 Output { id: String, text: String },
101 Exited { id: String, code: i32 },
102 Released { id: String },
103}
104
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106pub enum RosterUpdate {
107 Added {
108 slot: RosterSlot,
109 name: String,
110 identity: String,
111 },
112 Reloaded {
113 slot: RosterSlot,
114 },
115 Dropped {
116 slot: RosterSlot,
117 },
118 Promoted {
119 from: RosterSlot,
120 },
121 Swapped {
122 first: RosterSlot,
123 second: RosterSlot,
124 },
125 Rejected {
126 action: String,
127 detail: String,
128 },
129}
130
131#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
132pub enum AgentEvent {
133 RosterUpdated {
134 update: RosterUpdate,
135 },
136 Ready {
137 slot: RosterSlot,
138 capabilities: AgentCapabilities,
139 },
140 ModesReplaced {
141 slot: RosterSlot,
142 modes: Vec<Mode>,
143 current_mode: Option<String>,
144 },
145 ModeUpdated {
146 slot: RosterSlot,
147 current_mode: String,
148 },
149 UserText {
150 slot: RosterSlot,
151 text: String,
152 },
153 CommandsReplaced {
154 slot: RosterSlot,
155 commands: Vec<AgentCommand>,
156 },
157 UsageUpdated {
158 slot: RosterSlot,
159 usage: UsageUpdate,
160 },
161 Text {
162 slot: RosterSlot,
163 text: String,
164 },
165 Thought {
166 slot: RosterSlot,
167 text: String,
168 },
169 Tool {
170 slot: RosterSlot,
171 update: ToolUpdate,
172 },
173 Permission {
174 slot: RosterSlot,
175 request: PermissionRequest,
176 },
177 Terminal {
178 slot: RosterSlot,
179 event: TerminalEvent,
180 },
181 TurnComplete {
182 slot: RosterSlot,
183 },
184 Failed {
185 slot: RosterSlot,
186 started: bool,
187 detail: String,
188 },
189}
190
191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192pub enum Effect {
193 Render,
194 DispatchPrompt { slot: RosterSlot, prompt: String },
195 OfferReload { slot: RosterSlot, crashed: bool },
196}
197
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
199pub struct AgentSlot {
200 pub active: bool,
201 pub capabilities: AgentCapabilities,
202 pub modes: Vec<Mode>,
203 pub current_mode: Option<String>,
204 #[serde(default)]
205 pub commands: Vec<AgentCommand>,
206 #[serde(default)]
207 pub usage: Option<UsageUpdate>,
208}
209
210impl Default for AgentSlot {
211 fn default() -> Self {
212 Self {
213 active: true,
214 capabilities: AgentCapabilities::default(),
215 modes: Vec::new(),
216 current_mode: None,
217 commands: Vec::new(),
218 usage: None,
219 }
220 }
221}
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224pub struct SessionState {
225 pub slots: Vec<AgentSlot>,
226 pub active_slot: Option<RosterSlot>,
227 pub queued_prompts: VecDeque<(RosterSlot, String)>,
228 pub public_text: Vec<(RosterSlot, String)>,
229}
230
231impl SessionState {
232 pub fn new(roster_size: usize) -> Self {
233 Self {
234 slots: (0..roster_size).map(|_| AgentSlot::default()).collect(),
235 active_slot: None,
236 queued_prompts: VecDeque::new(),
237 public_text: Vec::new(),
238 }
239 }
240}
241
242pub fn reduce(state: &mut SessionState, event: AgentEvent) -> Vec<Effect> {
245 match event {
246 AgentEvent::RosterUpdated { .. } => vec![Effect::Render],
247 AgentEvent::Ready { slot, capabilities } => {
248 if let Some(agent) = state.slots.get_mut(slot) {
249 agent.capabilities = capabilities;
250 }
251 vec![Effect::Render]
252 }
253 AgentEvent::ModesReplaced {
254 slot,
255 modes,
256 current_mode,
257 } => {
258 if let Some(agent) = state.slots.get_mut(slot) {
259 agent.modes = modes;
260 agent.current_mode =
261 current_mode.filter(|id| agent.modes.iter().any(|mode| mode.id == *id));
262 }
263 vec![Effect::Render]
264 }
265 AgentEvent::CommandsReplaced { slot, commands } => {
266 if let Some(agent) = state.slots.get_mut(slot) {
267 agent.commands = commands;
268 }
269 vec![Effect::Render]
270 }
271 AgentEvent::ModeUpdated { slot, current_mode } => {
272 if let Some(agent) = state.slots.get_mut(slot) {
273 agent.current_mode = Some(current_mode);
274 }
275 vec![Effect::Render]
276 }
277 AgentEvent::UsageUpdated { slot, usage } => {
278 if let Some(agent) = state.slots.get_mut(slot) {
279 agent.usage = Some(usage);
280 }
281 vec![Effect::Render]
282 }
283 AgentEvent::Text { slot, text } => {
284 state.active_slot = Some(slot);
285 state.public_text.push((slot, text));
286 vec![Effect::Render]
287 }
288 AgentEvent::UserText { slot, .. } => {
289 state.active_slot = Some(slot);
290 vec![Effect::Render]
291 }
292 AgentEvent::Thought { slot, .. }
293 | AgentEvent::Tool { slot, .. }
294 | AgentEvent::Permission { slot, .. }
295 | AgentEvent::Terminal { slot, .. } => {
296 state.active_slot = Some(slot);
297 vec![Effect::Render]
298 }
299 AgentEvent::TurnComplete { .. } => {
300 state.active_slot = None;
301 let next =
302 state
303 .queued_prompts
304 .pop_front()
305 .map(|(target, prompt)| Effect::DispatchPrompt {
306 slot: target,
307 prompt,
308 });
309 let mut effects = vec![Effect::Render];
310 if let Some(effect) = next {
311 effects.push(effect);
312 }
313 effects
314 }
315 AgentEvent::Failed {
316 slot,
317 started,
318 detail: _,
319 } => {
320 if let Some(agent) = state.slots.get_mut(slot) {
321 agent.active = false;
322 }
323 if state.active_slot == Some(slot) {
324 state.active_slot = None;
325 }
326 vec![
327 Effect::Render,
328 Effect::OfferReload {
329 slot,
330 crashed: started,
331 },
332 ]
333 }
334 }
335}
336
337#[derive(Clone, Debug)]
342pub struct EventLog {
343 path: PathBuf,
344}
345
346impl EventLog {
347 pub fn open(path: impl Into<PathBuf>) -> Self {
348 Self { path: path.into() }
349 }
350
351 pub fn path(&self) -> &Path {
352 &self.path
353 }
354
355 pub fn append(&self, event: &AgentEvent) -> std::io::Result<()> {
356 let encoded = serde_json::to_string(event)
357 .map_err(|error| std::io::Error::other(error.to_string()))?;
358 let mut file = OpenOptions::new()
359 .create(true)
360 .append(true)
361 .open(&self.path)?;
362 file.write_all(encoded.as_bytes())?;
363 file.write_all(b"\n")
364 }
365
366 pub fn append_durable(&self, event: &AgentEvent) -> std::io::Result<()> {
372 let encoded = serde_json::to_string(event)
373 .map_err(|error| std::io::Error::other(error.to_string()))?;
374 let mut file = OpenOptions::new()
375 .create(true)
376 .append(true)
377 .open(&self.path)?;
378 file.write_all(encoded.as_bytes())?;
379 file.write_all(b"\n")?;
380 file.sync_data()
381 }
382
383 pub fn sync(&self) -> std::io::Result<()> {
387 let file = OpenOptions::new().read(true).open(&self.path)?;
388 file.sync_data()
389 }
390
391 pub fn buffered(&self) -> std::io::Result<BufferedEventLog> {
396 BufferedEventLog::open(self.path.clone())
397 }
398
399 pub fn read(&self) -> std::io::Result<Vec<AgentEvent>> {
400 let file = match File::open(&self.path) {
401 Ok(file) => file,
402 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
403 Err(error) => return Err(error),
404 };
405 BufReader::new(file)
406 .lines()
407 .enumerate()
408 .filter_map(|(line_number, result)| match result {
409 Ok(line) if line.trim().is_empty() => None,
410 Ok(line) => Some(serde_json::from_str(&line).map_err(|error| {
411 std::io::Error::new(
412 std::io::ErrorKind::InvalidData,
413 format!("event log line {}: {error}", line_number + 1),
414 )
415 })),
416 Err(error) => Some(Err(error)),
417 })
418 .collect()
419 }
420
421 pub fn replay(&self, roster_size: usize) -> std::io::Result<SessionState> {
422 let mut state = SessionState::new(roster_size);
423 for event in self.read()? {
424 reduce(&mut state, event);
425 }
426 Ok(state)
427 }
428}
429
430enum BufferedLogCommand {
431 Append(String),
432 Flush(Sender<std::io::Result<()>>),
433 Shutdown(Sender<std::io::Result<()>>),
434}
435
436pub struct BufferedEventLog {
442 sender: Sender<BufferedLogCommand>,
443 worker: Option<std::thread::JoinHandle<std::io::Result<()>>>,
444}
445
446impl std::fmt::Debug for BufferedEventLog {
447 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 formatter
449 .debug_struct("BufferedEventLog")
450 .field("worker_running", &self.worker.is_some())
451 .finish_non_exhaustive()
452 }
453}
454
455impl BufferedEventLog {
456 fn open(path: PathBuf) -> std::io::Result<Self> {
457 let (sender, receiver) = mpsc::channel();
458 let worker = std::thread::Builder::new()
459 .name("codeswarm-event-log".into())
460 .spawn(move || buffered_log_worker(path, receiver))?;
461 Ok(Self {
462 sender,
463 worker: Some(worker),
464 })
465 }
466
467 pub fn append(&self, event: &AgentEvent) -> std::io::Result<()> {
470 let encoded = serde_json::to_string(event)
471 .map_err(|error| std::io::Error::other(error.to_string()))?;
472 self.sender
473 .send(BufferedLogCommand::Append(format!("{encoded}\n")))
474 .map_err(|_| {
475 std::io::Error::new(
476 std::io::ErrorKind::BrokenPipe,
477 "event log background writer stopped",
478 )
479 })
480 }
481
482 pub fn flush(&self) -> std::io::Result<()> {
484 let (reply, result) = mpsc::channel();
485 self.sender
486 .send(BufferedLogCommand::Flush(reply))
487 .map_err(|_| {
488 std::io::Error::new(
489 std::io::ErrorKind::BrokenPipe,
490 "event log background writer stopped",
491 )
492 })?;
493 result.recv().map_err(|_| {
494 std::io::Error::new(
495 std::io::ErrorKind::BrokenPipe,
496 "event log background writer stopped",
497 )
498 })?
499 }
500}
501
502impl Drop for BufferedEventLog {
503 fn drop(&mut self) {
504 let (reply, result) = mpsc::channel();
505 if self
506 .sender
507 .send(BufferedLogCommand::Shutdown(reply))
508 .is_ok()
509 {
510 let _ = result.recv();
511 }
512 if let Some(worker) = self.worker.take() {
513 let _ = worker.join();
514 }
515 }
516}
517
518fn buffered_log_worker(
519 path: PathBuf,
520 receiver: Receiver<BufferedLogCommand>,
521) -> std::io::Result<()> {
522 let file = OpenOptions::new().create(true).append(true).open(path)?;
523 let mut writer = BufWriter::new(file);
524 while let Ok(command) = receiver.recv() {
525 match command {
526 BufferedLogCommand::Append(line) => writer.write_all(line.as_bytes())?,
527 BufferedLogCommand::Flush(reply) => {
528 let result = writer.flush().and_then(|()| writer.get_ref().sync_data());
529 let _ = reply.send(result);
530 }
531 BufferedLogCommand::Shutdown(reply) => {
532 let result = writer.flush().and_then(|()| writer.get_ref().sync_data());
533 let worker_result = match result {
534 Ok(()) => {
535 let _ = reply.send(Ok(()));
536 Ok(())
537 }
538 Err(error) => {
539 let kind = error.kind();
540 let detail = error.to_string();
541 let _ = reply.send(Err(std::io::Error::new(kind, detail.clone())));
542 Err(std::io::Error::new(kind, detail))
543 }
544 };
545 return worker_result;
546 }
547 }
548 }
549 writer.flush()
550}
551
552#[cfg(test)]
553mod tests {
554 use std::time::{SystemTime, UNIX_EPOCH};
555
556 use super::{AgentCapabilities, AgentEvent, Effect, Mode, SessionState, reduce};
557
558 #[test]
559 fn replacement_catalog_invalidates_stale_mode() {
560 let mut state = SessionState::new(1);
561 reduce(
562 &mut state,
563 AgentEvent::ModesReplaced {
564 slot: 0,
565 modes: vec![Mode {
566 id: "read".into(),
567 label: "Read only".into(),
568 }],
569 current_mode: Some("write".into()),
570 },
571 );
572 assert_eq!(state.slots[0].current_mode, None);
573 }
574
575 #[test]
576 fn crash_tombstones_slot_and_uses_crash_copy() {
577 let mut state = SessionState::new(2);
578 reduce(
579 &mut state,
580 AgentEvent::Ready {
581 slot: 1,
582 capabilities: AgentCapabilities::default(),
583 },
584 );
585 let effects = reduce(
586 &mut state,
587 AgentEvent::Failed {
588 slot: 1,
589 started: true,
590 detail: "process exited".into(),
591 },
592 );
593 assert!(!state.slots[1].active);
594 assert!(effects.contains(&Effect::OfferReload {
595 slot: 1,
596 crashed: true,
597 }));
598 }
599
600 #[test]
601 fn event_log_replays_into_the_same_state() {
602 let unique = SystemTime::now()
603 .duration_since(UNIX_EPOCH)
604 .expect("clock")
605 .as_nanos();
606 let path = std::env::temp_dir().join(format!("codeswarm-core-{unique}.jsonl"));
607 let log = super::EventLog::open(&path);
608 let events = [
609 AgentEvent::Text {
610 slot: 0,
611 text: "first".into(),
612 },
613 AgentEvent::Failed {
614 slot: 1,
615 started: true,
616 detail: "crashed".into(),
617 },
618 ];
619 for event in &events {
620 log.append(event).expect("append");
621 }
622 let replayed = log.replay(2).expect("replay");
623 let mut expected = SessionState::new(2);
624 for event in events {
625 reduce(&mut expected, event);
626 }
627 assert_eq!(replayed, expected);
628 std::fs::remove_file(path).expect("cleanup");
629 }
630
631 #[test]
632 fn event_log_can_checkpoint_batched_appends() {
633 let unique = SystemTime::now()
634 .duration_since(UNIX_EPOCH)
635 .expect("clock")
636 .as_nanos();
637 let path = std::env::temp_dir().join(format!("codeswarm-core-checkpoint-{unique}.jsonl"));
638 let log = super::EventLog::open(&path);
639 log.append(&AgentEvent::Text {
640 slot: 0,
641 text: "batched".into(),
642 })
643 .expect("append");
644 log.sync().expect("checkpoint");
647 assert_eq!(log.read().expect("read").len(), 1);
648 std::fs::remove_file(path).expect("cleanup");
649 }
650
651 #[test]
652 fn event_log_durable_append_is_replayable() {
653 let unique = SystemTime::now()
654 .duration_since(UNIX_EPOCH)
655 .expect("clock")
656 .as_nanos();
657 let path = std::env::temp_dir().join(format!("codeswarm-core-durable-{unique}.jsonl"));
658 let log = super::EventLog::open(&path);
659 log.append_durable(&AgentEvent::Text {
660 slot: 1,
661 text: "durable".into(),
662 })
663 .expect("durable append");
664 assert_eq!(
665 log.read().expect("read")[0].clone(),
666 AgentEvent::Text {
667 slot: 1,
668 text: "durable".into(),
669 }
670 );
671 std::fs::remove_file(path).expect("cleanup");
672 }
673
674 #[test]
675 fn buffered_event_log_drains_and_checkpoints_in_order() {
676 let unique = SystemTime::now()
677 .duration_since(UNIX_EPOCH)
678 .expect("clock")
679 .as_nanos();
680 let path = std::env::temp_dir().join(format!("codeswarm-core-buffered-{unique}.jsonl"));
681 let log = super::EventLog::open(&path);
682 let buffered = log.buffered().expect("background writer");
683 for text in ["one", "two", "three"] {
684 buffered
685 .append(&AgentEvent::Text {
686 slot: 0,
687 text: text.into(),
688 })
689 .expect("queue event");
690 }
691 buffered.flush().expect("checkpoint");
692 assert_eq!(
693 log.read().expect("read").into_iter().collect::<Vec<_>>(),
694 [
695 AgentEvent::Text {
696 slot: 0,
697 text: "one".into()
698 },
699 AgentEvent::Text {
700 slot: 0,
701 text: "two".into()
702 },
703 AgentEvent::Text {
704 slot: 0,
705 text: "three".into()
706 }
707 ]
708 );
709 drop(buffered);
710 std::fs::remove_file(path).expect("cleanup");
711 }
712
713 #[test]
714 fn activity_marks_turn_active_until_completion() {
715 let mut state = SessionState::new(1);
716 reduce(
717 &mut state,
718 AgentEvent::Text {
719 slot: 0,
720 text: "stream".into(),
721 },
722 );
723 assert_eq!(state.active_slot, Some(0));
724 reduce(&mut state, AgentEvent::TurnComplete { slot: 0 });
725 assert_eq!(state.active_slot, None);
726 }
727}