Skip to main content

forge_workspace/mcp/
caller_context.rs

1//! Shared "who's calling, and what's their project context?" helper
2//! consumed by both the cross-project and the in-project facades.
3//!
4//! Before this helper the two facades each carried their own walk over
5//! `list_projects()` and disagreed: one required the caller to be the
6//! project's lead session, the other walked both `live_workers` and
7//! the catalog. The stricter walk was the buggy one (#298 Cause 1) -
8//! a worker could not resolve its own identity. This helper
9//! consolidates the lookup so both facades see the same answer.
10
11use std::path::PathBuf;
12
13use crate::SessionSlot;
14use crate::target::ProjectKey;
15use crate::views::ProjectView;
16use crate::workspace::Workspace;
17
18/// Resolved context for an incoming MCP caller.
19#[derive(Debug)]
20pub(crate) struct CallerContext {
21    /// The project the caller belongs to.
22    pub project_key: ProjectKey,
23    /// Human-readable project metadata mirrored from [`ProjectView`].
24    pub project_name: String,
25    pub project_org: String,
26    pub project_path: PathBuf,
27    /// The project's lead slot. Always known: a project's lead is named
28    /// by the triple its spawn stated, not by whichever catalog row
29    /// currently happens to look like one.
30    pub lead: SessionSlot,
31    /// Whether an agent is pooled for that lead slot right now.
32    pub lead_running: bool,
33    /// True when `caller` itself IS the project's lead session.
34    pub is_lead: bool,
35    /// The caller's worker label when it is a live worker, else `None`
36    /// (a lead or other session). Stamps + scopes a caller's crons.
37    pub worker_label: Option<String>,
38}
39
40/// Resolve a caller's project context: the project the caller's slot
41/// names, when `list_projects` still declares it. A caller whose slot
42/// names no declared project resolves to nothing (a project dropped
43/// from `forge.toml` since the session spawned).
44pub(crate) fn caller_context(ws: &Workspace, caller: &SessionSlot) -> Option<CallerContext> {
45    ws.list_projects().into_iter().find_map(|view| caller_context_in_view(ws, &view, caller))
46}
47
48/// Per-view resolution. File-private so the test mod can exercise it
49/// against hand-constructed [`ProjectView`]s without going through
50/// the catalog-driven [`Workspace::list_projects`] path.
51fn caller_context_in_view(
52    ws: &Workspace,
53    view: &ProjectView,
54    caller: &SessionSlot,
55) -> Option<CallerContext> {
56    if caller.org() != view.org || caller.project() != view.name {
57        return None;
58    }
59    let live = ws.list_live_workers(&view.key);
60    let worker_label = live.iter().find(|w| w.slot == *caller).map(|w| w.label.clone());
61    let is_lead = worker_label.is_none() && caller.is_lead();
62    let lead = SessionSlot::lead(&view.org, &view.name);
63    let lead_running = ws.session_is_pooled(&lead);
64    Some(CallerContext {
65        project_key: view.key.clone(),
66        project_name: view.name.clone(),
67        project_org: view.org.clone(),
68        project_path: view.path.clone(),
69        lead,
70        lead_running,
71        is_lead,
72        worker_label,
73    })
74}
75
76#[cfg(all(test, feature = "test-helpers"))]
77mod tests {
78    use super::*;
79    use crate::WorkerEntry;
80    use forge_primitives::WorkerLiveness;
81    use std::time::SystemTime;
82
83    fn worker_entry(slot: SessionSlot) -> WorkerEntry {
84        WorkerEntry {
85            label: "reviewer".into(),
86            charter: "review the diff".into(),
87            slot,
88            session_id: None,
89            status: WorkerLiveness::Running,
90            spawned_at: SystemTime::UNIX_EPOCH,
91            spawned_by: SessionSlot::lead("me", "myproj"),
92            needs_tag: false,
93            is_git_repo_at_spawn: false,
94            cwd: None,
95            diagnostic: None,
96            kick: None,
97        }
98    }
99
100    fn fixture() -> (std::sync::Arc<Workspace>, ProjectView, SessionSlot, SessionSlot) {
101        let (ws, _rx) = Workspace::testing_stub();
102        let key = ProjectKey::new("myproj".to_owned());
103        let lead = SessionSlot::lead("me", "myproj");
104        let worker = SessionSlot::worker("me", "myproj", "reviewer");
105        let view = ProjectView::new_for_test_with_org(
106            key.clone(),
107            "myproj",
108            "/tmp/myproj",
109            "me",
110            Vec::new(),
111            Vec::new(),
112            vec![],
113        );
114        ws.insert_live_worker(&key, worker_entry(worker.clone()));
115        (ws, view, lead, worker)
116    }
117
118    #[test]
119    fn caller_context_resolves_worker_as_non_lead() {
120        let (ws, view, lead, worker) = fixture();
121        let cx = caller_context_in_view(&ws, &view, &worker)
122            .expect("worker caller resolves to its project");
123        assert_eq!(cx.project_name, "myproj");
124        assert_eq!(cx.project_org, "me");
125        assert_eq!(cx.project_key.as_str(), "myproj");
126        assert!(!cx.is_lead, "worker is not the lead");
127        assert_eq!(cx.worker_label.as_deref(), Some("reviewer"), "a live worker carries its label");
128        assert_eq!(cx.lead, lead, "the project's lead slot is named by the triple");
129        assert!(!cx.lead_running, "no lead is pooled in this fixture");
130    }
131
132    #[test]
133    fn caller_context_resolves_lead_as_lead() {
134        let (ws, view, lead, _) = fixture();
135        let cx =
136            caller_context_in_view(&ws, &view, &lead).expect("lead caller resolves to its project");
137        assert!(cx.is_lead, "lead caller flagged as lead");
138        assert_eq!(cx.worker_label, None, "a lead has no worker label");
139        assert_eq!(cx.lead, lead);
140    }
141
142    /// A caller whose slot names another project (or none) is not a
143    /// member of this one, so it resolves to nothing.
144    #[test]
145    fn caller_context_returns_none_for_an_unrelated_caller() {
146        let (ws, view, _, _) = fixture();
147        let elsewhere = SessionSlot::worker("someone-else", "otherproj", "reviewer");
148        assert!(
149            caller_context_in_view(&ws, &view, &elsewhere).is_none(),
150            "a caller under another project must return None",
151        );
152    }
153
154    /// The lead is named by the project's own triple, so the answer does
155    /// not depend on which catalog rows happen to be on disk: a project
156    /// with no lead transcript still has a lead slot.
157    #[test]
158    fn caller_context_names_the_lead_slot_with_no_catalog_rows() {
159        let (ws, _rx) = Workspace::testing_stub();
160        let key = ProjectKey::new("myproj".to_owned());
161        let worker = SessionSlot::worker("me", "myproj", "reviewer");
162        ws.insert_live_worker(&key, worker_entry(worker.clone()));
163        let view = ProjectView::new_for_test_with_org(
164            key,
165            "myproj",
166            "/tmp/myproj",
167            "me",
168            Vec::new(),
169            Vec::new(),
170            vec![],
171        );
172
173        let cx = caller_context_in_view(&ws, &view, &worker).expect("worker resolves");
174        assert_eq!(cx.lead, SessionSlot::lead("me", "myproj"));
175    }
176}