Skip to main content

forge_server/
testing.rs

1//! View fixtures for a sibling crate's tests.
2//!
3//! A `ViewSurface` only ever comes off an `Arc<Workspace>`, and the
4//! constructors that build one without a real machine are behind
5//! `forge-workspace`'s own test features. A crate testing a view would
6//! otherwise have to name that crate in its manifest to get one; this
7//! module is the way round, kept behind its own `testing` feature so a
8//! production build never sees it.
9//!
10//! Every constructor here hands back its failure rather than panicking, so
11//! a caller that wants a panic is the one that asks for it.
12
13use 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
26/// What a fixture hands back when it cannot build what was asked for.
27pub type FixtureError = Box<dyn std::error::Error + Send + Sync>;
28
29/// The facts a session holds that no update stream carries, for a view's
30/// fixture to seed. Every field is what the session reports: leaving one
31/// out is a session that has not reported it.
32#[derive(Default)]
33pub struct ViewFacts {
34    pub session_id: Option<forge_primitives::SessionId>,
35    pub model: Option<forge_primitives::CurrentModel>,
36    /// The effort a hook observed, which a session reports only once it
37    /// has used a tool.
38    pub observed_effort: Option<forge_primitives::EffortLevel>,
39    /// The effort the launch stamped, which stands until a hook reports.
40    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    /// The CLI's background registry, as `background_tasks_changed` would
46    /// have left it.
47    pub background_tasks: Vec<forge_workspace::BackgroundTask>,
48    /// The announced holds the registry does not name - a long foreground
49    /// call the CLI is tracking - which no wire in a fixture can produce.
50    pub live_tasks: Vec<forge_workspace::BackgroundTask>,
51    pub monitors: Vec<forge_primitives::MonitorRecord>,
52    /// The instances the session task's fold has folded. Seeded by hand
53    /// because the list a record reads is the one the session task pushes,
54    /// and a fixture has no task to push it.
55    pub cards: Vec<forge_primitives::runtime::SubagentCard>,
56}
57
58/// A view surface over a stub workspace, plus the seeding a test needs to
59/// put a roster in front of it.
60pub struct Fleet {
61    surface: Arc<ViewSurface>,
62    workspace: Arc<Workspace>,
63    /// Where the workspace's store and the CLI's transcripts live, which a
64    /// transcript fixture has to write into.
65    config_dir: std::path::PathBuf,
66    /// Declared last, so the directory goes after the store this fixture opened
67    /// under it has closed - unless a surface is cloned into something that
68    /// outlives the fleet, which unlinks the file while it is still open.
69    owned_dir: Option<tempfile::TempDir>,
70}
71
72/// The session id a seeded transcript belongs to. One per fleet is enough:
73/// a test that needs two reads two slots against their own files.
74const SEEDED_SESSION: &str = "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45";
75
76impl Fleet {
77    /// A fleet over a config directory of its own, removed with the fleet.
78    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    /// A fleet whose `forge.toml` declares one project per name under each
86    /// `(org, projects)` entry, in the order given. `config_dir` has to
87    /// outlive the fleet - the workspace's store lives under it, and each
88    /// project's path is a directory of its own under it.
89    ///
90    /// **The project directories are not created.** A fixture that makes one
91    /// itself moves the key `project_key` answers, because that derivation
92    /// canonicalises a path that now resolves - and everything the fixture
93    /// registered before it appeared is registered under a key nothing looks
94    /// up again. Read `project_key` before creating one.
95    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    /// [`Self::in_dir`] with a `[server]` block of the caller's own, for a
100    /// test that pins what a key DOES rather than that it parses.
101    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    /// [`Self::in_dir_with_server`] with each project's own
110    /// `[orgs.projects.env]`, which is the identity a scan's `git` and `gh`
111    /// children carry - a fixture that pins a lookup needs its own `PATH`.
112    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    /// A fleet with `[tts] enabled`, for a test that drives the voice
125    /// service: the fixture's own section stays off so no other test
126    /// synthesises.
127    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        // `new_for_test` opens the store under `config_dir`'s own
139        // app-support base, so a fixture gets a durable layer without
140        // opening a second handle on the same file.
141        let workspace = Arc::new(Workspace::new_for_test(config_dir.to_owned())?);
142        // The settings frame the socket pushes on attach carries a build;
143        // the fixture names a stand-in so every connection is handed one.
144        workspace.set_settings_version("test-build");
145        // An empty document, so no fixture reads the machine's own
146        // `$HOME/.claude.json` for a preference.
147        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    /// The surface, which keeps the workspace alive on its own.
157    pub fn surface(&self) -> Arc<ViewSurface> {
158        Arc::clone(&self.surface)
159    }
160
161    /// The workspace under the fixture, for a test that has to build
162    /// something FROM it - the transport a binary builds, above all, whose
163    /// browser relay has to be this workspace's own.
164    pub fn workspace(&self) -> Arc<Workspace> {
165        Arc::clone(&self.workspace)
166    }
167
168    /// Arm dictation for `key`: an engine over a temp models directory
169    /// whose weights are absent, and the seat marked live, so a test can
170    /// drive a take without hardware, a model or a CLI. The returned
171    /// directory has to outlive the engine.
172    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    /// Push one update onto the core's stream, so a test can watch a view
180    /// react to it without driving a whole session.
181    pub fn emit(&self, update: SessionUpdate) {
182        self.workspace.emit_for_test(update);
183    }
184
185    /// Give a project a task, so a fixture can read one back rather than
186    /// answering with an empty list a reader cannot tell from a missing field.
187    pub fn seed_task(&self, task: forge_primitives::tasks::Task) {
188        self.workspace.seed_test_task(task);
189    }
190
191    /// [`Self::emit`], reporting whether a subscriber was there to take it.
192    ///
193    /// `false` once nothing is listening, which is how a test tells a
194    /// subscription still attached from one that went with its socket.
195    pub fn emit_and_report(&self, update: SessionUpdate) -> bool {
196        self.workspace.emit_for_test_reported(update)
197    }
198
199    /// Park a Slack draft on `key` as a blocked Slack call would, so a
200    /// transport test can answer it from the socket without a session agent
201    /// driving the tool. The draft's own id is the one to answer.
202    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    /// How many subscribers are attached right now, the transport's own fold
207    /// included. A test that watches a socket's subscription arrive and leave
208    /// reads this rather than asking whether an emit landed: the fold makes
209    /// every emit land, whether or not a client is there.
210    pub fn subscriber_count(&self) -> usize {
211        self.workspace.test_subscriber_count()
212    }
213
214    /// How many subscribers could answer a prompt right now. The role decides
215    /// whether the core parks a turn on a reply rather than resolving it
216    /// `Cancelled`, and nothing on the wire reports it - so a test that watches
217    /// a client declare `answering` reads this.
218    pub fn answering_count(&self) -> usize {
219        self.workspace.test_answering_count()
220    }
221
222    /// Hold a claude version snapshot, so a view test renders a version
223    /// line without a real `claude --version` and npm probe.
224    pub fn set_cli_version(&self, installed: Option<&str>, latest: Option<&str>) {
225        self.workspace.seed_test_cli_version(installed, latest);
226    }
227
228    /// Hold the core's last fatal, as the boot spawn's failure records it, so
229    /// a test can read the home's encoding without a dying spawn.
230    pub fn set_fatal_error(&self, error: forge_primitives::error::AppError) {
231        self.workspace.seed_test_fatal_error(error);
232    }
233
234    /// Hold the CLI's per-user preferences document, so a view test reads a
235    /// preference of its own rather than the machine's.
236    pub fn set_user_preferences(&self, preferences: serde_json::Value) {
237        self.workspace.seed_test_user_preferences(preferences);
238    }
239
240    /// Give `project` a live lead session, registering the domain a spawn
241    /// would.
242    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    /// Put `label` under `project` as a worker that has run before: a
248    /// persisted row, so it is offered, plus a live entry, so it is not
249    /// asleep.
250    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    /// A worker label with no live registry entry: the row a despawned worker
275    /// leaves behind, which is drawn until the label is despawned.
276    ///
277    /// The project's directory has to exist for the label to be offered at
278    /// all - `worker_row_can_start` asks whether the tree it would start in
279    /// is there.
280    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    /// [`Self::add_worker`] for a worker spawned in a git repo, whose tree
287    /// is the worktree under the project rather than the project root.
288    ///
289    /// The two reads differ, which is the whole point: a fixture that puts
290    /// every worker in the project's own tree cannot tell a per-seat read
291    /// from a per-project one.
292    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    /// Write a session's transcript, where the CLI would have left it, and
322    /// register `slot` as the session that owns it. `rows` are the JSONL
323    /// lines themselves, so a fixture writes the wire shapes it means.
324    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    /// [`Self::seed_transcript`] for a seat nothing is running behind: the id
337    /// lives in the store's own row - which is what a wake resumes, and the
338    /// other place a transcript read is looked up by - and no live entry
339    /// names it.
340    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    /// The row file at the path the CLI would have written it to, with the
353    /// seat resolved; which of the two id sources names it is the caller's.
354    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    /// Hold the seat's conversation on `state`, read from the transcript a
378    /// fixture just seeded.
379    ///
380    /// **This is the window a `Connected` carries**, put there by hand: a
381    /// fixture that wants a seat to answer with a conversation the transport
382    /// is HANDED holds it this way, rather than leaning on the transport's
383    /// own read of the file - which is a sleeping seat's path, and firing it
384    /// here would make every held-window test pass through the other source.
385    /// So it reads the transcript RAW - the shape a `Connected` carries and a
386    /// resume answers with - and lets the conversation convert it. Not
387    /// through `ViewSurface::conversation`, which converts the task notices
388    /// on the way out: a fixture built on the converted shape would exercise
389    /// something production never seeds from, so a defect in the seed's own
390    /// conversion could not be seen here.
391    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    /// Give `slot` a live agent, which is what makes a view treat the seat as
427    /// running rather than as one nothing is behind. A stub: nothing answers
428    /// the commands it is sent, and a caller that holds the receiver it hands
429    /// back reads what was asked.
430    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    /// Park a terminal hand-off on `slot` as a blocked `terminal__hand_off`
445    /// call would, so a socket test can type through the hand-off's door.
446    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    /// Answer that parked hand-off with Done, as the dock's button would.
455    pub fn answer_terminal_hand_off(&self, id: &str, slot: &SessionSlot) {
456        self.workspace.answer_terminal_hand_off(id, slot);
457    }
458
459    /// Put a prompt in a seat's queue exactly as a dispatch leaves it: the row
460    /// a client attaching mid-queue reads, with no dispatch to drive.
461    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    /// Catch every command the core is dispatched, so a test can assert
472    /// what a view asked for without driving a session.
473    pub fn intercept_dispatch(&self) {
474        self.workspace.enable_test_dispatch_intercept();
475    }
476
477    /// The commands caught since the last call.
478    pub fn dispatched(&self) -> Vec<forge_workspace::Command> {
479        self.workspace.drain_test_dispatch_buffer()
480    }
481
482    /// Give `slot` the core's own record of a spawn that failed, as the
483    /// connection-failure path leaves one.
484    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    /// Hold `slot` on a pending interaction, the way a session that has
494    /// asked a person for something reads.
495    pub fn seed_test_pending_interaction(&self, slot: &SessionSlot, kind: PendingKind) {
496        self.workspace.seed_test_pending_interaction(slot, kind);
497    }
498
499    /// Let go of everything parked on `slot`, which is the half of answering
500    /// that is the core's.
501    pub fn clear_test_pending(&self, slot: &SessionSlot) {
502        self.workspace.clear_test_pending(slot);
503    }
504
505    /// Hold `slot` waiting to be let in, the way a session that cannot
506    /// authenticate reads.
507    pub fn await_login(&self, slot: &SessionSlot) {
508        self.workspace.seed_test_awaiting_login(slot);
509    }
510
511    /// Put `label` under `project` as a worker whose spawn has not
512    /// connected yet, so its row reads as starting rather than asleep.
513    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    /// Hold `slot` on a queue of `count` unanswered prompts, so a view
543    /// test can render a depth a session holding several reads.
544    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    /// Advertise `commands` and `agents` for `slot`, the way the CLI's
549    /// init frame leaves them: what the composer's `/` and `&` triggers
550    /// read.
551    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    /// Declare a task under `project`, held by the session labelled
561    /// `owner`, with `artifact` as what it produced.
562    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    /// Declare a durable cron under `project`, firing a day out so a view
604    /// renders it as a schedule rather than as an overdue one.
605    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    /// Record a transcript row for `project`, as a session that ran and
622    /// ended would leave behind: a project whose session is gone but whose
623    /// history is not.
624    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    /// Hold `facts` on `slot`'s session, as the events that carry them
631    /// would: the header's four, the MCP snapshot, the process walk, the
632    /// monitor set and the sub-agent attribution.
633    ///
634    /// A field left out is a fact the session has not reported, which is
635    /// its own state rather than an empty one.
636    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    /// The registry key for `project`.
667    ///
668    /// **The key is derived from the project's PATH, and the derivation
669    /// canonicalises it**, so the same project answers a different key before
670    /// and after its directory exists. `in_dir` writes the config without
671    /// creating the project directories, so a fixture that creates one itself
672    /// gets a key that moves under it: anything registered before it appeared
673    /// is registered under a key nothing looks up again, and the failure is
674    /// silent - an empty worker list rather than an error.
675    fn project_key(&self, project: &str) -> Result<ProjectKey, FixtureError> {
676        self.project_view(project).map(|view| view.key)
677    }
678}
679
680/// The `forge.toml` a fleet is built from: one `[[orgs]]` per entry, each
681/// project under its own directory so two projects never share a path.
682fn 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    // Dictation on, so the composer draws the control that starts a take:
715    // an install with it off has no way in at all. The styling is NOT the
716    // crate's default, so a greeting that carried the default instead of the
717    // config's value fails the test that reads it back.
718    sections.push(format!(
719        "[dictate]\nenabled = true\nmodels_dir = \"{}\"\nstyling = \"formal\"\n",
720        config_dir.join("models").display()
721    ));
722    // The speaking role's own directory, so a fixture pins a path under the
723    // fixture rather than this machine's cache dir. The section stays off
724    // unless a test asks for it: nothing else in a fleet test synthesises.
725    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    /// A fixture's directory goes with the fixture.
737    ///
738    /// Asserted across three, because one that abandons only its first
739    /// directory would pass with one.
740    #[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}