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