1use std::path::Path;
14use std::sync::Arc;
15
16use forge_primitives::SessionSlot;
17use forge_primitives::tasks::{Task, TaskStatus};
18use forge_primitives::{CronEntry, CronId, CronKind};
19use forge_workspace::{ProjectKey, Workspace};
20use tokio::sync::mpsc;
21
22use crate::SessionUpdate;
23use crate::surface::{PendingKind, ViewSurface};
24use crate::transport::TransportState;
25
26pub type FixtureError = Box<dyn std::error::Error + Send + Sync>;
28
29#[derive(Default)]
33pub struct ViewFacts {
34 pub session_id: Option<forge_primitives::SessionId>,
35 pub model: Option<forge_primitives::CurrentModel>,
36 pub observed_effort: Option<forge_primitives::EffortLevel>,
39 pub configured_effort: Option<forge_primitives::EffortLevel>,
41 pub permission_mode: Option<forge_primitives::PermissionMode>,
42 pub context: Option<forge_workspace::ContextUsage>,
43 pub mcp: Option<forge_workspace::McpServers>,
44 pub process_snapshot: Option<forge_workspace::env::processes::ProcessSnapshot>,
45 pub background_tasks: Vec<forge_workspace::BackgroundTask>,
48 pub live_tasks: Vec<forge_workspace::BackgroundTask>,
51 pub monitors: Vec<forge_primitives::MonitorRecord>,
52 pub cards: Vec<forge_primitives::runtime::SubagentCard>,
56}
57
58pub struct Fleet {
61 surface: Arc<ViewSurface>,
62 workspace: Arc<Workspace>,
63 config_dir: std::path::PathBuf,
66 owned_dir: Option<tempfile::TempDir>,
70}
71
72const SEEDED_SESSION: &str = "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45";
75
76impl Fleet {
77 pub fn new(orgs: &[(&str, &[&str])]) -> Result<Self, FixtureError> {
79 let dir = tempfile::tempdir()?;
80 let mut fleet = Self::in_dir(dir.path(), orgs)?;
81 fleet.owned_dir = Some(dir);
82 Ok(fleet)
83 }
84
85 pub fn in_dir(config_dir: &Path, orgs: &[(&str, &[&str])]) -> Result<Self, FixtureError> {
96 Self::in_dir_with_server(config_dir, orgs, "")
97 }
98
99 pub fn in_dir_with_server(
102 config_dir: &Path,
103 orgs: &[(&str, &[&str])],
104 server: &str,
105 ) -> Result<Self, FixtureError> {
106 Self::in_dir_with_server_and_env(config_dir, orgs, server, &[])
107 }
108
109 pub fn in_dir_with_server_and_env(
113 config_dir: &Path,
114 orgs: &[(&str, &[&str])],
115 server: &str,
116 env: &[(&str, &str)],
117 ) -> Result<Self, FixtureError> {
118 let forge = config_dir.join("forge");
119 std::fs::create_dir_all(&forge)?;
120 std::fs::write(forge.join("forge.toml"), config(config_dir, orgs, server, env, false))?;
121 Self::open(config_dir)
122 }
123
124 pub fn new_with_tts(orgs: &[(&str, &[&str])]) -> Result<Self, FixtureError> {
128 let dir = tempfile::tempdir()?;
129 let forge = dir.path().join("forge");
130 std::fs::create_dir_all(&forge)?;
131 std::fs::write(forge.join("forge.toml"), config(dir.path(), orgs, "", &[], true))?;
132 let mut fleet = Self::open(dir.path())?;
133 fleet.owned_dir = Some(dir);
134 Ok(fleet)
135 }
136
137 fn open(config_dir: &Path) -> Result<Self, FixtureError> {
138 let workspace = Arc::new(Workspace::new_for_test(config_dir.to_owned())?);
142 workspace.set_settings_version("test-build");
145 workspace.seed_test_user_preferences(serde_json::json!({}));
148 Ok(Self {
149 surface: Arc::new(ViewSurface::new(Arc::clone(&workspace))),
150 workspace,
151 config_dir: config_dir.to_owned(),
152 owned_dir: None,
153 })
154 }
155
156 pub fn surface(&self) -> Arc<ViewSurface> {
158 Arc::clone(&self.surface)
159 }
160
161 pub fn workspace(&self) -> Arc<Workspace> {
165 Arc::clone(&self.workspace)
166 }
167
168 pub fn arm_dictation(&self, key: &SessionSlot) -> Result<tempfile::TempDir, FixtureError> {
173 let dir = tempfile::tempdir()?;
174 self.workspace.install_test_dictate_engine(dir.path())?;
175 self.workspace.mark_test_session_live(key);
176 Ok(dir)
177 }
178
179 pub fn emit(&self, update: SessionUpdate) {
182 self.workspace.emit_for_test(update);
183 }
184
185 pub fn seed_task(&self, task: forge_primitives::tasks::Task) {
188 self.workspace.seed_test_task(task);
189 }
190
191 pub fn emit_and_report(&self, update: SessionUpdate) -> bool {
196 self.workspace.emit_for_test_reported(update)
197 }
198
199 pub fn park_slack_draft(&self, key: &SessionSlot, draft: forge_primitives::slack::SlackDraft) {
203 self.workspace.park_slack_draft_for_test(key, draft);
204 }
205
206 pub fn subscriber_count(&self) -> usize {
211 self.workspace.test_subscriber_count()
212 }
213
214 pub fn answering_count(&self) -> usize {
219 self.workspace.test_answering_count()
220 }
221
222 pub fn set_cli_version(&self, installed: Option<&str>, latest: Option<&str>) {
225 self.workspace.seed_test_cli_version(installed, latest);
226 }
227
228 pub fn set_fatal_error(&self, error: forge_primitives::error::AppError) {
231 self.workspace.seed_test_fatal_error(error);
232 }
233
234 pub fn set_user_preferences(&self, preferences: serde_json::Value) {
237 self.workspace.seed_test_user_preferences(preferences);
238 }
239
240 pub fn start(&self, org: &str, project: &str) -> Result<(), FixtureError> {
243 self.workspace.register_domain_session(SessionSlot::lead(org, project), None);
244 Ok(())
245 }
246
247 pub fn add_worker(&self, org: &str, project: &str, label: &str) -> Result<(), FixtureError> {
251 let key = self.project_key(project)?;
252 self.workspace.seed_test_worker_row(&key, label);
253 self.workspace.insert_live_worker(
254 &key,
255 forge_workspace::WorkerEntry {
256 label: label.to_owned(),
257 charter: format!("charter for {label}"),
258 slot: SessionSlot::worker(org, project, label),
259 session_id: None,
260 status: forge_primitives::WorkerLiveness::Running,
261 spawned_at: std::time::SystemTime::UNIX_EPOCH,
262 spawned_by: SessionSlot::lead(org, project),
263 needs_tag: false,
264 is_git_repo_at_spawn: false,
265 cwd: None,
266 diagnostic: None,
267 kick: None,
268 },
269 );
270 self.workspace.register_domain_session(SessionSlot::worker(org, project, label), None);
271 Ok(())
272 }
273
274 pub fn add_despawned_worker(&self, project: &str, label: &str) -> Result<(), FixtureError> {
281 let key = self.project_key(project)?;
282 self.workspace.seed_test_worker_row(&key, label);
283 Ok(())
284 }
285
286 pub fn add_git_worker(
293 &self,
294 org: &str,
295 project: &str,
296 label: &str,
297 ) -> Result<(), FixtureError> {
298 let key = self.project_key(project)?;
299 self.workspace.seed_test_git_worker_row(&key, label);
300 self.workspace.insert_live_worker(
301 &key,
302 forge_workspace::WorkerEntry {
303 label: label.to_owned(),
304 charter: format!("charter for {label}"),
305 slot: SessionSlot::worker(org, project, label),
306 session_id: None,
307 status: forge_primitives::WorkerLiveness::Running,
308 spawned_at: std::time::SystemTime::UNIX_EPOCH,
309 spawned_by: SessionSlot::lead(org, project),
310 needs_tag: false,
311 is_git_repo_at_spawn: true,
312 cwd: None,
313 diagnostic: None,
314 kick: None,
315 },
316 );
317 self.workspace.register_domain_session(SessionSlot::worker(org, project, label), None);
318 Ok(())
319 }
320
321 pub fn seed_transcript(
325 &self,
326 org: &str,
327 project: &str,
328 label: &str,
329 rows: &[&str],
330 ) -> Result<(), FixtureError> {
331 let slot = self.write_transcript(org, project, label, rows)?;
332 self.workspace.seed_test_running_session_id(&slot, SEEDED_SESSION);
333 Ok(())
334 }
335
336 pub fn seed_asleep_transcript(
341 &self,
342 org: &str,
343 project: &str,
344 label: &str,
345 rows: &[&str],
346 ) -> Result<(), FixtureError> {
347 let slot = self.write_transcript(org, project, label, rows)?;
348 self.workspace.seed_test_stored_session_id(&slot, SEEDED_SESSION);
349 Ok(())
350 }
351
352 fn write_transcript(
355 &self,
356 org: &str,
357 project: &str,
358 label: &str,
359 rows: &[&str],
360 ) -> Result<SessionSlot, FixtureError> {
361 let slot = if label == "lead" {
362 SessionSlot::lead(org, project)
363 } else {
364 SessionSlot::worker(org, project, label)
365 };
366 let cwd = self
367 .workspace
368 .cwd_for_session(&slot)
369 .ok_or_else(|| format!("{project} holds no session for {label}"))?;
370 let key = forge_workspace::userdata::catalog::scan::project_key_for_directory(Some(&cwd));
371 let dir = self.config_dir.join("projects").join(key);
372 std::fs::create_dir_all(&dir)?;
373 std::fs::write(dir.join(format!("{SEEDED_SESSION}.jsonl")), rows.join("\n"))?;
374 Ok(slot)
375 }
376
377 pub fn hold_conversation(
392 &self,
393 state: &TransportState,
394 org: &str,
395 project: &str,
396 label: &str,
397 ) -> Result<(), FixtureError> {
398 let slot = if label == "lead" {
399 SessionSlot::lead(org, project)
400 } else {
401 SessionSlot::worker(org, project, label)
402 };
403 let cwd = self
404 .workspace
405 .cwd_for_session(&slot)
406 .ok_or_else(|| format!("{project} holds no session for {label}"))?;
407 let read = forge_workspace::session_history(
408 self.workspace.config_dir(),
409 &self
410 .workspace
411 .running_session_id_for(&slot)
412 .ok_or_else(|| format!("{project} holds no running session for {label}"))?,
413 &cwd,
414 );
415 state.conversations.insert(
416 &slot,
417 crate::transport::conversation::Conversation::new(
418 read.messages,
419 read.compaction_count,
420 0,
421 ),
422 );
423 Ok(())
424 }
425
426 pub fn install_agent(
431 &self,
432 org: &str,
433 project: &str,
434 label: &str,
435 ) -> mpsc::UnboundedReceiver<forge_primitives::AgentCommand> {
436 let slot = if label == "lead" {
437 SessionSlot::lead(org, project)
438 } else {
439 SessionSlot::worker(org, project, label)
440 };
441 self.workspace.install_testing_stub(&slot)
442 }
443
444 pub fn park_terminal_hand_off(
447 &self,
448 slot: &SessionSlot,
449 terminal: u16,
450 ) -> Result<String, String> {
451 self.workspace.park_terminal_hand_off(slot, terminal)
452 }
453
454 pub fn answer_terminal_hand_off(&self, id: &str, slot: &SessionSlot) {
456 self.workspace.answer_terminal_hand_off(id, slot);
457 }
458
459 pub fn seed_queued_prompt(
462 &self,
463 slot: &SessionSlot,
464 uuid: &str,
465 source: forge_workspace::protocol::PromptSource,
466 text: &str,
467 ) {
468 self.workspace.seed_queued_prompt(slot, uuid, source, text);
469 }
470
471 pub fn intercept_dispatch(&self) {
474 self.workspace.enable_test_dispatch_intercept();
475 }
476
477 pub fn dispatched(&self) -> Vec<forge_workspace::Command> {
479 self.workspace.drain_test_dispatch_buffer()
480 }
481
482 pub fn fail_spawn(&self, org: &str, project: &str, label: &str, reason: &str) {
485 let slot = if label == "lead" {
486 SessionSlot::lead(org, project)
487 } else {
488 SessionSlot::worker(org, project, label)
489 };
490 self.workspace.record_spawn_failure_for_test(&slot, reason);
491 }
492
493 pub fn seed_test_pending_interaction(&self, slot: &SessionSlot, kind: PendingKind) {
496 self.workspace.seed_test_pending_interaction(slot, kind);
497 }
498
499 pub fn clear_test_pending(&self, slot: &SessionSlot) {
502 self.workspace.clear_test_pending(slot);
503 }
504
505 pub fn await_login(&self, slot: &SessionSlot) {
508 self.workspace.seed_test_awaiting_login(slot);
509 }
510
511 pub fn add_starting_worker(
514 &self,
515 org: &str,
516 project: &str,
517 label: &str,
518 ) -> Result<(), FixtureError> {
519 let key = self.project_key(project)?;
520 self.workspace.seed_test_worker_row(&key, label);
521 self.workspace.insert_live_worker(
522 &key,
523 forge_workspace::WorkerEntry {
524 label: label.to_owned(),
525 charter: format!("charter for {label}"),
526 slot: SessionSlot::worker(org, project, label),
527 session_id: None,
528 status: forge_primitives::WorkerLiveness::Spawning,
529 spawned_at: std::time::SystemTime::UNIX_EPOCH,
530 spawned_by: SessionSlot::lead(org, project),
531 needs_tag: false,
532 is_git_repo_at_spawn: false,
533 cwd: None,
534 diagnostic: None,
535 kick: None,
536 },
537 );
538 self.workspace.register_domain_session(SessionSlot::worker(org, project, label), None);
539 Ok(())
540 }
541
542 pub fn seed_test_prompt_queue(&self, slot: &SessionSlot, kind: PendingKind, count: usize) {
545 self.workspace.seed_test_prompt_queue(slot, kind, count);
546 }
547
548 pub fn advertise(
552 &self,
553 slot: &SessionSlot,
554 commands: Vec<forge_primitives::AvailableCommand>,
555 agents: Vec<forge_primitives::AvailableAgent>,
556 ) {
557 self.workspace.seed_test_advertised_catalogues(slot, commands, agents);
558 }
559
560 pub fn add_task(
563 &self,
564 org: &str,
565 project: &str,
566 subject: &str,
567 owner: &str,
568 artifact: Option<&str>,
569 ) -> Result<(), FixtureError> {
570 self.workspace.seed_test_task(Task {
571 id: subject.into(),
572 project_name: project.to_owned(),
573 subject: subject.to_owned(),
574 active_form: None,
575 detail: None,
576 status: TaskStatus::InProgress,
577 owner: Some(SessionSlot::worker(org, project, owner)),
578 parent: None,
579 waiting_on: None,
580 estimate: None,
581 rank: None,
582 verify: None,
583 attested: None,
584 reserved: None,
585 links: artifact
586 .map(|target| forge_primitives::tasks::TaskLink {
587 kind: forge_primitives::tasks::LinkKind::for_target(target),
588 label: None,
589 target: target.to_owned(),
590 state: None,
591 added_at: std::time::SystemTime::UNIX_EPOCH,
592 })
593 .into_iter()
594 .collect(),
595 attempt: 0,
596 archived_at: None,
597 created_at: std::time::SystemTime::UNIX_EPOCH,
598 updated_at: std::time::SystemTime::UNIX_EPOCH,
599 });
600 Ok(())
601 }
602
603 pub fn add_cron(&self, project: &str, prompt: &str) -> Result<(), FixtureError> {
606 self.workspace.seed_test_cron(CronEntry {
607 id: CronId::from(prompt),
608 project_name: project.to_owned(),
609 kind: CronKind::Recurring("0 9 * * *".to_owned()),
610 prompt: prompt.to_owned(),
611 description: None,
612 created_at: std::time::SystemTime::UNIX_EPOCH,
613 last_fire: None,
614 next_fire: std::time::SystemTime::now() + std::time::Duration::from_secs(86_400),
615 team_role: None,
616 wake: true,
617 });
618 Ok(())
619 }
620
621 pub fn record_session(&self, project: &str, session_id: &str) -> Result<(), FixtureError> {
625 let path = self.project_view(project)?.path.to_string_lossy().into_owned();
626 self.workspace.record_connected_session(&path, session_id, None);
627 Ok(())
628 }
629
630 pub fn seed_view_facts(&self, slot: &SessionSlot, facts: ViewFacts) {
637 let domain = self
638 .workspace
639 .domain_session_for(slot)
640 .unwrap_or_else(|| self.workspace.register_domain_session(slot.clone(), None));
641 let mut held = domain.lock();
642 held.session_id = facts.session_id;
643 held.current_model = facts.model;
644 held.observed_effort = facts.observed_effort;
645 if let Some(effort) = facts.configured_effort {
646 held.configured_effort = effort;
647 }
648 held.observed_permission_mode = facts.permission_mode;
649 held.context_usage = facts.context;
650 held.mcp_servers = facts.mcp;
651 held.process_snapshot = facts.process_snapshot;
652 held.background_tasks = facts.background_tasks;
653 held.seed_live_tasks(facts.live_tasks);
654 held.monitors = facts.monitors;
655 held.cards_snapshot = facts.cards;
656 }
657
658 fn project_view(&self, project: &str) -> Result<forge_workspace::ProjectView, FixtureError> {
659 self.workspace
660 .list_projects()
661 .into_iter()
662 .find(|view| view.name == project)
663 .ok_or_else(|| format!("{project} is not a project this fleet declared").into())
664 }
665
666 fn project_key(&self, project: &str) -> Result<ProjectKey, FixtureError> {
676 self.project_view(project).map(|view| view.key)
677 }
678}
679
680fn config(
683 config_dir: &Path,
684 orgs: &[(&str, &[&str])],
685 server: &str,
686 env: &[(&str, &str)],
687 tts_enabled: bool,
688) -> String {
689 let mut sections: Vec<String> = Vec::new();
690 if !server.is_empty() {
691 sections.push(server.to_owned());
692 }
693 for (org, projects) in orgs {
694 sections.push(format!("[[orgs]]\nname = \"{org}\"\naccounts = [\"Acct\"]\n\n"));
695 for project in *projects {
696 let path = config_dir.join(project);
697 sections.push(format!(
698 "[[orgs.projects]]\nname = \"{project}\"\npath = \"{}\"\n",
699 path.display()
700 ));
701 if !env.is_empty() {
702 sections.push("[orgs.projects.env]\n".to_owned());
703 for (key, value) in env {
704 sections.push(format!("{key} = {value:?}\n"));
705 }
706 }
707 sections.push('\n'.to_string());
708 }
709 }
710 sections.push(
711 "[[accounts]]\ndisplay_name = \"Acct\"\ntoken = \"t\"\nmodels = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n"
712 .to_owned(),
713 );
714 sections.push(format!(
719 "[dictate]\nenabled = true\nmodels_dir = \"{}\"\nstyling = \"formal\"\n",
720 config_dir.join("models").display()
721 ));
722 sections.push(format!(
726 "[tts]\nenabled = {tts_enabled}\nmodels_dir = \"{}\"\n",
727 config_dir.join("models").display()
728 ));
729 sections.concat()
730}
731
732#[cfg(test)]
733mod tests {
734 use super::Fleet;
735
736 #[test]
741 fn a_fixtures_directory_is_removed_with_the_fixture() {
742 let paths: Vec<std::path::PathBuf> = (0..3)
743 .map(|_| {
744 let fleet = Fleet::new(&[("TestOrg", &["proj"])]).expect("the fleet builds");
745 let path = fleet.config_dir.clone();
746 assert!(
747 path.exists(),
748 "the directory is there while the fixture is: {}",
749 path.display(),
750 );
751 path
752 })
753 .collect();
754
755 for path in paths {
756 assert!(
757 !path.exists(),
758 "and is removed with it, not abandoned under the temp dir: {}",
759 path.display(),
760 );
761 }
762 }
763}