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