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)]
135pub enum HistoryContent {
136 UserText(String),
137 Text(String),
138 Thought(String),
139 Tool(ToolUpdate),
140}
141
142#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
143pub enum AgentEvent {
144 History {
145 slot: RosterSlot,
146 content: HistoryContent,
147 },
148 GoalUpdated {
149 goal: Option<goal::Goal>,
150 },
151 RosterUpdated {
152 update: RosterUpdate,
153 },
154 Ready {
155 slot: RosterSlot,
156 capabilities: AgentCapabilities,
157 },
158 TurnStarted {
159 slot: RosterSlot,
160 },
161 ModesReplaced {
162 slot: RosterSlot,
163 modes: Vec<Mode>,
164 current_mode: Option<String>,
165 },
166 ModeUpdated {
167 slot: RosterSlot,
168 current_mode: String,
169 },
170 ModelsReplaced {
171 slot: RosterSlot,
172 config_id: String,
173 models: Vec<Mode>,
174 current_model: Option<String>,
175 },
176 ModelUpdated {
177 slot: RosterSlot,
178 current_model: String,
179 },
180 UserText {
181 slot: RosterSlot,
182 text: String,
183 },
184 CommandsReplaced {
185 slot: RosterSlot,
186 commands: Vec<AgentCommand>,
187 },
188 UsageUpdated {
189 slot: RosterSlot,
190 usage: UsageUpdate,
191 },
192 Text {
193 slot: RosterSlot,
194 text: String,
195 },
196 Thought {
197 slot: RosterSlot,
198 text: String,
199 },
200 Tool {
201 slot: RosterSlot,
202 update: ToolUpdate,
203 },
204 Permission {
205 slot: RosterSlot,
206 request: PermissionRequest,
207 },
208 Terminal {
209 slot: RosterSlot,
210 event: TerminalEvent,
211 },
212 TurnComplete {
213 slot: RosterSlot,
214 },
215 UsageLimitReached {
218 slot: RosterSlot,
219 detail: String,
220 },
221 Failed {
222 slot: RosterSlot,
223 started: bool,
224 detail: String,
225 },
226}
227
228#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
229pub enum Effect {
230 Render,
231 DispatchPrompt { slot: RosterSlot, prompt: String },
232 OfferReload { slot: RosterSlot, crashed: bool },
233}
234
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236pub struct AgentSlot {
237 pub active: bool,
238 pub capabilities: AgentCapabilities,
239 pub modes: Vec<Mode>,
240 pub current_mode: Option<String>,
241 #[serde(default)]
242 pub models: Vec<Mode>,
243 #[serde(default)]
244 pub current_model: Option<String>,
245 #[serde(default)]
246 pub commands: Vec<AgentCommand>,
247 #[serde(default)]
248 pub usage: Option<UsageUpdate>,
249}
250
251impl Default for AgentSlot {
252 fn default() -> Self {
253 Self {
254 active: true,
255 capabilities: AgentCapabilities::default(),
256 modes: Vec::new(),
257 current_mode: None,
258 models: Vec::new(),
259 current_model: None,
260 commands: Vec::new(),
261 usage: None,
262 }
263 }
264}
265
266#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
267pub struct SessionState {
268 #[serde(default)]
269 pub goal: Option<goal::Goal>,
270 pub slots: Vec<AgentSlot>,
271 pub active_slot: Option<RosterSlot>,
272 pub queued_prompts: VecDeque<(RosterSlot, String)>,
273 pub public_text: Vec<(RosterSlot, String)>,
274}
275
276impl SessionState {
277 pub fn new(roster_size: usize) -> Self {
278 Self {
279 slots: (0..roster_size).map(|_| AgentSlot::default()).collect(),
280 active_slot: None,
281 queued_prompts: VecDeque::new(),
282 public_text: Vec::new(),
283 goal: None,
284 }
285 }
286}
287
288pub fn reduce(state: &mut SessionState, event: AgentEvent) -> Vec<Effect> {
291 match event {
292 AgentEvent::History { .. } => vec![Effect::Render],
293 AgentEvent::GoalUpdated { goal } => {
294 state.goal = goal;
295 vec![Effect::Render]
296 }
297 AgentEvent::RosterUpdated { .. } => vec![Effect::Render],
298 AgentEvent::Ready { slot, capabilities } => {
299 if let Some(agent) = state.slots.get_mut(slot) {
300 agent.capabilities = capabilities;
301 }
302 vec![Effect::Render]
303 }
304 AgentEvent::TurnStarted { slot } => {
305 state.active_slot = Some(slot);
306 vec![Effect::Render]
307 }
308 AgentEvent::ModesReplaced {
309 slot,
310 modes,
311 current_mode,
312 } => {
313 if let Some(agent) = state.slots.get_mut(slot) {
314 agent.modes = modes;
315 agent.current_mode =
316 current_mode.filter(|id| agent.modes.iter().any(|mode| mode.id == *id));
317 }
318 vec![Effect::Render]
319 }
320 AgentEvent::CommandsReplaced { slot, commands } => {
321 if let Some(agent) = state.slots.get_mut(slot) {
322 agent.commands = commands;
323 }
324 vec![Effect::Render]
325 }
326 AgentEvent::ModeUpdated { slot, current_mode } => {
327 if let Some(agent) = state.slots.get_mut(slot) {
328 agent.current_mode = Some(current_mode);
329 }
330 vec![Effect::Render]
331 }
332 AgentEvent::ModelsReplaced {
333 slot,
334 models,
335 current_model,
336 ..
337 } => {
338 if let Some(agent) = state.slots.get_mut(slot) {
339 agent.models = models;
340 agent.current_model =
341 current_model.filter(|id| agent.models.iter().any(|model| model.id == *id));
342 }
343 vec![Effect::Render]
344 }
345 AgentEvent::ModelUpdated {
346 slot,
347 current_model,
348 } => {
349 if let Some(agent) = state.slots.get_mut(slot) {
350 agent.current_model = Some(current_model);
351 }
352 vec![Effect::Render]
353 }
354 AgentEvent::UsageUpdated { slot, usage } => {
355 if let Some(agent) = state.slots.get_mut(slot) {
356 agent.usage = Some(usage);
357 }
358 vec![Effect::Render]
359 }
360 AgentEvent::Text { slot, text } => {
361 state.active_slot = Some(slot);
362 state.public_text.push((slot, text));
363 vec![Effect::Render]
364 }
365 AgentEvent::UserText { slot, .. } => {
366 state.active_slot = Some(slot);
367 vec![Effect::Render]
368 }
369 AgentEvent::Thought { slot, .. }
370 | AgentEvent::Tool { slot, .. }
371 | AgentEvent::Permission { slot, .. }
372 | AgentEvent::Terminal { slot, .. } => {
373 state.active_slot = Some(slot);
374 vec![Effect::Render]
375 }
376 AgentEvent::TurnComplete { .. } => {
377 state.active_slot = None;
378 let next =
379 state
380 .queued_prompts
381 .pop_front()
382 .map(|(target, prompt)| Effect::DispatchPrompt {
383 slot: target,
384 prompt,
385 });
386 let mut effects = vec![Effect::Render];
387 if let Some(effect) = next {
388 effects.push(effect);
389 }
390 effects
391 }
392 AgentEvent::UsageLimitReached { slot, .. } => {
393 if state.active_slot == Some(slot) {
394 state.active_slot = None;
395 }
396 vec![Effect::Render]
397 }
398 AgentEvent::Failed {
399 slot,
400 started,
401 detail: _,
402 } => {
403 if let Some(agent) = state.slots.get_mut(slot) {
404 agent.active = false;
405 }
406 if state.active_slot == Some(slot) {
407 state.active_slot = None;
408 }
409 vec![
410 Effect::Render,
411 Effect::OfferReload {
412 slot,
413 crashed: started,
414 },
415 ]
416 }
417 }
418}
419
420#[derive(Clone, Debug)]
425pub struct EventLog {
426 path: PathBuf,
427}
428
429impl EventLog {
430 pub fn open(path: impl Into<PathBuf>) -> Self {
431 Self { path: path.into() }
432 }
433
434 pub fn path(&self) -> &Path {
435 &self.path
436 }
437
438 pub fn append(&self, event: &AgentEvent) -> std::io::Result<()> {
439 let encoded = serde_json::to_string(event)
440 .map_err(|error| std::io::Error::other(error.to_string()))?;
441 let mut file = OpenOptions::new()
442 .create(true)
443 .append(true)
444 .open(&self.path)?;
445 file.write_all(encoded.as_bytes())?;
446 file.write_all(b"\n")
447 }
448
449 pub fn append_durable(&self, event: &AgentEvent) -> std::io::Result<()> {
455 let encoded = serde_json::to_string(event)
456 .map_err(|error| std::io::Error::other(error.to_string()))?;
457 let mut file = OpenOptions::new()
458 .create(true)
459 .append(true)
460 .open(&self.path)?;
461 file.write_all(encoded.as_bytes())?;
462 file.write_all(b"\n")?;
463 file.sync_data()
464 }
465
466 pub fn sync(&self) -> std::io::Result<()> {
470 let file = OpenOptions::new().read(true).open(&self.path)?;
471 file.sync_data()
472 }
473
474 pub fn buffered(&self) -> std::io::Result<BufferedEventLog> {
479 BufferedEventLog::open(self.path.clone())
480 }
481
482 pub fn read(&self) -> std::io::Result<Vec<AgentEvent>> {
483 let file = match File::open(&self.path) {
484 Ok(file) => file,
485 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
486 Err(error) => return Err(error),
487 };
488 BufReader::new(file)
489 .lines()
490 .enumerate()
491 .filter_map(|(line_number, result)| match result {
492 Ok(line) if line.trim().is_empty() => None,
493 Ok(line) => Some(serde_json::from_str(&line).map_err(|error| {
494 std::io::Error::new(
495 std::io::ErrorKind::InvalidData,
496 format!("event log line {}: {error}", line_number + 1),
497 )
498 })),
499 Err(error) => Some(Err(error)),
500 })
501 .collect()
502 }
503
504 pub fn replay(&self, roster_size: usize) -> std::io::Result<SessionState> {
505 let mut state = SessionState::new(roster_size);
506 for event in self.read()? {
507 reduce(&mut state, event);
508 }
509 Ok(state)
510 }
511}
512
513enum BufferedLogCommand {
514 Append(String),
515 Flush(Sender<std::io::Result<()>>),
516 Shutdown(Sender<std::io::Result<()>>),
517}
518
519pub struct BufferedEventLog {
525 sender: Sender<BufferedLogCommand>,
526 worker: Option<std::thread::JoinHandle<std::io::Result<()>>>,
527}
528
529impl std::fmt::Debug for BufferedEventLog {
530 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531 formatter
532 .debug_struct("BufferedEventLog")
533 .field("worker_running", &self.worker.is_some())
534 .finish_non_exhaustive()
535 }
536}
537
538impl BufferedEventLog {
539 fn open(path: PathBuf) -> std::io::Result<Self> {
540 let (sender, receiver) = mpsc::channel();
541 let worker = std::thread::Builder::new()
542 .name("codeswarm-event-log".into())
543 .spawn(move || buffered_log_worker(path, receiver))?;
544 Ok(Self {
545 sender,
546 worker: Some(worker),
547 })
548 }
549
550 pub fn append(&self, event: &AgentEvent) -> std::io::Result<()> {
553 let encoded = serde_json::to_string(event)
554 .map_err(|error| std::io::Error::other(error.to_string()))?;
555 self.sender
556 .send(BufferedLogCommand::Append(format!("{encoded}\n")))
557 .map_err(|_| {
558 std::io::Error::new(
559 std::io::ErrorKind::BrokenPipe,
560 "event log background writer stopped",
561 )
562 })
563 }
564
565 pub fn flush(&self) -> std::io::Result<()> {
567 let (reply, result) = mpsc::channel();
568 self.sender
569 .send(BufferedLogCommand::Flush(reply))
570 .map_err(|_| {
571 std::io::Error::new(
572 std::io::ErrorKind::BrokenPipe,
573 "event log background writer stopped",
574 )
575 })?;
576 result.recv().map_err(|_| {
577 std::io::Error::new(
578 std::io::ErrorKind::BrokenPipe,
579 "event log background writer stopped",
580 )
581 })?
582 }
583}
584
585impl Drop for BufferedEventLog {
586 fn drop(&mut self) {
587 let (reply, result) = mpsc::channel();
588 if self
589 .sender
590 .send(BufferedLogCommand::Shutdown(reply))
591 .is_ok()
592 {
593 let _ = result.recv();
594 }
595 if let Some(worker) = self.worker.take() {
596 let _ = worker.join();
597 }
598 }
599}
600
601fn buffered_log_worker(
602 path: PathBuf,
603 receiver: Receiver<BufferedLogCommand>,
604) -> std::io::Result<()> {
605 let file = OpenOptions::new().create(true).append(true).open(path)?;
606 let mut writer = BufWriter::new(file);
607 while let Ok(command) = receiver.recv() {
608 match command {
609 BufferedLogCommand::Append(line) => writer.write_all(line.as_bytes())?,
610 BufferedLogCommand::Flush(reply) => {
611 let result = writer.flush().and_then(|()| writer.get_ref().sync_data());
612 let _ = reply.send(result);
613 }
614 BufferedLogCommand::Shutdown(reply) => {
615 let result = writer.flush().and_then(|()| writer.get_ref().sync_data());
616 let worker_result = match result {
617 Ok(()) => {
618 let _ = reply.send(Ok(()));
619 Ok(())
620 }
621 Err(error) => {
622 let kind = error.kind();
623 let detail = error.to_string();
624 let _ = reply.send(Err(std::io::Error::new(kind, detail.clone())));
625 Err(std::io::Error::new(kind, detail))
626 }
627 };
628 return worker_result;
629 }
630 }
631 }
632 writer.flush()
633}
634
635#[cfg(test)]
636mod tests {
637 use std::time::{SystemTime, UNIX_EPOCH};
638
639 use super::{AgentCapabilities, AgentEvent, Effect, Mode, SessionState, reduce};
640
641 #[test]
642 fn replacement_catalog_invalidates_stale_mode() {
643 let mut state = SessionState::new(1);
644 reduce(
645 &mut state,
646 AgentEvent::ModesReplaced {
647 slot: 0,
648 modes: vec![Mode {
649 id: "read".into(),
650 label: "Read only".into(),
651 }],
652 current_mode: Some("write".into()),
653 },
654 );
655 assert_eq!(state.slots[0].current_mode, None);
656 }
657
658 #[test]
659 fn crash_tombstones_slot_and_uses_crash_copy() {
660 let mut state = SessionState::new(2);
661 reduce(
662 &mut state,
663 AgentEvent::Ready {
664 slot: 1,
665 capabilities: AgentCapabilities::default(),
666 },
667 );
668 let effects = reduce(
669 &mut state,
670 AgentEvent::Failed {
671 slot: 1,
672 started: true,
673 detail: "process exited".into(),
674 },
675 );
676 assert!(!state.slots[1].active);
677 assert!(effects.contains(&Effect::OfferReload {
678 slot: 1,
679 crashed: true,
680 }));
681 }
682
683 #[test]
684 fn event_log_replays_into_the_same_state() {
685 let unique = SystemTime::now()
686 .duration_since(UNIX_EPOCH)
687 .expect("clock")
688 .as_nanos();
689 let path = std::env::temp_dir().join(format!("codeswarm-core-{unique}.jsonl"));
690 let log = super::EventLog::open(&path);
691 let events = [
692 AgentEvent::Text {
693 slot: 0,
694 text: "first".into(),
695 },
696 AgentEvent::Failed {
697 slot: 1,
698 started: true,
699 detail: "crashed".into(),
700 },
701 ];
702 for event in &events {
703 log.append(event).expect("append");
704 }
705 let replayed = log.replay(2).expect("replay");
706 let mut expected = SessionState::new(2);
707 for event in events {
708 reduce(&mut expected, event);
709 }
710 assert_eq!(replayed, expected);
711 std::fs::remove_file(path).expect("cleanup");
712 }
713
714 #[test]
715 fn event_log_can_checkpoint_batched_appends() {
716 let unique = SystemTime::now()
717 .duration_since(UNIX_EPOCH)
718 .expect("clock")
719 .as_nanos();
720 let path = std::env::temp_dir().join(format!("codeswarm-core-checkpoint-{unique}.jsonl"));
721 let log = super::EventLog::open(&path);
722 log.append(&AgentEvent::Text {
723 slot: 0,
724 text: "batched".into(),
725 })
726 .expect("append");
727 log.sync().expect("checkpoint");
730 assert_eq!(log.read().expect("read").len(), 1);
731 std::fs::remove_file(path).expect("cleanup");
732 }
733
734 #[test]
735 fn event_log_durable_append_is_replayable() {
736 let unique = SystemTime::now()
737 .duration_since(UNIX_EPOCH)
738 .expect("clock")
739 .as_nanos();
740 let path = std::env::temp_dir().join(format!("codeswarm-core-durable-{unique}.jsonl"));
741 let log = super::EventLog::open(&path);
742 log.append_durable(&AgentEvent::Text {
743 slot: 1,
744 text: "durable".into(),
745 })
746 .expect("durable append");
747 assert_eq!(
748 log.read().expect("read")[0].clone(),
749 AgentEvent::Text {
750 slot: 1,
751 text: "durable".into(),
752 }
753 );
754 std::fs::remove_file(path).expect("cleanup");
755 }
756
757 #[test]
758 fn buffered_event_log_drains_and_checkpoints_in_order() {
759 let unique = SystemTime::now()
760 .duration_since(UNIX_EPOCH)
761 .expect("clock")
762 .as_nanos();
763 let path = std::env::temp_dir().join(format!("codeswarm-core-buffered-{unique}.jsonl"));
764 let log = super::EventLog::open(&path);
765 let buffered = log.buffered().expect("background writer");
766 for text in ["one", "two", "three"] {
767 buffered
768 .append(&AgentEvent::Text {
769 slot: 0,
770 text: text.into(),
771 })
772 .expect("queue event");
773 }
774 buffered.flush().expect("checkpoint");
775 assert_eq!(
776 log.read().expect("read").into_iter().collect::<Vec<_>>(),
777 [
778 AgentEvent::Text {
779 slot: 0,
780 text: "one".into()
781 },
782 AgentEvent::Text {
783 slot: 0,
784 text: "two".into()
785 },
786 AgentEvent::Text {
787 slot: 0,
788 text: "three".into()
789 }
790 ]
791 );
792 drop(buffered);
793 std::fs::remove_file(path).expect("cleanup");
794 }
795
796 #[test]
797 fn activity_marks_turn_active_until_completion() {
798 let mut state = SessionState::new(1);
799 reduce(
800 &mut state,
801 AgentEvent::Text {
802 slot: 0,
803 text: "stream".into(),
804 },
805 );
806 assert_eq!(state.active_slot, Some(0));
807 reduce(&mut state, AgentEvent::TurnComplete { slot: 0 });
808 assert_eq!(state.active_slot, None);
809 }
810}