Skip to main content

forge_server/surface/
roster.rs

1//! `roster()`: the projects and their sessions, plus the per-project
2//! lists the Projects pane draws.
3
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use forge_primitives::CronEntry;
8use forge_primitives::SessionSlot;
9use forge_primitives::tasks::Task;
10use forge_workspace::{ProjectKey, ProjectView, SessionChipInfo, Workspace};
11
12/// The projects and the per-project lists a view draws them from.
13pub struct Roster {
14    workspace: Arc<Workspace>,
15    /// Every project in `forge.toml`, each carrying its catalog
16    /// sessions; the same list [`Workspace::list_projects`] returns.
17    pub projects: Vec<ProjectView>,
18}
19
20impl Roster {
21    pub(super) fn collect(workspace: Arc<Workspace>) -> Self {
22        let projects = workspace.list_projects();
23        Self { workspace, projects }
24    }
25
26    /// The project `name` declares, or `None` for a name no project
27    /// declares.
28    pub fn project_named(&self, name: &str) -> Option<&ProjectView> {
29        self.projects.iter().find(|project| project.name == name)
30    }
31
32    /// The declared project NAME that owns `cwd`, matched by the
33    /// longest configured ancestor path.
34    pub fn project_name_for_path(&self, cwd: &str) -> Option<String> {
35        self.workspace.project_name_for_path(cwd)
36    }
37
38    /// The account chip `project` renders beside its row, or `None`
39    /// when no account would serve it.
40    pub fn chip_for(&self, project: &ProjectKey) -> Option<SessionChipInfo> {
41        self.workspace.session_chip_for(project)
42    }
43
44    /// Whether a spawn in `project` would find an account.
45    pub fn would_bind(&self, project: &ProjectKey) -> bool {
46        self.workspace.project_would_bind(project)
47    }
48
49    /// Whether `slot` has a workspace-side domain registered.
50    pub fn has_domain(&self, slot: &SessionSlot) -> bool {
51        self.workspace.domain_session_for(slot).is_some()
52    }
53
54    /// Whether `slot` has a live agent handle.
55    pub fn has_agent(&self, slot: &SessionSlot) -> bool {
56        self.workspace.has_agent_for(slot)
57    }
58
59    /// OS pid of `slot`'s `claude` subprocess, or `None` when the
60    /// session has no live client.
61    pub fn claude_pid(&self, slot: &SessionSlot) -> Option<u32> {
62        self.workspace.claude_pid(slot)
63    }
64
65    /// The config dir `slot`'s agent reads, or `None` when no agent is
66    /// registered for it.
67    pub fn config_dir(&self, slot: &SessionSlot) -> Option<PathBuf> {
68        self.workspace.config_dir_for(slot)
69    }
70
71    /// The directory `slot`'s agent works in: its project's root, or the
72    /// worktree a git worker was forked into.
73    ///
74    /// **A session that has not started still resolves.** A lead's directory
75    /// comes from the project's own declaration, so a declared-but-unstarted
76    /// project answers its root; `None` is a slot naming no loaded project at
77    /// all, which for a worker means no registry row either.
78    pub fn cwd_for(&self, slot: &SessionSlot) -> Option<PathBuf> {
79        self.workspace.cwd_for_session(slot).map(PathBuf::from)
80    }
81
82    /// The extra document roots `slot`'s project declares in
83    /// `forge.toml`; empty when it declares none.
84    pub fn document_roots_for(&self, slot: &SessionSlot) -> Vec<PathBuf> {
85        self.workspace.document_roots_for_session(slot)
86    }
87
88    /// The task rows declared under the project named `project`.
89    pub fn tasks_for_project(&self, project: &str) -> Vec<Task> {
90        self.workspace.tasks_for_project(project)
91    }
92
93    /// The durable cron rows declared under the project named
94    /// `project`.
95    pub fn crons_for_project(&self, project: &str) -> Vec<CronEntry> {
96        self.workspace.crons_for_project(project)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use std::path::PathBuf;
103    use std::sync::Arc;
104
105    use forge_primitives::tasks::{Task, TaskStatus};
106    use forge_primitives::{CronEntry, CronId, CronKind, SessionSlot, TaskId};
107    use forge_workspace::Workspace;
108
109    use crate::surface::ViewSurface;
110
111    fn stub_workspace() -> Arc<Workspace> {
112        let (workspace, _updates) = Workspace::testing_stub();
113        workspace
114    }
115
116    fn sample_task(id: &str, project: &str) -> Task {
117        Task {
118            id: TaskId::from(id),
119            project_name: project.to_owned(),
120            subject: format!("subject {id}"),
121            active_form: None,
122            detail: None,
123            status: TaskStatus::Pending,
124            owner: None,
125            parent: None,
126            waiting_on: None,
127            estimate: None,
128            rank: None,
129            verify: None,
130            attested: None,
131            reserved: None,
132            links: Vec::new(),
133            attempt: 0,
134            archived_at: None,
135            created_at: std::time::SystemTime::UNIX_EPOCH,
136            updated_at: std::time::SystemTime::UNIX_EPOCH,
137        }
138    }
139
140    fn workspace_with_a_bound_project() -> (tempfile::TempDir, Arc<Workspace>) {
141        let dir = tempfile::tempdir().expect("tempdir");
142        let forge_dir = dir.path().join("forge");
143        std::fs::create_dir_all(&forge_dir).expect("forge dir");
144        std::fs::write(
145            forge_dir.join("forge.toml"),
146            r#"
147[[orgs]]
148name = "Default"
149accounts = ["Stargate"]
150
151[[orgs.projects]]
152name = "forge"
153path = "~/Projects/forge"
154model = "claude-sonnet-5"
155
156[[accounts]]
157display_name = "Stargate"
158token = "t"
159models = ["claude-sonnet-5"]
160provider = "anthropic"
161"#,
162        )
163        .expect("write forge.toml");
164        let workspace =
165            Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
166        (dir, workspace)
167    }
168
169    fn sample_cron(id: &str, project: &str) -> CronEntry {
170        CronEntry {
171            id: CronId::from(id),
172            project_name: project.to_owned(),
173            kind: CronKind::Recurring("0 9 * * *".to_owned()),
174            prompt: "stand-up".to_owned(),
175            description: None,
176            created_at: std::time::SystemTime::UNIX_EPOCH,
177            last_fire: None,
178            next_fire: std::time::SystemTime::UNIX_EPOCH,
179            team_role: None,
180            wake: true,
181        }
182    }
183
184    /// Catches a roster that stops taking its project list from
185    /// `list_projects` (filtering it, reordering it, or serving a
186    /// stale walk), and a `project_name_for_path` that reimplements the
187    /// ancestor match instead of forwarding it.
188    #[test]
189    fn roster_carries_the_projects_and_resolves_cwds_as_the_workspace_does() {
190        let workspace = stub_workspace();
191        workspace.seed_test_project("forge", "/tmp/forge-roster-a");
192        workspace.seed_test_project("other", "/tmp/forge-roster-a/other");
193
194        let roster = ViewSurface::new(Arc::clone(&workspace)).roster();
195        let direct = workspace.list_projects();
196
197        let verb: Vec<&str> = roster.projects.iter().map(|p| p.name.as_str()).collect();
198        let expected: Vec<&str> = direct.iter().map(|p| p.name.as_str()).collect();
199        assert_eq!(verb, expected, "the roster must carry the projects list_projects returns");
200        for project in &roster.projects {
201            let same =
202                direct.iter().find(|p| p.name == project.name).expect("every roster project");
203            assert_eq!(project.path, same.path, "the roster must carry the project's own path");
204            assert_eq!(
205                project.sessions.len(),
206                same.sessions.len(),
207                "the roster must carry every catalog session row"
208            );
209        }
210
211        assert_eq!(
212            roster.project_named("other").map(|p| p.name.clone()),
213            Some("other".to_owned()),
214            "project_named must answer for a declared name"
215        );
216        assert!(
217            roster.project_named("absent").is_none(),
218            "project_named must answer None for an undeclared name"
219        );
220
221        // The nested cwd is owned by the longer of the two seeded paths,
222        // so a first-match or a shortest-prefix implementation disagrees.
223        let nested = "/tmp/forge-roster-a/other/nested";
224        assert_eq!(
225            roster.project_name_for_path(nested),
226            workspace.project_name_for_path(nested),
227            "the roster must resolve a cwd to the project the workspace names"
228        );
229        assert_eq!(
230            roster.project_name_for_path(nested),
231            Some("other".to_owned()),
232            "the roster must resolve a cwd to its longest owning path"
233        );
234        assert_eq!(
235            roster.project_name_for_path("/tmp/nowhere"),
236            workspace.project_name_for_path("/tmp/nowhere"),
237            "a cwd under no project must resolve to None on both paths"
238        );
239    }
240
241    /// Catches a roster read of a session's domain, agent, pid or config
242    /// dir that answers from its own bookkeeping rather than the
243    /// workspace's.
244    #[test]
245    fn roster_reads_a_sessions_domain_and_agent_as_the_workspace_does() {
246        let workspace = stub_workspace();
247        let connected = SessionSlot::lead("TestOrg", "forge");
248        let idle = SessionSlot::lead("TestOrg", "other");
249        let _stub = workspace.install_testing_stub(&connected);
250
251        let roster = ViewSurface::new(Arc::clone(&workspace)).roster();
252
253        assert!(
254            roster.has_domain(&connected),
255            "a session with an agent behind it must read as registered"
256        );
257        assert_eq!(
258            roster.has_domain(&idle),
259            workspace.domain_session_for(&idle).is_some(),
260            "an unregistered session must read as unregistered"
261        );
262        assert!(
263            roster.has_agent(&connected),
264            "a session with a live handle must read as having one"
265        );
266        assert_eq!(
267            roster.has_agent(&idle),
268            workspace.has_agent_for(&idle),
269            "a session with no handle must read as having none"
270        );
271        assert_eq!(
272            roster.claude_pid(&connected),
273            workspace.claude_pid(&connected),
274            "the roster's pid must be the workspace's own answer"
275        );
276        assert_eq!(
277            roster.config_dir(&connected),
278            workspace.config_dir_for(&connected),
279            "the roster's config dir must be the workspace's own answer"
280        );
281        assert!(
282            roster.config_dir(&connected).is_some(),
283            "an installed agent must resolve a config dir"
284        );
285    }
286
287    /// Catches a `chip_for` or `would_bind` that stops forwarding to the
288    /// walk, or that answers from a snapshot taken when the roster was
289    /// collected rather than from the core as it is now.
290    ///
291    /// The account is seeded after the roster is collected, so an
292    /// eagerly-filled roster fails the first assertion.
293    #[test]
294    fn roster_chip_and_binding_forward_the_walk() {
295        let (_dir, workspace) = workspace_with_a_bound_project();
296        let roster = ViewSurface::new(Arc::clone(&workspace)).roster();
297        let project = roster.project_named("forge").expect("configured project").key.clone();
298
299        workspace.seed_test_ready_account("Stargate");
300
301        assert!(
302            roster.would_bind(&project),
303            "a ready account declaring the project's model is what a spawn lands on"
304        );
305        assert_eq!(
306            roster.would_bind(&project),
307            workspace.project_would_bind(&project),
308            "the binding answer must be the workspace's own"
309        );
310        assert_eq!(
311            roster.chip_for(&project).map(|chip| chip.account_name),
312            workspace.session_chip_for(&project).map(|chip| chip.account_name),
313            "the chip must be the workspace's own answer"
314        );
315        assert_eq!(
316            roster.chip_for(&project).map(|chip| chip.account_name),
317            Some("Stargate".to_owned()),
318            "the chip must name the account the walk would pick"
319        );
320    }
321
322    /// Catches a cwd read that reimplements the resolution rather than
323    /// forwarding it, and one that answers the project root for a git
324    /// worker whose tree is a worktree.
325    #[test]
326    fn cwd_for_resolves_a_workers_worktree_under_its_project() {
327        let workspace = stub_workspace();
328        workspace.seed_test_project("forge", "/tmp/forge-roster-cwd");
329        let surface = ViewSurface::new(Arc::clone(&workspace));
330        let roster = surface.roster();
331        let project = roster.project_named("forge").expect("seeded project").key.clone();
332
333        assert_eq!(
334            roster.cwd_for(&SessionSlot::from_str_for_test("forge-nobody")),
335            None,
336            "a slot with no session and no worker row has no working tree",
337        );
338
339        workspace.insert_live_worker(
340            &project,
341            forge_workspace::WorkerEntry {
342                label: "probe-cwd".to_owned(),
343                charter: "charter".to_owned(),
344                slot: SessionSlot::worker("TestOrg", "forge", "probe-cwd"),
345                session_id: None,
346                status: forge_primitives::WorkerLiveness::Running,
347                spawned_at: std::time::SystemTime::UNIX_EPOCH,
348                spawned_by: SessionSlot::lead("TestOrg", "forge"),
349                needs_tag: false,
350                is_git_repo_at_spawn: true,
351                cwd: None,
352                diagnostic: None,
353                kick: None,
354            },
355        );
356
357        assert_eq!(
358            roster.cwd_for(&SessionSlot::worker("TestOrg", "forge", "probe-cwd")),
359            Some(PathBuf::from("/tmp/forge-roster-cwd/.claude/worktrees/probe-cwd")),
360            "a git worker's tree is its worktree, so the row's branch is its own",
361        );
362    }
363
364    /// Catches task or cron rows that come from anywhere but the
365    /// project's own store slice.
366    #[test]
367    fn roster_rows_come_from_the_project_stores_as_the_workspace_reports_them() {
368        let workspace = stub_workspace();
369        workspace.seed_test_project("forge", "/tmp/forge-roster-b");
370        workspace.seed_test_task(sample_task("t-1", "forge"));
371        workspace.seed_test_cron(sample_cron("c-1", "forge"));
372
373        let roster = ViewSurface::new(Arc::clone(&workspace)).roster();
374
375        assert_eq!(
376            roster.tasks_for_project("forge"),
377            workspace.tasks_for_project("forge"),
378            "the roster's task rows must be the project's own"
379        );
380        assert_eq!(roster.tasks_for_project("forge").len(), 1, "the seeded task must be listed");
381        assert!(
382            roster.tasks_for_project("other").is_empty(),
383            "another project must see none of its tasks"
384        );
385        assert_eq!(
386            roster.crons_for_project("forge"),
387            workspace.crons_for_project("forge"),
388            "the roster's cron rows must be the project's own"
389        );
390        assert_eq!(roster.crons_for_project("forge").len(), 1, "the seeded cron must be listed");
391
392        let project = roster.project_named("forge").expect("seeded project").key.clone();
393        assert_eq!(
394            roster.chip_for(&project),
395            workspace.session_chip_for(&project),
396            "the chip must be the workspace's own answer"
397        );
398        assert_eq!(
399            roster.would_bind(&project),
400            workspace.project_would_bind(&project),
401            "the binding answer must be the workspace's own"
402        );
403    }
404}