1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, MutexGuard};
4
5use thiserror::Error;
6use tracing::{debug, info};
7
8use crate::config::Config;
9use crate::event::{CommandResult, Event, EventError, EventJournal, EventKind, StateEvent};
10use crate::folder::{FolderError, FolderState, Folders};
11use crate::store::{Store, StoreError};
12use crate::terminal::{
13 TerminalError, TerminalId, TerminalManager, TerminalSize, TerminalState, TerminalStatus,
14 TerminalStream, TranscriptError, TranscriptRecorder,
15};
16
17use crate::harness::{HarnessId, LoginPath};
18use crate::workflow::{SessionId, WorkflowId, WorkflowKind, WorkflowState};
19
20mod agents;
21mod configuration;
22mod files;
23mod git;
24mod harness_steps;
25#[cfg(test)]
26mod recovery;
27mod resume_agents;
28mod run_inputs;
29mod runs;
30mod sessions;
31mod terminals;
32mod traces;
33mod windows;
34mod workflow_types;
35mod workflows;
36
37const DATABASE_FILE_NAME: &str = "twine.db";
38const DEFAULT_EVENT_CAPACITY: usize = 4_096;
39const DEFAULT_TERMINAL_CAPACITY_BYTES: usize = 1024 * 1024;
40const DEFAULT_TERMINAL_CAPACITY_CHUNKS: usize = 4_096;
41
42#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
43pub struct RequestId(pub u64);
44
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub enum Command {
47 Ping,
48 ReloadConfig,
50 ValidateWorkflowType {
51 definition: crate::WorkflowTypeDefinition,
52 },
53 SaveWorkflowType {
54 expected_definition: Option<Box<crate::WorkflowTypeDefinition>>,
55 source: Option<crate::WorkflowTypeRef>,
56 definition: crate::WorkflowTypeDefinition,
57 },
58 DeleteWorkflowType {
59 reference: crate::WorkflowTypeRef,
60 expected_definition: Box<crate::WorkflowTypeDefinition>,
61 },
62 OpenFolder {
64 path: PathBuf,
65 },
66 RestoreFolder {
68 path: PathBuf,
69 },
70 CloseFolder,
72 CloseFolderIfOpen {
74 path: PathBuf,
75 },
76 RemoveRecentFolder {
77 path: PathBuf,
78 },
79 RefreshGitBranch {
80 folder: PathBuf,
81 },
82 CreateSession {
83 folder: PathBuf,
84 name: String,
85 },
86 RenameSession {
87 session_id: SessionId,
88 name: String,
89 },
90 SelectSession {
91 session_id: SessionId,
92 },
93 DeleteSession {
94 session_id: SessionId,
95 },
96 CreateWorkflow {
97 folder: PathBuf,
98 session_id: Option<SessionId>,
99 kind: WorkflowKind,
100 roles: Vec<String>,
102 size: TerminalSize,
103 },
104 ActivateWorkflow {
105 workflow_id: WorkflowId,
106 },
107 NameDraftWorkflow {
108 workflow_id: WorkflowId,
109 name: String,
110 },
111 CloseWorkflow {
112 workflow_id: WorkflowId,
113 },
114 StartAgent {
117 workflow_id: WorkflowId,
118 harness: HarnessId,
119 model: Option<String>,
121 effort: Option<String>,
123 yolo: bool,
125 prompt: String,
126 size: TerminalSize,
127 },
128 ResumeAgent {
129 workflow_id: WorkflowId,
130 session: String,
131 },
132 CancelAgent {
134 workflow_id: WorkflowId,
135 },
136 StartWorkflowRun {
139 workflow_id: WorkflowId,
140 workflow_type: crate::WorkflowTypeRef,
141 prompt: String,
142 roles: Vec<crate::RoleLaunch>,
143 size: TerminalSize,
144 },
145 CompleteWorkflowRole {
146 workflow_id: WorkflowId,
147 agent_id: crate::AgentId,
148 generation: u64,
149 signal: crate::CompletionSignal,
150 },
151 ContinueWorkflowRun {
153 workflow_id: WorkflowId,
154 agent_id: crate::AgentId,
155 generation: u64,
156 mode_revision: u64,
157 },
158 SetWorkflowIndividualMode {
159 workflow_id: WorkflowId,
160 generation: u64,
161 mode_revision: u64,
162 individual_mode: bool,
163 },
164 SubmitWorkflowTask {
165 workflow_id: WorkflowId,
166 generation: u64,
167 prompt: String,
168 },
169 ChooseWorkflowResult {
170 workflow_id: WorkflowId,
171 generation: u64,
172 agent_id: crate::AgentId,
173 },
174 CancelWorkflowRun {
175 workflow_id: WorkflowId,
176 },
177 StartTerminal {
178 working_directory: PathBuf,
179 size: TerminalSize,
180 },
181 CloseTerminal {
182 terminal_id: TerminalId,
183 },
184}
185
186#[derive(Clone, Debug, Eq, PartialEq)]
187pub enum CommandDisposition {
188 Accepted,
189 Rejected { code: String, message: String },
190}
191
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub struct CommandReceipt {
194 pub request_id: RequestId,
195 pub disposition: CommandDisposition,
196}
197
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199pub enum ApplicationState {
200 Ready,
201}
202
203#[derive(Clone, Debug, Eq, PartialEq)]
204pub struct Snapshot {
205 pub sequence: u64,
206 pub state: ApplicationState,
207 pub config: Config,
208 pub folders: FolderState,
209 pub terminals: Vec<TerminalState>,
210 pub workflows: WorkflowState,
211 pub traces: Vec<crate::TraceSummary>,
212 pub workflow_types: Vec<crate::WorkflowType>,
213}
214
215#[derive(Debug)]
216struct Inner {
217 state: ApplicationState,
218 config: Config,
219 folders: Folders,
220 events: EventJournal,
221 terminals: HashMap<TerminalId, TerminalStatus>,
222 workflows: WorkflowState,
223 trace_spans: HashMap<TerminalId, crate::TraceSpanId>,
224 step_terminals: HashMap<TerminalId, WorkflowId>,
225 pending_trace_endings: HashMap<TerminalId, traces::PendingTraceEnding>,
226 command_shells: std::collections::HashSet<TerminalId>,
227 shell_process_endings: HashMap<TerminalId, traces::PendingTraceEnding>,
228 pending_shell_marks: std::collections::VecDeque<crate::terminal::ShellObservation>,
229 pending_shell_workflows: HashMap<TerminalId, crate::Workflow>,
230 workflow_types_cache: Vec<crate::WorkflowType>,
231 workflow_types_polled: Option<std::time::Instant>,
232}
233
234pub struct Application {
235 window_storage: Option<windows::WindowStorageLease>,
236 shared_recording: bool,
238 files: crate::files::FileWatcher,
239 commands: Mutex<()>,
241 inner: Arc<Mutex<Inner>>,
242 terminal_output: Arc<TerminalStream>,
243 terminals: TerminalManager,
244 login_path: Arc<LoginPath>,
246 model_lists: crate::harness::models::ModelListWorkers,
247 #[cfg(test)]
249 harness_path: Option<std::ffi::OsString>,
250 run_processes: Mutex<HashMap<WorkflowId, runs::RunProcesses>>,
251 harness_steps: Mutex<HashMap<TerminalId, harness_steps::HarnessRecording>>,
252 history: Mutex<crate::harness::history::HistoryWorkers>,
253}
254
255impl Application {
256 pub fn new(data_directory: &Path) -> Result<Self, ApplicationError> {
264 Self::with_config(data_directory, Config::load_user())
265 }
266
267 pub fn with_config(data_directory: &Path, config: Config) -> Result<Self, ApplicationError> {
275 let transcripts = Arc::new(TranscriptRecorder::open(
277 &data_directory.join("transcripts"),
278 )?);
279 let store = Store::open(&data_directory.join(DATABASE_FILE_NAME))?;
280 Self::with_store(store, config, DEFAULT_EVENT_CAPACITY, transcripts)
281 }
282
283 #[cfg(test)]
286 pub(crate) fn with_event_capacity(event_capacity: usize) -> Result<Self, ApplicationError> {
287 let mut application = Self::with_store(
288 Store::open_in_memory()?,
289 Config::default(),
290 event_capacity,
291 Arc::new(TranscriptRecorder::temporary()?),
292 )?;
293 application
294 .terminals
295 .set_test_shell(PathBuf::from("/bin/sh"));
296 Ok(application)
297 }
298
299 fn with_store(
300 mut store: Store,
301 config: Config,
302 event_capacity: usize,
303 transcripts: Arc<TranscriptRecorder>,
304 ) -> Result<Self, ApplicationError> {
305 store.recover_interrupted_work()?;
307 let folders = Folders::restore(store)?;
308 Self::with_folders(folders, config, event_capacity, transcripts, false)
309 }
310
311 fn with_folders(
312 mut folders: Folders,
313 config: Config,
314 event_capacity: usize,
315 transcripts: Arc<TranscriptRecorder>,
316 shared_recording: bool,
317 ) -> Result<Self, ApplicationError> {
318 folders.store().configure_trace_storage(&config.traces)?;
319 let mut events = EventJournal::new(event_capacity)?;
320 events.append(EventKind::State(StateEvent::ApplicationReady))?;
321
322 let terminal_output = Arc::new(TerminalStream::new(
323 DEFAULT_TERMINAL_CAPACITY_BYTES,
324 DEFAULT_TERMINAL_CAPACITY_CHUNKS,
325 transcripts,
326 )?);
327 let terminals = TerminalManager::new(Arc::clone(&terminal_output));
328 #[cfg(test)]
329 let terminals = {
330 let mut terminals = terminals;
331 terminals.set_test_shell(PathBuf::from("/bin/sh"));
333 terminals
334 };
335 let application = Self {
336 window_storage: None,
337 shared_recording,
338 files: crate::files::FileWatcher::new()?,
339 run_processes: Mutex::new(HashMap::new()),
340 harness_steps: Mutex::new(HashMap::new()),
341 history: Mutex::new(
342 crate::harness::history::HistoryWorkers::new()
343 .map_err(crate::StoreError::TracePayload)?,
344 ),
345 commands: Mutex::new(()),
346 inner: Arc::new(Mutex::new(Inner {
347 state: ApplicationState::Ready,
348 config,
349 folders,
350 events,
351 terminals: HashMap::new(),
352 workflows: WorkflowState::default(),
353 trace_spans: HashMap::new(),
354 step_terminals: HashMap::new(),
355 pending_trace_endings: HashMap::new(),
356 command_shells: std::collections::HashSet::new(),
357 shell_process_endings: HashMap::new(),
358 pending_shell_marks: std::collections::VecDeque::new(),
359 pending_shell_workflows: HashMap::new(),
360 workflow_types_cache: Vec::new(),
361 workflow_types_polled: None,
362 })),
363 terminal_output: Arc::clone(&terminal_output),
364 terminals,
365 model_lists: crate::harness::models::ModelListWorkers::default(),
366 #[cfg(test)]
368 login_path: Arc::new(LoginPath::ready(None)),
369 #[cfg(not(test))]
370 login_path: Arc::new(LoginPath::spawn(crate::terminal::login_shell())),
371 #[cfg(test)]
372 harness_path: None,
373 };
374
375 application.restore_workflows()?;
376 let _ = application.trace_storage_status(None)?;
377 info!("application core initialized");
378 Ok(application)
379 }
380
381 #[expect(clippy::too_many_lines, reason = "one short arm per command")]
388 pub fn handle_command(
389 &self,
390 request_id: RequestId,
391 command: Command,
392 ) -> Result<CommandReceipt, ApplicationError> {
393 let _command_guard = self
394 .commands
395 .lock()
396 .map_err(|_| ApplicationError::Poisoned)?;
397 self.lock_inner()?.retry_trace_endings();
398 self.poll_harness_steps()?;
399 self.poll_shell_observations()?;
400 let disposition = match command {
401 Command::ReloadConfig => self.reload_config()?,
402 Command::ValidateWorkflowType { definition } => {
403 self.validate_workflow_type(request_id, &definition)?
404 }
405 Command::SaveWorkflowType {
406 source,
407 definition,
408 expected_definition,
409 } => self.save_workflow_type(
410 request_id,
411 source,
412 &definition,
413 expected_definition.as_deref(),
414 )?,
415 Command::DeleteWorkflowType {
416 reference,
417 expected_definition,
418 } => self.delete_workflow_type(request_id, reference, &expected_definition)?,
419 Command::Ping => {
420 let mut inner = self.lock_inner()?;
421 inner.events.append(EventKind::CommandCompleted {
422 request_id,
423 result: CommandResult::Pong,
424 })?;
425 CommandDisposition::Accepted
426 }
427 Command::OpenFolder { path } => self.change_folder(Some(&path))?,
428 Command::RestoreFolder { path } => self.restore_window_folder(&path)?,
429 Command::CloseFolder => self.change_folder(None)?,
430 Command::CloseFolderIfOpen { path } => {
431 if self.lock_inner()?.folders.state().open_folder.as_deref() == Some(path.as_path())
432 {
433 self.change_folder(None)?
434 } else {
435 CommandDisposition::Accepted
436 }
437 }
438 Command::RemoveRecentFolder { path } => self
439 .lock_inner()?
440 .update_folders(|folders| folders.remove_recent(&path))?,
441 Command::RefreshGitBranch { folder } => self.refresh_git_branch(&folder)?,
442 Command::CreateSession { folder, name } => {
443 self.create_session(request_id, &folder, &name)?
444 }
445 Command::RenameSession { session_id, name } => {
446 self.rename_session(request_id, session_id, &name)?
447 }
448 Command::SelectSession { session_id } => self.select_session(request_id, session_id)?,
449 Command::DeleteSession { session_id } => self.delete_session(request_id, session_id)?,
450 Command::CreateWorkflow {
451 folder,
452 session_id,
453 kind,
454 roles,
455 size,
456 } => self.create_workflow(request_id, &folder, session_id, kind, &roles, size)?,
457 Command::ActivateWorkflow { workflow_id } => {
458 self.activate_workflow(request_id, workflow_id)?
459 }
460 Command::NameDraftWorkflow { workflow_id, name } => {
461 self.name_draft_workflow(workflow_id, &name)?
462 }
463 Command::CloseWorkflow { workflow_id } => {
464 self.close_workflow(request_id, workflow_id)?
465 }
466 Command::StartAgent {
467 workflow_id,
468 harness,
469 model,
470 effort,
471 yolo,
472 prompt,
473 size,
474 } => self.start_agent(
475 request_id,
476 workflow_id,
477 harness,
478 (model.as_deref(), effort.as_deref(), yolo),
479 &prompt,
480 size,
481 )?,
482 Command::ResumeAgent {
483 workflow_id,
484 session,
485 } => self.resume_agent(request_id, workflow_id, &session)?,
486 Command::CancelAgent { workflow_id } => self.cancel_agent(request_id, workflow_id)?,
487 Command::StartWorkflowRun {
488 workflow_id,
489 workflow_type,
490 prompt,
491 roles,
492 size,
493 } => self.start_workflow_run(workflow_id, workflow_type, prompt, &roles, size)?,
494 Command::CompleteWorkflowRole {
495 workflow_id,
496 agent_id,
497 generation,
498 signal,
499 } => self.complete_workflow_role(workflow_id, agent_id, generation, signal)?,
500 Command::ContinueWorkflowRun {
501 workflow_id,
502 agent_id,
503 generation,
504 mode_revision,
505 } => self.continue_workflow_run(workflow_id, agent_id, generation, mode_revision)?,
506 Command::SetWorkflowIndividualMode {
507 workflow_id,
508 generation,
509 mode_revision,
510 individual_mode,
511 } => self.set_workflow_individual_mode(
512 workflow_id,
513 generation,
514 mode_revision,
515 individual_mode,
516 )?,
517 Command::SubmitWorkflowTask {
518 workflow_id,
519 generation,
520 prompt,
521 } => self.submit_workflow_task(workflow_id, generation, prompt)?,
522 Command::ChooseWorkflowResult {
523 workflow_id,
524 generation,
525 agent_id,
526 } => self.choose_workflow_result(workflow_id, generation, agent_id)?,
527 Command::CancelWorkflowRun { workflow_id } => self.cancel_workflow_run(workflow_id)?,
528 Command::StartTerminal {
529 working_directory,
530 size,
531 } => self.start_terminal_command(request_id, &working_directory, size)?,
532 Command::CloseTerminal { terminal_id } => {
533 self.close_terminal_command(request_id, terminal_id)?
534 }
535 };
536 self.prune_run_processes()?;
537 match &disposition {
538 CommandDisposition::Accepted => debug!(request_id = request_id.0, "command accepted"),
539 CommandDisposition::Rejected { code, .. } => {
540 debug!(request_id = request_id.0, code, "command rejected");
541 }
542 }
543 Ok(CommandReceipt {
544 request_id,
545 disposition,
546 })
547 }
548
549 pub fn snapshot(&self) -> Result<Snapshot, ApplicationError> {
555 self.poll_harness_steps()?;
556 self.poll_shell_observations()?;
557 self.poll_workflow_signals()?;
558 let mut inner = self.lock_inner()?;
559 let workflow_types =
560 crate::workflow_type::WorkflowCatalog::new(inner.folders.store()).list()?;
561 inner.workflow_types_cache.clone_from(&workflow_types);
562 let mut terminals = inner
563 .terminals
564 .iter()
565 .map(|(terminal_id, status)| TerminalState {
566 terminal_id: *terminal_id,
567 status: status.clone(),
568 })
569 .collect::<Vec<_>>();
570 terminals.sort_by_key(|terminal| terminal.terminal_id.value());
571 Ok(Snapshot {
572 sequence: inner.events.latest_sequence(),
573 state: inner.state,
574 config: inner.config.clone(),
575 folders: inner.folders.state().clone(),
576 terminals,
577 workflows: inner.workflows.clone(),
578 traces: match &inner.folders.state().open_folder {
579 Some(folder) => inner.folders.read_store().trace_summaries(folder)?,
580 None => Vec::new(),
581 },
582 workflow_types,
583 })
584 }
585
586 pub fn events_after(
593 &self,
594 sequence: u64,
595 limit: usize,
596 ) -> Result<Vec<Event>, ApplicationError> {
597 self.poll_shell_observations()?;
598 self.poll_workflow_signals()?;
599 let mut inner = self.lock_inner()?;
600 let previous = inner.folders.state().clone();
601 if inner.folders.refresh_shared()? && *inner.folders.state() != previous {
602 let folders = inner.folders.state().clone();
603 inner
604 .events
605 .append(EventKind::State(StateEvent::FoldersChanged(folders)))?;
606 }
607 if inner
608 .workflow_types_polled
609 .is_none_or(|time| time.elapsed() >= std::time::Duration::from_secs(1))
610 {
611 inner.workflow_types_polled = Some(std::time::Instant::now());
612 let types = crate::workflow_type::WorkflowCatalog::new(inner.folders.store()).list()?;
613 if types != inner.workflow_types_cache {
614 let initialized = !inner.workflow_types_cache.is_empty();
615 inner.workflow_types_cache.clone_from(&types);
616 if initialized {
617 inner
618 .events
619 .append(EventKind::State(StateEvent::WorkflowTypesChanged(types)))?;
620 }
621 }
622 }
623 Ok(inner.events.after(sequence, limit)?)
624 }
625
626 fn lock_inner(&self) -> Result<MutexGuard<'_, Inner>, ApplicationError> {
627 self.inner.lock().map_err(|_| ApplicationError::Poisoned)
628 }
629}
630
631impl Inner {
632 fn update_folders(
635 &mut self,
636 change: impl FnOnce(&mut Folders) -> Result<(), FolderError>,
637 ) -> Result<CommandDisposition, ApplicationError> {
638 let previous = self.folders.state().clone();
639 let result = change(&mut self.folders);
640 if *self.folders.state() != previous {
641 self.events
642 .append(EventKind::State(StateEvent::FoldersChanged(
643 self.folders.state().clone(),
644 )))?;
645 }
646
647 let (code, error) = match result {
648 Ok(()) => return Ok(CommandDisposition::Accepted),
649 Err(FolderError::Store(error)) => return Err(error.into()),
650 Err(error @ FolderError::InvalidPath) => ("invalidPath", error),
651 Err(error @ FolderError::Missing) => ("folderNotFound", error),
652 Err(error @ FolderError::Inaccessible) => ("folderInaccessible", error),
653 };
654 Ok(rejection(code, &error))
655 }
656}
657
658impl Drop for Application {
659 fn drop(&mut self) {
660 self.login_path.cancel();
663 self.model_lists.shutdown();
664 self.login_path.shutdown();
665 let _ = self.poll_harness_steps();
666 let _ = self.poll_shell_observations();
667 if let Ok(mut inner) = self.inner.lock() {
668 inner.retry_trace_endings();
669 let ids = inner
670 .trace_spans
671 .keys()
672 .chain(inner.step_terminals.keys())
673 .copied()
674 .collect::<std::collections::HashSet<_>>();
675 for id in ids {
676 if let Ok(observation) = self.terminals.observe(id)
677 && let Err(error) =
678 inner.stop_trace(id, observation, "Process stopped when Twine quit.")
679 {
680 tracing::warn!(%error, "failed to persist trace during shutdown");
681 }
682 }
683 inner.terminals.clear();
685 }
686 self.terminals.shutdown();
687 if !self.shared_recording {
688 self.terminal_output.shutdown_recording();
689 }
690 self.window_storage.take();
691 }
692}
693
694fn rejection(code: &str, error: &dyn std::error::Error) -> CommandDisposition {
695 CommandDisposition::Rejected {
696 code: code.to_owned(),
697 message: error.to_string(),
698 }
699}
700
701#[derive(Debug, Error)]
702pub enum ApplicationError {
703 #[error("Could not create a workflow signal inbox: {0}")]
704 CompletionInbox(std::io::Error),
705 #[error(transparent)]
706 Harness(#[from] crate::harness::HarnessError),
707 #[error(transparent)]
708 Catalog(#[from] crate::CatalogError),
709 #[error(transparent)]
710 Files(#[from] crate::files::FileError),
711 #[error(transparent)]
712 Event(#[from] EventError),
713 #[error("session or workflow identifiers exhausted")]
714 IdExhausted,
715 #[error("application state lock is poisoned")]
716 Poisoned,
717 #[error(transparent)]
718 Store(#[from] StoreError),
719 #[error(transparent)]
720 Terminal(#[from] TerminalError),
721 #[error(transparent)]
722 Transcript(#[from] TranscriptError),
723 #[error(transparent)]
724 Trace(#[from] crate::TraceError),
725}
726
727#[cfg(test)]
728mod tests {
729 use std::sync::Arc;
730
731 use super::*;
732 use crate::config::{ColorScheme, Config};
733 use crate::folder::{UnavailableFolder, UnavailableReason};
734
735 #[test]
736 fn snapshot_then_events_has_no_gap_or_repeat() {
737 let data = tempfile::tempdir().expect("a data directory should be available");
738 let application = Application::with_config(data.path(), Config::default())
739 .expect("application should initialize");
740 let snapshot = application.snapshot().expect("snapshot should succeed");
741
742 let receipt = application
743 .handle_command(RequestId(41), Command::Ping)
744 .expect("command should succeed");
745 let events = application
746 .events_after(snapshot.sequence, 16)
747 .expect("events should be available");
748
749 assert_eq!(receipt.request_id, RequestId(41));
750 assert_eq!(receipt.disposition, CommandDisposition::Accepted);
751 assert_eq!(events.len(), 1);
752 assert_eq!(events[0].sequence, snapshot.sequence + 1);
753 assert_eq!(
754 events[0].kind,
755 EventKind::CommandCompleted {
756 request_id: RequestId(41),
757 result: CommandResult::Pong,
758 }
759 );
760 assert_eq!(
761 application
762 .events_after(events[0].sequence, 16)
763 .expect("up-to-date cursor should succeed"),
764 []
765 );
766 }
767
768 #[test]
769 fn expired_cursor_is_explicit() {
770 let application =
771 Application::with_event_capacity(2).expect("application should initialize");
772 application
773 .handle_command(RequestId(1), Command::Ping)
774 .expect("first command should succeed");
775 application
776 .handle_command(RequestId(2), Command::Ping)
777 .expect("second command should succeed");
778
779 let error = application
780 .events_after(0, 16)
781 .expect_err("the evicted cursor should fail");
782
783 assert!(matches!(
784 error,
785 ApplicationError::Event(EventError::CursorExpired {
786 requested: 0,
787 oldest_available: 2,
788 })
789 ));
790 }
791
792 #[test]
793 fn folder_commands_publish_folder_state_after_the_snapshot() {
794 let folder = tempfile::tempdir().expect("a folder should be available");
795 let application =
796 Application::with_event_capacity(16).expect("application should initialize");
797 let snapshot = application.snapshot().expect("snapshot should succeed");
798 assert_eq!(snapshot.folders, FolderState::default());
799
800 let receipt = application
801 .handle_command(
802 RequestId(1),
803 Command::OpenFolder {
804 path: folder.path().to_owned(),
805 },
806 )
807 .expect("command should succeed");
808 assert_eq!(receipt.disposition, CommandDisposition::Accepted);
809 let events = application
810 .events_after(snapshot.sequence, 16)
811 .expect("events should be available");
812 let [event] = events.as_slice() else {
813 panic!("expected one event, got {events:?}");
814 };
815 assert_eq!(event.sequence, snapshot.sequence + 1);
816 let EventKind::State(StateEvent::FoldersChanged(folders)) = &event.kind else {
817 panic!("expected a folder event, got {event:?}");
818 };
819 assert_eq!(folders.open_folder.as_deref(), Some(folder.path()));
820 assert_eq!(
821 application.snapshot().expect("snapshot should succeed"),
822 Snapshot {
823 sequence: event.sequence,
824 state: ApplicationState::Ready,
825 config: Config::default(),
826 folders: folders.clone(),
827 terminals: Vec::new(),
828 workflows: WorkflowState::default(),
829 traces: Vec::new(),
830 workflow_types: application.workflow_types().unwrap(),
831 }
832 );
833 }
834
835 #[test]
836 fn rejected_terminal_commands_explain_themselves() {
837 let application =
838 Application::with_event_capacity(16).expect("application should initialize");
839 let missing = {
840 let folder = tempfile::tempdir().expect("a folder should be available");
841 folder.path().to_owned()
842 }; let sequence = application
844 .snapshot()
845 .expect("snapshot should succeed")
846 .sequence;
847
848 let start = application
849 .handle_command(
850 RequestId(1),
851 Command::StartTerminal {
852 working_directory: missing,
853 size: TerminalSize {
854 rows: 24,
855 columns: 80,
856 pixel_width: 800,
857 pixel_height: 480,
858 },
859 },
860 )
861 .expect("command should succeed");
862 assert!(matches!(
863 start.disposition,
864 CommandDisposition::Rejected { ref code, .. } if code == "terminalStartFailed"
865 ));
866
867 let close = application
868 .handle_command(
869 RequestId(2),
870 Command::CloseTerminal {
871 terminal_id: TerminalId::from_value(99),
872 },
873 )
874 .expect("command should succeed");
875 assert!(matches!(
876 close.disposition,
877 CommandDisposition::Rejected { ref code, .. } if code == "terminalCloseFailed"
878 ));
879
880 let snapshot = application.snapshot().expect("snapshot should succeed");
881 assert_eq!(snapshot.sequence, sequence);
882 assert_eq!(snapshot.terminals, []);
883 }
884
885 #[test]
886 fn rejected_folder_commands_explain_themselves() {
887 let application =
888 Application::with_event_capacity(16).expect("application should initialize");
889 let missing = {
890 let folder = tempfile::tempdir().expect("a folder should be available");
891 folder.path().to_owned()
892 }; let sequence = application
894 .snapshot()
895 .expect("snapshot should succeed")
896 .sequence;
897
898 let receipt = application
899 .handle_command(
900 RequestId(1),
901 Command::OpenFolder {
902 path: missing.clone(),
903 },
904 )
905 .expect("command should succeed");
906 assert!(matches!(
907 receipt.disposition,
908 CommandDisposition::Rejected { ref code, .. } if code == "folderNotFound"
909 ));
910 let snapshot = application.snapshot().expect("snapshot should succeed");
911 assert_eq!(snapshot.sequence, sequence + 1);
912 assert_eq!(
913 snapshot.folders.unavailable_folder,
914 Some(UnavailableFolder {
915 path: missing,
916 reason: UnavailableReason::Missing,
917 })
918 );
919
920 let receipt = application
921 .handle_command(
922 RequestId(2),
923 Command::RemoveRecentFolder {
924 path: PathBuf::from("relative"),
925 },
926 )
927 .expect("command should succeed");
928 assert!(matches!(
929 receipt.disposition,
930 CommandDisposition::Rejected { ref code, .. } if code == "invalidPath"
931 ));
932 assert_eq!(
933 application
934 .snapshot()
935 .expect("snapshot should succeed")
936 .sequence,
937 snapshot.sequence,
938 "a command that changes nothing publishes no event"
939 );
940 }
941
942 #[test]
943 fn a_new_application_reopens_the_last_folder_from_its_data_directory() {
944 let data = tempfile::tempdir().expect("a data directory should be available");
945 let folder = tempfile::tempdir().expect("a folder should be available");
946 let application = Application::with_config(data.path(), Config::default())
947 .expect("application should initialize");
948 application
949 .handle_command(
950 RequestId(1),
951 Command::OpenFolder {
952 path: folder.path().to_owned(),
953 },
954 )
955 .expect("command should succeed");
956 drop(application);
957
958 let relaunched = Application::with_config(data.path(), Config::default())
959 .expect("application should initialize");
960 assert_eq!(
961 relaunched
962 .snapshot()
963 .expect("snapshot should succeed")
964 .folders
965 .open_folder
966 .as_deref(),
967 Some(folder.path())
968 );
969 }
970
971 #[test]
972 fn concurrent_snapshots_and_commands_keep_sequences_contiguous() {
973 const COMMANDS_PER_THREAD: usize = 500;
974 const THREADS: usize = 4;
975
976 let application = Arc::new(
977 Application::with_event_capacity(3_000).expect("application should initialize"),
978 );
979 let snapshot = application.snapshot().expect("snapshot should succeed");
980 let finished = Arc::new(std::sync::atomic::AtomicUsize::new(0));
981 let handles: Vec<_> = (0..THREADS)
982 .map(|thread| {
983 let application = Arc::clone(&application);
984 let finished = Arc::clone(&finished);
985 std::thread::spawn(move || {
986 for request in 0..COMMANDS_PER_THREAD {
987 application
988 .handle_command(
989 RequestId(
990 u64::try_from(thread * COMMANDS_PER_THREAD + request)
991 .expect("test request ID should fit"),
992 ),
993 Command::Ping,
994 )
995 .expect("command should succeed");
996 if request % 25 == 0 {
997 std::thread::yield_now();
998 }
999 }
1000 finished.fetch_add(1, std::sync::atomic::Ordering::Release);
1001 })
1002 })
1003 .collect();
1004
1005 while finished.load(std::sync::atomic::Ordering::Acquire) != THREADS {
1006 let concurrent_snapshot = application.snapshot().expect("snapshot should succeed");
1007 let events = application
1008 .events_after(concurrent_snapshot.sequence, THREADS * COMMANDS_PER_THREAD)
1009 .expect("snapshot cursor should remain available");
1010 for (index, event) in events.iter().enumerate() {
1011 assert_eq!(
1012 event.sequence,
1013 concurrent_snapshot.sequence
1014 + u64::try_from(index).expect("test index should fit")
1015 + 1
1016 );
1017 }
1018 }
1019
1020 for handle in handles {
1021 handle.join().expect("command thread should not panic");
1022 }
1023
1024 let events = application
1025 .events_after(snapshot.sequence, THREADS * COMMANDS_PER_THREAD)
1026 .expect("all events should remain available");
1027 assert_eq!(events.len(), THREADS * COMMANDS_PER_THREAD);
1028 for (index, event) in events.iter().enumerate() {
1029 assert_eq!(
1030 event.sequence,
1031 snapshot.sequence + u64::try_from(index).expect("test index should fit") + 1
1032 );
1033 }
1034 }
1035
1036 #[test]
1037 fn snapshot_contains_loaded_config() {
1038 let directory = tempfile::tempdir().unwrap();
1039 let path = directory.path().join("config.toml");
1040 std::fs::write(
1041 &path,
1042 "[appearance]\ncolor_scheme = 'dark'\n[terminal]\nfont_family = 'Menlo'\nfont_size = 15.5\n",
1043 )
1044 .unwrap();
1045 let application =
1046 Application::with_config(directory.path(), Config::load(&path).config).unwrap();
1047 assert_eq!(
1048 application
1049 .snapshot()
1050 .unwrap()
1051 .config
1052 .appearance
1053 .color_scheme,
1054 ColorScheme::Dark
1055 );
1056 let config = application.snapshot().unwrap().config;
1057 assert_eq!(config.terminal.font_family, "Menlo");
1058 assert!((config.terminal.font_size.points() - 15.5).abs() < f64::EPSILON);
1059 }
1060}