Skip to main content

forge_workspace/mcp/review/
facade.rs

1//! The narrow workspace surface the review-conversation tools call, plus
2//! the production impl on [`Workspace`] and a mock for unit tests.
3//!
4//! Mirrors [`crate::mcp::peers::facade`]: the four `Tool` impls hold an
5//! `Arc<dyn ReviewFacade>` so tests drive them with a
6//! `MockReviewFacade` instead of a live `Workspace`. [`resolve_scope`]
7//! maps the caller to its `(project, branch)` review scope - project via
8//! the shared [`crate::mcp::caller_context`], branch via a git query on
9//! the caller's cwd - matching the key the `/diff` overlay persists under.
10//!
11//! [`resolve_scope`]: ReviewFacade::resolve_scope
12
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Weak};
15
16use async_trait::async_trait;
17use forge_primitives::git_diff::RepoGate;
18
19use crate::SessionSlot;
20use crate::mcp::caller_context::CallerContext;
21use crate::mcp::review::{CommentAnchorView, ReviewDetail, ReviewSummary};
22use crate::workspace::Workspace;
23
24/// The caller's resolved review context. `(project, branch)` is the store
25/// key both the `/diff` overlay and these tools address; `author_label` is
26/// how a `review__reply` from this caller is attributed in the thread;
27/// `caller` is the session whose turn buffer accumulates the activity so
28/// the reviewer gets one batched notice at turn end.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct ReviewScope {
31    pub project: String,
32    pub branch: String,
33    pub author_label: String,
34    pub caller: SessionSlot,
35}
36
37/// Which step of [`ReviewFacade::resolve_scope`]'s chain failed. One
38/// variant per step so the caller-facing [`Self::message`] states only
39/// what was observed: the prior single string asserted a detached HEAD
40/// for every failure, and workers chased their own HEAD over a step that
41/// had failed earlier.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub enum ScopeError {
44    /// The workspace is gone (forge shutting down mid-call).
45    WorkspaceGone,
46    /// The caller's session isn't registered under any loaded project.
47    UnknownCaller,
48    /// The caller's project resolved, but the workspace holds no cwd for
49    /// the session.
50    SessionCwdUnknown,
51    /// The checkout forge derived for this session isn't on disk.
52    ScanDirMissing { scan_cwd: PathBuf },
53    /// The dir is there but git reported no branch for it. `git
54    /// rev-parse` exits 128 both for a non-work-tree and for its own
55    /// failures; the scanner separates them with a repo-existence
56    /// probe, so `gate` distinguishes a genuine non-repo
57    /// (`NotARepo`) from a sick scanner (`ScannerFailed`) rather than
58    /// just recording that git said nothing usable.
59    NoBranchFromGit { scan_cwd: PathBuf, gate: RepoGate },
60    /// The inspected checkout is a repo, on a detached HEAD.
61    DetachedHead { scan_cwd: PathBuf },
62}
63
64impl ScopeError {
65    /// The failing step. Filter the WARN by field, not by bare name -
66    /// `grep '"event_name":"review_scope_unresolved"'`; the plain name
67    /// also matches the log's record of the command that greps for it.
68    fn step(&self) -> &'static str {
69        match self {
70            Self::WorkspaceGone => "workspace_upgrade",
71            Self::UnknownCaller => "caller_context",
72            Self::SessionCwdUnknown => "session_cwd",
73            Self::ScanDirMissing { .. } => "scan_dir",
74            Self::NoBranchFromGit { .. } => "git_branch",
75            Self::DetachedHead { .. } => "git_head",
76        }
77    }
78
79    /// The checkout the git step inspected, for the steps that got that
80    /// far.
81    fn scan_cwd(&self) -> Option<&Path> {
82        match self {
83            Self::WorkspaceGone | Self::UnknownCaller | Self::SessionCwdUnknown => None,
84            Self::ScanDirMissing { scan_cwd }
85            | Self::NoBranchFromGit { scan_cwd, .. }
86            | Self::DetachedHead { scan_cwd } => Some(scan_cwd),
87        }
88    }
89
90    /// What git reported at the branch read, for the WARN line. `None`
91    /// for the steps that never got to git.
92    fn gate(&self) -> Option<RepoGate> {
93        match self {
94            Self::NoBranchFromGit { gate, .. } => Some(*gate),
95            Self::DetachedHead { .. } => Some(RepoGate::InRepo),
96            _ => None,
97        }
98    }
99
100    /// What the tool tells the caller. Each states the observation and
101    /// stops; only [`Self::DetachedHead`] mentions a detached HEAD, and
102    /// every path-bearing variant names the checkout it inspected.
103    pub fn message(&self) -> String {
104        match self {
105            Self::WorkspaceGone => {
106                "forge is shutting down, so your review scope could not be resolved.".to_owned()
107            }
108            Self::UnknownCaller => "your session is not registered under any forge project, so \
109                 there is no project to key reviews by."
110                .to_owned(),
111            Self::SessionCwdUnknown => "forge has no working directory recorded for your session, \
112                 so it cannot tell which checkout you are in."
113                .to_owned(),
114            Self::ScanDirMissing { scan_cwd } => {
115                format!("the checkout forge has you in, {}, is not on disk.", scan_cwd.display())
116            }
117            Self::NoBranchFromGit { scan_cwd, .. } => format!(
118                "git reported no branch for {} - it is either not a work tree or the git call \
119                 failed; forge's log carries git's own error.",
120                scan_cwd.display(),
121            ),
122            Self::DetachedHead { scan_cwd } => format!(
123                "the checkout at {} is on a detached HEAD, so it has no branch name to key \
124                 reviews by.",
125                scan_cwd.display(),
126            ),
127        }
128    }
129}
130
131/// Narrow surface the review-conversation tools depend on. `resolve_scope`
132/// is async (it shells out to git for the branch); the store ops are
133/// synchronous over the resolved scope.
134#[async_trait]
135pub trait ReviewFacade: Send + Sync {
136    /// Resolve the caller to its review scope, or the step that failed.
137    /// `scope` overrides the branch: an explicit scope (a document's, from
138    /// a hand-off) needs no checkout, so the git step is skipped and only
139    /// the caller's project still has to resolve.
140    async fn resolve_scope(
141        &self,
142        caller: &SessionSlot,
143        scope: Option<&str>,
144    ) -> Result<ReviewScope, ScopeError>;
145
146    /// `review__list` rows for the scope's branch, newest review first.
147    fn list(&self, scope: &ReviewScope) -> Result<Vec<ReviewSummary>, String>;
148
149    /// Branches in the scope's project that hold submitted reviews. Tells
150    /// [`Self::list`]'s branch-scoped answer apart from the project's
151    /// whole set, on an empty result and a populated one alike.
152    fn branches_with_reviews(&self, scope: &ReviewScope) -> Vec<String>;
153
154    /// `review__get` detail for one review, or `None` when it isn't on the
155    /// scope's branch.
156    fn get(&self, scope: &ReviewScope, review_id: &str) -> Result<Option<ReviewDetail>, String>;
157
158    /// Append a reply to `comment_id`; returns the comment's anchor and
159    /// the status after the append. `Err` when `comment_id` isn't in this
160    /// scope.
161    fn reply(
162        &self,
163        scope: &ReviewScope,
164        comment_id: &str,
165        text: &str,
166        at: &str,
167    ) -> Result<CommentAnchorView, String>;
168
169    /// Mark `comment_id` Resolved; returns the comment's anchor and its
170    /// new status. `Err` when it isn't in this scope.
171    fn resolve(&self, scope: &ReviewScope, comment_id: &str) -> Result<CommentAnchorView, String>;
172}
173
174/// Production impl over a `Weak<Workspace>` (the same cycle-breaking shape
175/// [`crate::mcp::peers::facade::ProdWorkspaceFacade`] uses).
176pub struct ProdReviewFacade(pub Weak<Workspace>);
177
178impl ProdReviewFacade {
179    /// Construct from a strong reference, downgrading immediately.
180    pub fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn ReviewFacade> {
181        Arc::new(Self(Arc::downgrade(workspace)))
182    }
183}
184
185/// The one WARN a failed scope resolution emits, carrying every field
186/// that discriminates the failing step. This is the line to reach for
187/// when a caller reports it cannot read its reviews.
188fn warn_unresolved(
189    caller: &SessionSlot,
190    cx: Option<&CallerContext>,
191    cwd_raw: Option<&str>,
192    error: ScopeError,
193) -> ScopeError {
194    tracing::warn!(
195        target: "forge_workspace::review",
196        event_name = "review_scope_unresolved",
197        step = error.step(),
198        caller = %caller.display(),
199        project = cx.map_or("-", |c| c.project_name.as_str()),
200        worker_label = cx.and_then(|c| c.worker_label.as_deref()).unwrap_or("-"),
201        cwd_raw = cwd_raw.unwrap_or("-"),
202        scan_cwd = %error.scan_cwd().unwrap_or(Path::new("-")).display(),
203        gate = ?error.gate(),
204        message = %error.message(),
205    );
206    error
207}
208
209#[async_trait]
210impl ReviewFacade for ProdReviewFacade {
211    async fn resolve_scope(
212        &self,
213        caller: &SessionSlot,
214        scope: Option<&str>,
215    ) -> Result<ReviewScope, ScopeError> {
216        let Some(ws) = self.0.upgrade() else {
217            return Err(warn_unresolved(caller, None, None, ScopeError::WorkspaceGone));
218        };
219        let Some(cx) = crate::mcp::caller_context::caller_context(&ws, caller) else {
220            return Err(warn_unresolved(caller, None, None, ScopeError::UnknownCaller));
221        };
222        let author_label = cx.worker_label.as_deref().unwrap_or("agent").to_owned();
223        // An explicit scope (a document's) is the store key itself, so the
224        // checkout never enters it: no cwd, no git query.
225        if let Some(branch) = scope {
226            return Ok(ReviewScope {
227                project: cx.project_name,
228                branch: branch.to_owned(),
229                author_label,
230                caller: caller.clone(),
231            });
232        }
233        // The branch keys the review store; resolve it from the caller's
234        // git dir the same way the /diff overlay does. A worker's git dir
235        // is its worktree, so route the raw cwd through the scan-dir
236        // adjustment before querying.
237        let Some(cwd_raw) = ws.cwd_for_session(caller) else {
238            return Err(warn_unresolved(caller, Some(&cx), None, ScopeError::SessionCwdUnknown));
239        };
240        let scan_cwd = ws.git_scan_cwd_for_session(caller, Path::new(&cwd_raw));
241        let fail = |error| Err(warn_unresolved(caller, Some(&cx), Some(cwd_raw.as_str()), error));
242        if !scan_cwd.is_dir() {
243            return fail(ScopeError::ScanDirMissing { scan_cwd });
244        }
245        let branch = match forge_agent::env::git_diff::current_branch(&scan_cwd).await {
246            Ok(Some(branch)) => branch,
247            Ok(None) => return fail(ScopeError::DetachedHead { scan_cwd }),
248            Err(gate) => return fail(ScopeError::NoBranchFromGit { scan_cwd, gate }),
249        };
250        Ok(ReviewScope { project: cx.project_name, branch, author_label, caller: caller.clone() })
251    }
252
253    fn list(&self, scope: &ReviewScope) -> Result<Vec<ReviewSummary>, String> {
254        let ws = self.0.upgrade().ok_or_else(|| "workspace unavailable".to_owned())?;
255        ws.review_list(&scope.project, &scope.branch)
256    }
257
258    /// A failure here can only degrade a diagnostic, so it warns and
259    /// reports none rather than failing the `review__list` that already
260    /// succeeded.
261    fn branches_with_reviews(&self, scope: &ReviewScope) -> Vec<String> {
262        let Some(ws) = self.0.upgrade() else {
263            return Vec::new();
264        };
265        ws.review_branches(&scope.project).unwrap_or_else(|error| {
266            tracing::warn!(
267                target: "forge_workspace::review",
268                event_name = "review_branch_probe_failed",
269                project = %scope.project,
270                %error,
271            );
272            Vec::new()
273        })
274    }
275
276    fn get(&self, scope: &ReviewScope, review_id: &str) -> Result<Option<ReviewDetail>, String> {
277        let ws = self.0.upgrade().ok_or_else(|| "workspace unavailable".to_owned())?;
278        ws.review_get(&scope.project, &scope.branch, review_id)
279    }
280
281    fn reply(
282        &self,
283        scope: &ReviewScope,
284        comment_id: &str,
285        text: &str,
286        at: &str,
287    ) -> Result<CommentAnchorView, String> {
288        let ws = self.0.upgrade().ok_or_else(|| "workspace unavailable".to_owned())?;
289        ws.review_reply(
290            &scope.caller,
291            &scope.project,
292            &scope.branch,
293            comment_id,
294            &scope.author_label,
295            text,
296            at,
297        )
298    }
299
300    fn resolve(&self, scope: &ReviewScope, comment_id: &str) -> Result<CommentAnchorView, String> {
301        let ws = self.0.upgrade().ok_or_else(|| "workspace unavailable".to_owned())?;
302        ws.review_resolve(&scope.caller, &scope.project, &scope.branch, comment_id)
303    }
304}
305
306/// Mock for the four Tool impls' unit tests. Preloads a scope + return
307/// values and captures reply/resolve calls, so tests assert routing +
308/// scope-rejection without a live `Workspace`.
309#[cfg(test)]
310pub struct MockReviewFacade {
311    /// Resolved scope; an `Err` exercises the unresolved-scope path.
312    pub scope: parking_lot::Mutex<Result<ReviewScope, ScopeError>>,
313    pub summaries: parking_lot::Mutex<Vec<ReviewSummary>>,
314    /// Summaries answered for a seat-TREE scope (the files page's reviews),
315    /// so a test can tell the two scopes' rows apart.
316    pub tree_summaries: parking_lot::Mutex<Vec<ReviewSummary>>,
317    /// Branches the scope's project holds reviews on.
318    pub review_branches: parking_lot::Mutex<Vec<String>>,
319    pub detail: parking_lot::Mutex<Option<ReviewDetail>>,
320    /// Detail answered for a seat-tree scope.
321    pub tree_detail: parking_lot::Mutex<Option<ReviewDetail>>,
322    /// When true, ops on a non-tree scope fail as "not in this scope" -
323    /// the state the tool's seat-tree fallback exists for.
324    pub fail_non_tree: parking_lot::Mutex<bool>,
325    /// When set, a LIST on a seat-tree scope fails with this error - the
326    /// state the tool's degrade-and-state path exists for.
327    pub tree_list_error: parking_lot::Mutex<Option<String>>,
328    /// Captured `(comment_id, text)` reply calls.
329    pub reply_calls: parking_lot::Mutex<Vec<(String, String)>>,
330    /// Captured `comment_id` resolve calls.
331    pub resolve_calls: parking_lot::Mutex<Vec<String>>,
332    /// The anchor a successful `reply` returns.
333    pub reply_anchor: parking_lot::Mutex<CommentAnchorView>,
334    /// The anchor a successful `resolve` returns.
335    pub resolve_anchor: parking_lot::Mutex<CommentAnchorView>,
336    /// When set, `reply` / `resolve` return this error (scope rejection).
337    pub force_error: parking_lot::Mutex<Option<String>>,
338    /// The branch of every scope an op was called with, in call order, so a
339    /// test can pin which scope each tool addressed.
340    pub scopes_seen: parking_lot::Mutex<Vec<String>>,
341}
342
343/// Whether a scope is a seat-TREE scope: the one the mock's calls below
344/// answer differently.
345#[cfg(test)]
346fn on_seat_tree(scope: &ReviewScope) -> bool {
347    forge_primitives::review::is_tree_scope(&scope.branch)
348}
349
350#[cfg(test)]
351impl MockReviewFacade {
352    pub fn new() -> Self {
353        Self {
354            scope: parking_lot::Mutex::new(Ok(ReviewScope {
355                project: "forge".to_owned(),
356                branch: "feat".to_owned(),
357                author_label: "implementer".to_owned(),
358                caller: SessionSlot::from_str_for_test("caller"),
359            })),
360            summaries: parking_lot::Mutex::new(Vec::new()),
361            tree_summaries: parking_lot::Mutex::new(Vec::new()),
362            review_branches: parking_lot::Mutex::new(Vec::new()),
363            detail: parking_lot::Mutex::new(None),
364            tree_detail: parking_lot::Mutex::new(None),
365            fail_non_tree: parking_lot::Mutex::new(false),
366            tree_list_error: parking_lot::Mutex::new(None),
367            reply_calls: parking_lot::Mutex::new(Vec::new()),
368            resolve_calls: parking_lot::Mutex::new(Vec::new()),
369            reply_anchor: parking_lot::Mutex::new(CommentAnchorView {
370                comment_id: "c1".to_owned(),
371                file: "src/x.rs".to_owned(),
372                line: 12,
373                side: "new",
374                status: "addressed",
375                number: Some(1),
376            }),
377            resolve_anchor: parking_lot::Mutex::new(CommentAnchorView {
378                comment_id: "c2".to_owned(),
379                file: "src/x.rs".to_owned(),
380                line: 12,
381                side: "new",
382                status: "resolved",
383                number: Some(1),
384            }),
385            force_error: parking_lot::Mutex::new(None),
386            scopes_seen: parking_lot::Mutex::new(Vec::new()),
387        }
388    }
389
390    pub fn into_arc(self) -> Arc<dyn ReviewFacade> {
391        Arc::new(self)
392    }
393}
394
395#[cfg(test)]
396#[async_trait]
397impl ReviewFacade for MockReviewFacade {
398    async fn resolve_scope(
399        &self,
400        _caller: &SessionSlot,
401        scope: Option<&str>,
402    ) -> Result<ReviewScope, ScopeError> {
403        let mut resolved = self.scope.lock().clone()?;
404        if let Some(branch) = scope {
405            branch.clone_into(&mut resolved.branch);
406        }
407        Ok(resolved)
408    }
409
410    fn list(&self, scope: &ReviewScope) -> Result<Vec<ReviewSummary>, String> {
411        self.scopes_seen.lock().push(scope.branch.clone());
412        if on_seat_tree(scope) {
413            if let Some(err) = self.tree_list_error.lock().clone() {
414                return Err(err);
415            }
416            return Ok(self.tree_summaries.lock().clone());
417        }
418        Ok(self.summaries.lock().clone())
419    }
420
421    fn branches_with_reviews(&self, _scope: &ReviewScope) -> Vec<String> {
422        self.review_branches.lock().clone()
423    }
424
425    fn get(&self, scope: &ReviewScope, review_id: &str) -> Result<Option<ReviewDetail>, String> {
426        self.scopes_seen.lock().push(scope.branch.clone());
427        let held = if on_seat_tree(scope) { self.tree_detail.lock() } else { self.detail.lock() };
428        Ok(held.clone().filter(|d| d.review_id == review_id))
429    }
430
431    fn reply(
432        &self,
433        scope: &ReviewScope,
434        comment_id: &str,
435        text: &str,
436        _at: &str,
437    ) -> Result<CommentAnchorView, String> {
438        self.scopes_seen.lock().push(scope.branch.clone());
439        if let Some(err) = self.force_error.lock().clone() {
440            return Err(err);
441        }
442        if *self.fail_non_tree.lock() && !on_seat_tree(scope) {
443            return Err(format!("comment {comment_id} is not in this scope"));
444        }
445        self.reply_calls.lock().push((comment_id.to_owned(), text.to_owned()));
446        Ok(self.reply_anchor.lock().clone())
447    }
448
449    fn resolve(&self, scope: &ReviewScope, comment_id: &str) -> Result<CommentAnchorView, String> {
450        self.scopes_seen.lock().push(scope.branch.clone());
451        if let Some(err) = self.force_error.lock().clone() {
452            return Err(err);
453        }
454        if *self.fail_non_tree.lock() && !on_seat_tree(scope) {
455            return Err(format!("comment {comment_id} is not in this scope"));
456        }
457        self.resolve_calls.lock().push(comment_id.to_owned());
458        Ok(self.resolve_anchor.lock().clone())
459    }
460}
461
462/// Drives [`ProdReviewFacade`] against a real (stub) `Workspace` so each
463/// failure step is asserted where it is produced, not where a mock
464/// replays it.
465#[cfg(test)]
466mod resolve_scope_tests {
467    use super::{ProdReviewFacade, ReviewFacade, ReviewScope, ScopeError};
468    use crate::SessionSlot;
469    use crate::workspace::Workspace;
470    use std::path::{Path, PathBuf};
471    use std::sync::{Arc, Weak};
472
473    #[test]
474    fn branches_with_reviews_reports_the_projects_other_branches() {
475        let dir = tempfile::tempdir().expect("tempdir");
476        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
477        ws.install_db_for_test(
478            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
479        );
480        let reviewer = SessionSlot::from_str_for_test("lead-uuid");
481        ws.submit_review("myproj", "feat/theirs", None, &[], reviewer.clone())
482            .expect("submit theirs");
483        ws.submit_review("other", "feat/elsewhere", None, &[], reviewer).expect("submit elsewhere");
484        let scope = ReviewScope {
485            project: "myproj".to_owned(),
486            branch: "feat/mine".to_owned(),
487            author_label: "implementer".to_owned(),
488            caller: SessionSlot::from_str_for_test("caller-uuid"),
489        };
490        assert_eq!(
491            ProdReviewFacade(Arc::downgrade(&ws)).branches_with_reviews(&scope),
492            vec!["feat/theirs".to_owned()],
493            "only this project's review-bearing branches",
494        );
495    }
496
497    fn git(dir: &Path, args: &[&str]) {
498        let out = std::process::Command::new("git")
499            .arg("-C")
500            .arg(dir)
501            .args(args)
502            .output()
503            .expect("git runs");
504        assert!(
505            out.status.success(),
506            "git {args:?} in {}: {}",
507            dir.display(),
508            String::from_utf8_lossy(&out.stderr),
509        );
510    }
511
512    /// A repo at `dir` on `branch`, with one commit so HEAD resolves.
513    /// `git init -b` needs git 2.28; CI runs 2.25, hence `symbolic-ref`.
514    fn init_repo(dir: &Path, branch: &str) {
515        git(dir, &["init", "-q"]);
516        git(dir, &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")]);
517        git(dir, &["config", "user.email", "test@example.com"]);
518        git(dir, &["config", "user.name", "Test"]);
519        git(dir, &["config", "commit.gpgsign", "false"]);
520        std::fs::write(dir.join("README.md"), "hi\n").expect("write");
521        git(dir, &["add", "."]);
522        git(dir, &["commit", "-q", "-m", "init"]);
523    }
524
525    /// A worktree fork of the repo at `root`, at the path claude's
526    /// `--worktree <label>` puts it, on branch `worktree-<label>`.
527    fn add_worktree(root: &Path, label: &str) -> PathBuf {
528        let path = root.join(".claude/worktrees").join(label);
529        git(
530            root,
531            &["worktree", "add", "-q", "-b", &format!("worktree-{label}"), &path.to_string_lossy()],
532        );
533        path
534    }
535
536    /// A workspace holding one project rooted at `cwd`, which the
537    /// returned lead caller resolves through. The project is declared in
538    /// the config rather than seeded into the test overlay: a lead's cwd
539    /// comes from `project_for_slot`, which reads `config.projects`.
540    fn ws_with_session_cwd(cwd: &str) -> (Arc<Workspace>, SessionSlot) {
541        let mut config = crate::config::LoadedConfig::empty_for_test();
542        config.projects.push(crate::config::LoadedProject {
543            name: "myproj".to_owned(),
544            path: PathBuf::from(cwd),
545            display_path: cwd.to_owned(),
546            org: "TestOrg".to_owned(),
547            accounts: Vec::new(),
548            fallback_accounts: Vec::new(),
549            model: None,
550            env: std::collections::HashMap::new(),
551            env_file: None,
552            max_workers: None,
553            permission_mode: forge_primitives::permission::PermissionMode::Auto,
554            document_roots: Vec::new(),
555            handoff: None,
556            browser_profile: forge_primitives::browser::ProfileKey::of("TestOrg", "default")
557                .expect("a profile key"),
558            slack_workspace: None,
559        });
560        let (ws, _rx) = Workspace::testing_stub_with_config(
561            PathBuf::from("/tmp/forge-review-testing-stub"),
562            config,
563        )
564        .expect("the stub config's [[slack]] entries are well-formed");
565        (ws, SessionSlot::lead("TestOrg", "myproj"))
566    }
567
568    /// Register `label` as a live worker of the project rooted at
569    /// `project_root`, with NO catalog row - the state every spawned
570    /// worker is in (the boot scan hides worker-tagged sessions and the
571    /// Connected handler skips the catalog mirror for workers), so the
572    /// worker registry is the only place its cwd can come from.
573    fn seed_worker(
574        ws: &Arc<Workspace>,
575        project_root: &str,
576        label: &str,
577        is_git_repo_at_spawn: bool,
578    ) -> SessionSlot {
579        ws.seed_test_project("myproj", project_root);
580        let key = ws
581            .list_projects()
582            .into_iter()
583            .find(|v| v.name == "myproj")
584            .map(|v| v.key)
585            .expect("seeded project");
586        let caller = SessionSlot::worker("TestOrg", "myproj", label);
587        ws.insert_live_worker(
588            &key,
589            crate::WorkerEntry {
590                label: label.to_owned(),
591                charter: "build".to_owned(),
592                slot: caller.clone(),
593                session_id: None,
594                status: forge_primitives::WorkerLiveness::Running,
595                spawned_at: std::time::SystemTime::UNIX_EPOCH,
596                spawned_by: SessionSlot::lead("TestOrg", "myproj"),
597                needs_tag: false,
598                is_git_repo_at_spawn,
599                cwd: None,
600                diagnostic: None,
601                kick: None,
602            },
603        );
604        caller
605    }
606
607    async fn scope_err(ws: &Arc<Workspace>, caller: &SessionSlot) -> ScopeError {
608        ProdReviewFacade(Arc::downgrade(ws))
609            .resolve_scope(caller, None)
610            .await
611            .expect_err("scope resolution must fail")
612    }
613
614    /// A document's review is keyed to the document's scope, not the
615    /// caller's branch, so an explicit scope resolves without a checkout:
616    /// the git step is skipped, while the project still resolves through
617    /// the caller.
618    #[tokio::test]
619    async fn an_explicit_scope_resolves_without_a_checkout() {
620        let dir = tempfile::tempdir().expect("tempdir");
621        let cwd = dir.path().to_string_lossy().into_owned();
622        let (ws, caller) = ws_with_session_cwd(&cwd);
623        let scope = ProdReviewFacade(Arc::downgrade(&ws))
624            .resolve_scope(&caller, Some("doc:/proj/plan.md"))
625            .await
626            .expect("an explicit scope needs no branch");
627        assert_eq!(scope.project, "myproj", "the project still resolves through the caller");
628        assert_eq!(scope.branch, "doc:/proj/plan.md");
629        assert_eq!(scope.author_label, "agent");
630    }
631
632    /// The explicit scope replaces the branch step alone: an unknown caller
633    /// still has no project to key the scope by, and the failure says so.
634    #[tokio::test]
635    async fn an_explicit_scope_still_requires_a_known_caller() {
636        let (ws, _rx) = Workspace::testing_stub();
637        let err = ProdReviewFacade(Arc::downgrade(&ws))
638            .resolve_scope(&SessionSlot::from_str_for_test("ghost-uuid"), Some("doc:/x"))
639            .await
640            .expect_err("an explicit scope does not bypass the caller step");
641        assert_eq!(err, ScopeError::UnknownCaller);
642    }
643
644    #[tokio::test]
645    async fn workspace_gone_is_its_own_reason() {
646        let err = ProdReviewFacade(Weak::new())
647            .resolve_scope(&SessionSlot::from_str_for_test("caller-uuid"), None)
648            .await
649            .expect_err("a dropped workspace fails");
650        assert_eq!(err, ScopeError::WorkspaceGone);
651        assert!(!err.message().contains("detached"), "{}", err.message());
652    }
653
654    #[tokio::test]
655    async fn caller_outside_every_project_is_its_own_reason() {
656        let (ws, _rx) = Workspace::testing_stub();
657        let err = scope_err(&ws, &SessionSlot::from_str_for_test("ghost-uuid")).await;
658        assert_eq!(err, ScopeError::UnknownCaller);
659        assert!(!err.message().contains("detached"), "{}", err.message());
660    }
661
662    /// A worker's cwd comes from the worker registry, not the sessions
663    /// catalog - the catalog never holds a worker row. The review the
664    /// worker must read is the one keyed to its worktree's branch, not
665    /// the project's default branch.
666    #[tokio::test]
667    async fn worker_without_a_catalog_row_reads_its_worktree_branch() {
668        let dir = tempfile::tempdir().expect("tempdir");
669        let root = dir.path().join("myproj");
670        std::fs::create_dir_all(&root).expect("mkdir");
671        init_repo(&root, "main");
672        add_worktree(&root, "pyth-review-fixes");
673
674        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
675        ws.install_db_for_test(
676            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
677        );
678        let caller = seed_worker(&ws, &root.to_string_lossy(), "pyth-review-fixes", true);
679        ws.submit_review(
680            "myproj",
681            "worktree-pyth-review-fixes",
682            Some("round 1".to_owned()),
683            &[],
684            SessionSlot::from_str_for_test("lead-uuid"),
685        )
686        .expect("submit review on the worker's branch");
687
688        let facade = ProdReviewFacade(Arc::downgrade(&ws));
689        let scope = facade.resolve_scope(&caller, None).await.expect("worker scope resolves");
690        assert_eq!(scope.branch, "worktree-pyth-review-fixes");
691        assert_eq!(scope.author_label, "pyth-review-fixes");
692        assert_eq!(
693            facade.list(&scope).expect("list").len(),
694            1,
695            "the worker reads the review filed on its worktree branch",
696        );
697    }
698
699    /// A non-git worker never forked into a worktree, so the registry
700    /// resolves it to the project root. The root here is not a repo, so
701    /// git is what fails - the cwd step no longer does.
702    #[tokio::test]
703    async fn non_git_worker_without_a_catalog_row_resolves_the_project_root() {
704        let dir = tempfile::tempdir().expect("tempdir");
705        let (ws, _rx) = Workspace::testing_stub();
706        let caller = seed_worker(&ws, &dir.path().to_string_lossy(), "researcher", false);
707        let err = scope_err(&ws, &caller).await;
708        assert!(
709            matches!(&err, ScopeError::NoBranchFromGit { scan_cwd, .. } if scan_cwd == dir.path()),
710            "the cwd step must resolve to the project root: {err:?}",
711        );
712    }
713
714    /// `git rev-parse` exits 128 outside a work tree, which the
715    /// scanner's repo-existence probe resolves to `NotARepo`. The
716    /// variant still covers both gates, so its message claims neither.
717    #[tokio::test]
718    async fn non_git_scan_dir_is_its_own_reason() {
719        let dir = tempfile::tempdir().expect("tempdir");
720        let cwd = dir.path().to_string_lossy().into_owned();
721        let (ws, caller) = ws_with_session_cwd(&cwd);
722        let err = scope_err(&ws, &caller).await;
723        assert!(
724            matches!(&err, ScopeError::NoBranchFromGit { scan_cwd, .. } if scan_cwd == dir.path()),
725            "{err:?}",
726        );
727        assert!(err.message().contains(&cwd), "the message names the dir it inspected");
728        assert!(!err.message().contains("detached"), "{}", err.message());
729    }
730
731    #[tokio::test]
732    async fn a_scan_dir_that_is_not_on_disk_is_its_own_reason() {
733        let dir = tempfile::tempdir().expect("tempdir");
734        let gone = dir.path().join("gone");
735        let cwd = gone.to_string_lossy().into_owned();
736        let (ws, caller) = ws_with_session_cwd(&cwd);
737        let err = scope_err(&ws, &caller).await;
738        assert_eq!(err, ScopeError::ScanDirMissing { scan_cwd: gone });
739        assert!(err.message().contains(&cwd), "the message names the dir it looked for");
740        assert!(!err.message().contains("detached"), "{}", err.message());
741    }
742
743    #[tokio::test]
744    async fn detached_head_names_the_checkout_it_inspected() {
745        let dir = tempfile::tempdir().expect("tempdir");
746        init_repo(dir.path(), "main");
747        git(dir.path(), &["checkout", "-q", "--detach"]);
748        let cwd = dir.path().to_string_lossy().into_owned();
749        let (ws, caller) = ws_with_session_cwd(&cwd);
750        let err = scope_err(&ws, &caller).await;
751        assert_eq!(err, ScopeError::DetachedHead { scan_cwd: dir.path().to_path_buf() });
752        assert!(err.message().contains("detached HEAD"), "{}", err.message());
753        assert!(err.message().contains(&cwd), "the message names the checkout it inspected");
754    }
755
756    #[tokio::test]
757    async fn a_named_branch_resolves_to_a_scope() {
758        let dir = tempfile::tempdir().expect("tempdir");
759        init_repo(dir.path(), "feat/wgpu-plotter");
760        let cwd = dir.path().to_string_lossy().into_owned();
761        let (ws, caller) = ws_with_session_cwd(&cwd);
762        let scope = ProdReviewFacade(Arc::downgrade(&ws))
763            .resolve_scope(&caller, None)
764            .await
765            .expect("a named branch resolves");
766        assert_eq!(scope.project, "myproj");
767        assert_eq!(scope.branch, "feat/wgpu-plotter");
768        assert_eq!(scope.author_label, "agent", "a non-worker caller falls back to 'agent'");
769        assert_eq!(scope.caller, caller);
770    }
771}