Skip to main content

forge_server/surface/
session.rs

1//! `session()`: one session's operational state.
2
3use std::path::{Path, PathBuf};
4
5use forge_primitives::SessionSlot;
6use forge_workspace::Workspace;
7
8use super::ViewSurface;
9
10/// The machine turn the record carries, from the two states the core and the
11/// CLI each already know.
12///
13/// A handoff outranks a compaction, because it is the one that replaces the
14/// seat; `None` is a seat taking input.
15pub fn machine_turn(
16    handoff: bool,
17    compacting: bool,
18) -> Option<forge_primitives::runtime::MachineTurn> {
19    use forge_primitives::runtime::MachineTurn;
20    if handoff {
21        Some(MachineTurn::Handoff)
22    } else if compacting {
23        Some(MachineTurn::Compacting)
24    } else {
25        None
26    }
27}
28
29/// What a view reads about one session.
30pub struct SessionState {
31    /// The seat this state belongs to.
32    pub slot: SessionSlot,
33    /// Where forge scans git for this session: a git worker's worktree
34    /// path, else the caller's `cwd_raw` unchanged.
35    pub scan_cwd: PathBuf,
36    /// The prompts still waiting in the CLI's queue, oldest first - the pile a
37    /// client draws.
38    ///
39    /// The same read path: `prompt_lifecycle` frames speak only on a change,
40    /// so a client attaching mid-queue would otherwise draw nothing until the
41    /// next prompt moved. Held by the core, so two clients agree.
42    pub queue: Vec<forge_workspace::protocol::QueuedPrompt>,
43    /// Clients with voice on for this seat, this one included. A late
44    /// attach reads the room here rather than waiting for the next toggle.
45    pub voice_listeners: u32,
46}
47
48impl ViewSurface {
49    /// Whether a handoff owns this seat: its judgment turn is running, or the
50    /// swap it opened is still building or spawning.
51    ///
52    /// From the moment the judgment prompt is sent until the successor
53    /// connects, the seat has no composer to take input for - which is what
54    /// tells the page to stand down. A handoff merely *parked* on a running
55    /// turn is not this: the person still has the seat until the boundary,
56    /// when it begins for real.
57    pub fn handoff_in_flight(&self, slot: &SessionSlot) -> bool {
58        self.workspace.handoff_in_flight(slot)
59    }
60}
61
62impl SessionState {
63    pub(super) fn collect(workspace: &Workspace, slot: &SessionSlot, cwd_raw: &Path) -> Self {
64        let queue = workspace
65            .domain_session_for(slot)
66            .map(|domain| {
67                let guard = domain.lock();
68                // **The wire fields only.** The held entry carries the
69                // prompt's image payloads for the row a start will draw, and
70                // a seat read has no use for them: cloning the whole entry
71                // per read copies every base64 payload only for serde to
72                // skip it.
73                guard
74                    .prompt_queue
75                    .iter()
76                    .map(|prompt| forge_workspace::protocol::QueuedPrompt {
77                        uuid: prompt.uuid.clone(),
78                        source: prompt.source,
79                        text: prompt.text.clone(),
80                        attachments: Vec::new(),
81                    })
82                    .collect()
83            })
84            .unwrap_or_default();
85        Self {
86            slot: slot.clone(),
87            scan_cwd: workspace.git_scan_cwd_for_session(slot, cwd_raw),
88            queue,
89            voice_listeners: u32::try_from(workspace.voice_listeners(slot)).unwrap_or(u32::MAX),
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use std::path::{Path, PathBuf};
97    use std::sync::Arc;
98
99    use forge_primitives::{SessionSlot, WorkerLiveness};
100    use forge_workspace::{WorkerEntry, Workspace};
101
102    use forge_primitives::runtime::MachineTurn;
103
104    use super::machine_turn;
105    use crate::surface::ViewSurface;
106
107    fn stub_workspace() -> Arc<Workspace> {
108        let (workspace, _updates) = Workspace::testing_stub();
109        workspace
110    }
111
112    fn status_frame(slot: &SessionSlot, status: &serde_json::Value) -> crate::SessionUpdate {
113        use forge_primitives::Message;
114        let msg: Message = serde_json::from_value(serde_json::json!({
115            "type": "system",
116            "session_id": "s1",
117            "subtype": "status",
118            "status": status,
119        }))
120        .expect("a status frame");
121        crate::SessionUpdate::ChatAppended { key: slot.clone(), msg, source: None }
122    }
123
124    /// **A seat read's queue carries no attachment payloads.** The held entry
125    /// keeps the images for the row a start draws; cloning it whole per read
126    /// copies base64 only for serde to skip it.
127    #[test]
128    fn the_read_queue_carries_no_attachment_payloads() {
129        let (workspace, _dir) = crate::surface::testing::workspace();
130        let seat = SessionSlot::lead("TestOrg", "forge");
131        workspace.register_domain_session(seat.clone(), None);
132        workspace.domain_session_for(&seat).expect("the domain").lock().prompt_queue.push(
133            forge_workspace::protocol::QueuedPrompt {
134                uuid: "p1".to_owned(),
135                source: forge_workspace::protocol::PromptSource::You,
136                text: "queued words".to_owned(),
137                attachments: vec![forge_primitives::ImageAttachment {
138                    data: "iVBORw0KGgo=".to_owned(),
139                    mime_type: "image/png".to_owned(),
140                }],
141            },
142        );
143
144        let state = super::SessionState::collect(&workspace, &seat, Path::new("/tmp"));
145
146        assert_eq!(state.queue.len(), 1, "the waiting prompt is still served");
147        assert!(state.queue[0].attachments.is_empty(), "and its payloads stay core-side");
148    }
149
150    /// **A handoff owns the seat until the swap replaces it, and a
151    /// compaction until the CLI settles it.** The composer is taken away for
152    /// exactly that span, and a handoff outranks a compaction because it is
153    /// the one that replaces the seat.
154    #[test]
155    fn the_machine_turn_is_a_handoff_until_the_swap_and_a_compaction_until_it_settles() {
156        let (workspace, _dir) = crate::surface::testing::workspace();
157        let surface = ViewSurface::new(std::sync::Arc::clone(&workspace));
158        let seat = SessionSlot::lead("TestOrg", "forge");
159        workspace.register_domain_session(seat.clone(), None);
160        let mut composer = crate::composer::Composer::default();
161
162        let turn = |surface: &ViewSurface, composer: &crate::composer::Composer| {
163            machine_turn(surface.handoff_in_flight(&seat), composer.compacting(&seat))
164        };
165
166        assert_eq!(turn(&surface, &composer), None, "an idle seat takes input");
167
168        // **A parked handoff is not a running one.** It waits for the turn
169        // boundary to begin, and the person still has the seat until then -
170        // standing the composer down here would lock a box on a request that
171        // has not started.
172        workspace.domain_session_for(&seat).expect("the domain").lock().handoff.parked = true;
173        assert_eq!(turn(&surface, &composer), None, "a parked handoff leaves the seat live");
174        workspace.domain_session_for(&seat).expect("the domain").lock().handoff.parked = false;
175
176        composer.apply(&status_frame(&seat, &serde_json::json!("compacting")));
177        assert_eq!(
178            turn(&surface, &composer),
179            Some(MachineTurn::Compacting),
180            "the CLI's status frame names the compaction",
181        );
182        composer.apply(&status_frame(&seat, &serde_json::Value::Null));
183        assert_eq!(turn(&surface, &composer), None, "and its null settles it");
184
185        workspace.domain_session_for(&seat).expect("the domain").lock().handoff.awaiting = true;
186        assert_eq!(
187            turn(&surface, &composer),
188            Some(MachineTurn::Handoff),
189            "the judgment turn owns the seat",
190        );
191        composer.apply(&status_frame(&seat, &serde_json::json!("compacting")));
192        assert_eq!(
193            turn(&surface, &composer),
194            Some(MachineTurn::Handoff),
195            "a handoff outranks a compaction begun beside it",
196        );
197        workspace.domain_session_for(&seat).expect("the domain").lock().handoff.awaiting = false;
198        composer.apply(&status_frame(&seat, &serde_json::Value::Null));
199        assert_eq!(turn(&surface, &composer), None, "the swap and the settle clear both");
200    }
201
202    fn git_worker(slot: SessionSlot) -> WorkerEntry {
203        WorkerEntry {
204            label: slot.label().to_owned(),
205            charter: "charter".to_owned(),
206            slot,
207            session_id: None,
208            status: WorkerLiveness::Running,
209            spawned_at: std::time::SystemTime::UNIX_EPOCH,
210            spawned_by: SessionSlot::lead("TestOrg", "forge"),
211            needs_tag: false,
212            is_git_repo_at_spawn: true,
213            cwd: None,
214            diagnostic: None,
215            kick: None,
216        }
217    }
218
219    /// Catches a session verb that hands back the caller's own cwd for a
220    /// git worker, and one that names a seat other than the one it was
221    /// asked about.
222    #[test]
223    fn session_scan_cwd_agrees_with_the_workspace() {
224        let workspace = stub_workspace();
225        workspace.seed_test_project("forge", "/tmp/forge-session");
226        let surface = ViewSurface::new(Arc::clone(&workspace));
227        let project = surface.roster().project_named("forge").expect("seeded project").key.clone();
228        let worker = SessionSlot::worker("TestOrg", "forge", "probe-a");
229        workspace.insert_live_worker(&project, git_worker(worker.clone()));
230
231        // Both lifecycle cwds - the project root a fresh spawn reports,
232        // and the worktree path a resumed one reports - must converge on
233        // the worker's worktree.
234        for cwd_raw in ["/tmp/forge-session", "/tmp/forge-session/.claude/worktrees/probe-a"] {
235            let cwd = Path::new(cwd_raw);
236            assert_eq!(
237                surface.session(&worker, cwd).scan_cwd,
238                workspace.git_scan_cwd_for_session(&worker, cwd),
239                "a git worker's scan cwd must be the workspace's own answer for {cwd_raw}"
240            );
241            assert_eq!(
242                surface.session(&worker, cwd).scan_cwd,
243                PathBuf::from("/tmp/forge-session/.claude/worktrees/probe-a"),
244                "a git worker must scan its worktree, not the cwd it was handed"
245            );
246        }
247
248        let lead = SessionSlot::lead("TestOrg", "forge");
249        let cwd = Path::new("/tmp/elsewhere");
250        let state = surface.session(&lead, cwd);
251        assert_eq!(
252            state.scan_cwd,
253            PathBuf::from("/tmp/elsewhere"),
254            "a session that is not a git worker must scan the cwd it was handed"
255        );
256        assert_eq!(state.slot, lead, "the state must name the seat it was asked about");
257    }
258}