1use 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
12pub struct Roster {
14 workspace: Arc<Workspace>,
15 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 pub fn project_named(&self, name: &str) -> Option<&ProjectView> {
29 self.projects.iter().find(|project| project.name == name)
30 }
31
32 pub fn project_name_for_path(&self, cwd: &str) -> Option<String> {
35 self.workspace.project_name_for_path(cwd)
36 }
37
38 pub fn chip_for(&self, project: &ProjectKey) -> Option<SessionChipInfo> {
41 self.workspace.session_chip_for(project)
42 }
43
44 pub fn would_bind(&self, project: &ProjectKey) -> bool {
46 self.workspace.project_would_bind(project)
47 }
48
49 pub fn has_domain(&self, slot: &SessionSlot) -> bool {
51 self.workspace.domain_session_for(slot).is_some()
52 }
53
54 pub fn has_agent(&self, slot: &SessionSlot) -> bool {
56 self.workspace.has_agent_for(slot)
57 }
58
59 pub fn claude_pid(&self, slot: &SessionSlot) -> Option<u32> {
62 self.workspace.claude_pid(slot)
63 }
64
65 pub fn config_dir(&self, slot: &SessionSlot) -> Option<PathBuf> {
68 self.workspace.config_dir_for(slot)
69 }
70
71 pub fn cwd_for(&self, slot: &SessionSlot) -> Option<PathBuf> {
79 self.workspace.cwd_for_session(slot).map(PathBuf::from)
80 }
81
82 pub fn document_roots_for(&self, slot: &SessionSlot) -> Vec<PathBuf> {
85 self.workspace.document_roots_for_session(slot)
86 }
87
88 pub fn tasks_for_project(&self, project: &str) -> Vec<Task> {
90 self.workspace.tasks_for_project(project)
91 }
92
93 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 #[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 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 #[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 #[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 #[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 #[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}