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