Skip to main content

forge_server/
surface.rs

1//! The surface a view uses: named read verbs by subject, returning values
2//! that carry no view type, and one dispatch verb for what a view acts
3//! with.
4//!
5//! [`ViewSurface::dispatch`] is a verb rather than an accessor, so a second
6//! view is handed the commands it needs and not the whole core, and changes
7//! arrive on [`ViewSurface::subscribe_client`].
8
9pub mod accounts;
10pub mod agents;
11pub mod biometric;
12pub mod board;
13pub mod composer;
14pub mod connectors;
15pub mod dictate;
16pub mod extensions;
17pub mod inspector;
18pub mod plugins;
19pub mod reviews;
20pub mod roster;
21pub mod session;
22pub mod usage;
23pub mod vault;
24pub mod voice;
25pub mod workers;
26
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use forge_primitives::SessionSlot;
31use forge_workspace::{Command, DispatchError, Workspace};
32
33pub use agents::{AgentRow, Agents, PendingKind};
34pub use dictate::DictateView;
35pub use extensions::{ExtensionsView, LspServerCheck};
36pub use forge_primitives::document::DocumentRead;
37// A view compares the values the surface hands it, so it needs their names
38// too - re-exported here rather than reached for in the crates below, which
39// a view does not name.
40pub use forge_primitives::ConversationHistory;
41pub use forge_workspace::env::cli_version::CliVersionInfo;
42pub use forge_workspace::protocol::PendingAsk;
43pub use forge_workspace::{DictateFailure, DictateModelState, DictateOutcome, LoadingState};
44pub use roster::Roster;
45pub use session::{SessionState, machine_turn};
46pub use workers::{WorkerRef, Workers};
47
48/// A view's read handle on the core.
49pub struct ViewSurface {
50    workspace: Arc<Workspace>,
51    /// The pool's last scan and when it was taken, so a reader asking twice
52    /// inside the window does not walk it twice. A `Mutex` rather than a
53    /// second cache type: one report, one window.
54    usage_cache:
55        std::sync::Mutex<Option<(std::time::Instant, forge_primitives::token_usage::UsageReport)>>,
56    /// Whether a pricing refresh is already out. The fetch crosses the
57    /// network, so the ask that finds one running is not a reason for a
58    /// second.
59    pricing_refill: std::sync::atomic::AtomicBool,
60    /// The one scan a cold cache runs: a second reader waits here rather than
61    /// walking the pool beside the first.
62    usage_scan: tokio::sync::Mutex<()>,
63    /// How many scans were started, so a test can prove two misses ran one.
64    #[cfg(any(test, feature = "testing"))]
65    usage_scans: std::sync::atomic::AtomicU64,
66    /// Holds every miss at one point, so the race the guard covers is a
67    /// barrier rather than a large pool that might finish first.
68    #[cfg(any(test, feature = "testing"))]
69    usage_gate: std::sync::Mutex<Option<Arc<tokio::sync::Barrier>>>,
70}
71
72/// The update the core emits, re-exported: it is the workspace-to-view
73/// protocol already, so a view subscribes to it rather than to a second
74/// vocabulary this crate would have to keep in step.
75pub use forge_workspace::SeatUpdate;
76pub use forge_workspace::SessionUpdate;
77
78/// The wire shape a turn that finished arrives as. A view marking a
79/// completion reads the `Result` through this rather than deciding for
80/// itself which results count.
81pub fn is_success_result(is_error: bool, subtype: &str) -> bool {
82    !is_error && subtype == "success"
83}
84
85/// A `[server]` size key in KB as bytes: `0` means no bound (`tool_record_cap_kb`
86/// serves the records whole, `first_frame_cap_kb` the page the turn count asks
87/// for), and a value too large for this machine is clamped rather than
88/// truncated to zero.
89fn cap_bytes(kb: u64) -> usize {
90    if kb == 0 {
91        usize::MAX
92    } else {
93        usize::try_from(kb.saturating_mul(1024)).unwrap_or(usize::MAX)
94    }
95}
96
97impl ViewSurface {
98    pub fn new(workspace: Arc<Workspace>) -> Self {
99        Self {
100            workspace,
101            usage_cache: std::sync::Mutex::new(None),
102            pricing_refill: std::sync::atomic::AtomicBool::new(false),
103            usage_scan: tokio::sync::Mutex::new(()),
104            #[cfg(any(test, feature = "testing"))]
105            usage_scans: std::sync::atomic::AtomicU64::new(0),
106            #[cfg(any(test, feature = "testing"))]
107            usage_gate: std::sync::Mutex::new(None),
108        }
109    }
110
111    /// The terminal a client asked to view, with this connection counted as
112    /// a viewer. **An attach naming a terminal the seat no longer has is
113    /// refused**; an attach naming none opens the seat's terminal, which is
114    /// the person's door on a seat no agent has touched.
115    pub fn terminal_attach_or_open(
116        &self,
117        seat: &SessionSlot,
118        id: Option<u16>,
119    ) -> Result<Arc<forge_workspace::terminal::TerminalEntry>, String> {
120        let entry = match id {
121            Some(id) => self.workspace.terminal_by_id(seat, id)?,
122            // **The person's door on an untouched seat.** An attach that
123            // names no terminal opens the seat's, creating it on first use.
124            None => self.workspace.open_terminal(seat)?,
125        };
126        entry.attached.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
127        Ok(entry)
128    }
129
130    /// The person's own take: always explicit, and landing even during a
131    /// parked hand-off, whose surface opens as a viewer's.
132    pub fn take_terminal_control(
133        &self,
134        seat: &SessionSlot,
135        id: u16,
136        viewer: u64,
137    ) -> Result<(), String> {
138        self.workspace.take_terminal_control(seat, id, viewer)
139    }
140
141    /// The person's close door: end the terminal's process group.
142    pub fn terminal_close(&self, seat: &SessionSlot, id: u16) -> Result<(), String> {
143        self.workspace.terminal_by_id(seat, id)?;
144        self.workspace.close_terminal(seat);
145        Ok(())
146    }
147
148    /// The attached surface changed size.
149    pub fn terminal_resize(
150        &self,
151        seat: &SessionSlot,
152        id: u16,
153        cols: u16,
154        rows: u16,
155    ) -> Result<(), String> {
156        let entry = self.workspace.terminal_by_id(seat, id)?;
157        entry.terminal.resize(cols, rows).map_err(|error| error.to_string())
158    }
159
160    /// The person's own keystrokes, written to the PTY. They are not logged
161    /// or forwarded, but the shell may echo them onto the screen the terminal
162    /// tools read once the hand-off resolves.
163    pub fn terminal_write(&self, seat: &SessionSlot, id: u16, bytes: &[u8]) -> Result<(), String> {
164        let entry = self.workspace.terminal_by_id(seat, id)?;
165        entry.terminal.write_bytes(bytes).map_err(|error| error.to_string())
166    }
167
168    /// Whether a hand-off parked on the seat's terminal owns its input.
169    pub fn terminal_held(&self, seat: &SessionSlot) -> bool {
170        self.workspace.terminal_held(seat)
171    }
172
173    /// The seat's terminal as the record draws it, when it has one.
174    pub fn terminal(
175        &self,
176        seat: &SessionSlot,
177    ) -> Option<forge_primitives::terminal::TerminalSummary> {
178        let held = self.workspace.terminal_held(seat);
179        self.workspace.terminal_if_any(seat).map(|entry| entry.summary(seat, held))
180    }
181
182    /// How many pool scans this surface started. Test-only: the single-flight
183    /// guard's whole claim is that two concurrent misses start one.
184    #[cfg(any(test, feature = "testing"))]
185    pub fn test_usage_scans(&self) -> u64 {
186        self.usage_scans.load(std::sync::atomic::Ordering::Acquire)
187    }
188
189    /// Hold every usage miss here until the test lets it go. Test-only, and
190    /// `None` outside the tests that seed one.
191    #[cfg(any(test, feature = "testing"))]
192    pub fn seed_test_usage_gate(&self, gate: Arc<tokio::sync::Barrier>) {
193        *self.usage_gate.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(gate);
194    }
195
196    #[cfg(any(test, feature = "testing"))]
197    async fn pass_test_usage_gate(&self) {
198        let gate =
199            self.usage_gate.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
200        if let Some(gate) = gate {
201            gate.wait().await;
202        }
203    }
204
205    /// The byte cap one frame's `tool_use_result` may reach in a served
206    /// page (#2374): `[server] tool_record_cap_kb`, with `0` meaning whole.
207    pub fn tool_record_cap_bytes(&self) -> usize {
208        cap_bytes(self.workspace.server_config().tool_record_cap_kb)
209    }
210
211    /// The conversation bytes a session's first snapshot may carry:
212    /// `[server] first_frame_cap_kb`, with `0` meaning the turn count alone
213    /// decides the page.
214    pub fn first_frame_cap_bytes(&self) -> usize {
215        cap_bytes(self.workspace.server_config().first_frame_cap_kb)
216    }
217
218    /// The conversation bytes a `more` page may carry:
219    /// `[server] more_page_cap_kb`, with `0` meaning the turn count alone
220    /// decides the page.
221    pub fn more_page_cap_bytes(&self) -> usize {
222        cap_bytes(self.workspace.server_config().more_page_cap_kb)
223    }
224
225    /// Dispatch one command to the core, which is how a view acts rather
226    /// than only reads.
227    ///
228    /// A verb rather than an accessor, on purpose: a second view is handed
229    /// the writes it needs and not the whole core, so nothing here grows a
230    /// way to reach the workspace itself.
231    pub fn dispatch(&self, command: Command) -> Result<(), DispatchError> {
232        // A view's entry rather than the core's: the press is routed through
233        // the view's own door, and no frame marks who sent it since v27.
234        self.workspace.dispatch_from_view(command)
235    }
236
237    /// The last fatal error, or `None` when nothing has failed fatally.
238    ///
239    /// App-level rather than the seat's: it names a startup that could not
240    /// happen. It is held because the update carries no state of its own,
241    /// so a view that attached afterwards has no other way to learn of it.
242    pub fn fatal_error(&self) -> Option<forge_primitives::error::AppError> {
243        self.workspace.last_fatal_error()
244    }
245
246    /// The statuspage's last answer, or `None`.
247    ///
248    /// App-level: the answer is the same for every viewer, so the core
249    /// probes it once and every view reads this rather than fetching its
250    /// own. `None` covers both a healthy statuspage and one that could not
251    /// be reached, which is the fetch's own contract.
252    pub fn service_status(&self) -> Option<forge_primitives::cloud::service_status::ServiceIssue> {
253        self.workspace.service_status()
254    }
255
256    /// What the seat at `slot` is holding, with a draft leading and the rest
257    /// oldest first, so a view that attached after a prompt landed still
258    /// draws what it offers. Empty when the seat is holding nothing.
259    pub fn pending_asks(&self, slot: &SessionSlot) -> Vec<PendingAsk> {
260        self.workspace.pending_asks(slot)
261    }
262
263    /// The core's own update stream for a consumer that reads it WITHOUT
264    /// answering.
265    ///
266    /// **The caller this exists for is a process-wide fold.** It folds into
267    /// state of its own and never replies, so the answering role would park
268    /// a permission, question or Slack draft waiting on an answer nobody
269    /// sends.
270    ///
271    /// A view with a dock to answer from takes [`Self::subscribe_client`].
272    pub fn subscribe_mirror(&self) -> tokio::sync::mpsc::UnboundedReceiver<SeatUpdate> {
273        self.workspace.subscribe_mirror()
274    }
275
276    /// The core's stream for a connecting client, registered with the
277    /// role that client declared.
278    ///
279    /// **A stream carries what follows its own subscribe and nothing
280    /// before it**, whatever the role: a client that attaches late reads what
281    /// it missed from the subject's snapshot.
282    ///
283    /// The role decides one thing: whether the core may park a prompt on this
284    /// connection's reply. `answering` is the client's own statement that it
285    /// can show and answer a prompt.
286    pub fn subscribe_client(
287        &self,
288        answering: bool,
289    ) -> tokio::sync::mpsc::UnboundedReceiver<SeatUpdate> {
290        if answering {
291            self.workspace.subscribe_answerer()
292        } else {
293            self.workspace.subscribe_mirror()
294        }
295    }
296
297    /// The projects, their sessions, and the per-project lists the home
298    /// page renders.
299    pub fn roster(&self) -> Roster {
300        Roster::collect(Arc::clone(&self.workspace))
301    }
302
303    /// One session's operational state. `cwd_raw` is the caller's own
304    /// cwd for the session, which a git worker's worktree overrides.
305    pub fn session(&self, slot: &SessionSlot, cwd_raw: &Path) -> SessionState {
306        SessionState::collect(&self.workspace, slot, cwd_raw)
307    }
308
309    /// The number `slot`'s next frame will be minted under: the read a
310    /// client makes when it attaches. It belongs to the epoch its occupant
311    /// names - a seat whose occupant changed starts over at 1 - so the two
312    /// are read together.
313    pub fn seat_next_seq(&self, slot: &SessionSlot) -> u64 {
314        self.workspace.seat_next_seq(slot)
315    }
316
317    /// The frames `slot`'s stream emitted under the numbers in `from..=to`,
318    /// oldest first: the answer to a client's range ask while the seat's own
319    /// window holds them.
320    pub fn seat_frames_between(&self, slot: &SessionSlot, from: u64, to: u64) -> Vec<SeatUpdate> {
321        self.workspace.seat_frames_between(slot, from, to)
322    }
323
324    /// The oldest number `slot`'s window still holds, `None` for a seat that
325    /// has emitted nothing: what tells a range answered from memory from one
326    /// that has to be read off the transcript.
327    pub fn seat_oldest_kept_seq(&self, slot: &SessionSlot) -> Option<u64> {
328        self.workspace.seat_oldest_kept_seq(slot)
329    }
330
331    /// How many of the numbers in `from..=to` on `slot`'s stream no window
332    /// holds: the frames that close an epoch are numbered but kept out of the
333    /// ring, so a reader measuring whether a copy covers a stretch leaves them
334    /// out of what it must account for.
335    pub fn seat_unringed_between(&self, slot: &SessionSlot, from: u64, to: u64) -> u64 {
336        self.workspace.seat_unringed_between(slot, from, to)
337    }
338
339    /// Whether `slot`'s latest epoch-opening seal carried rows, which no ring
340    /// holds.
341    pub fn seat_epoch_opened_with_rows(&self, slot: &SessionSlot) -> bool {
342        self.workspace.seat_epoch_opened_with_rows(slot)
343    }
344
345    /// The live workers, per project.
346    pub fn workers(&self) -> Workers {
347        Workers::collect(Arc::clone(&self.workspace))
348    }
349
350    /// The installed and npm-published `claude` CLI versions. The same
351    /// answer for every viewer, so the core holds it and a change arrives
352    /// as `SessionUpdate::CliVersionChanged`; `None` until the boot probe
353    /// lands.
354    pub fn cli_version(&self) -> Option<CliVersionInfo> {
355        self.workspace.cli_version()
356    }
357
358    /// Register this connection as an attached client for as long as it
359    /// lives, so a document hand-off can tell a viewer exists to open on.
360    /// The transport's per-connection guard calls these two.
361    pub fn client_attached(&self) {
362        self.workspace.note_client_attached();
363    }
364
365    /// The counterpart of [`Self::client_attached`].
366    pub fn client_detached(&self) {
367        self.workspace.note_client_detached();
368    }
369
370    /// The live `[server]` snapshot, read by the socket's origin gate on
371    /// every upgrade so a reload's new allowances apply to the next
372    /// connection without a restart.
373    pub fn server_config(&self) -> forge_primitives::ServerConfig {
374        self.workspace.server_config()
375    }
376
377    /// The `[client]` settings a connection greets with. Read when the
378    /// connection opens, so an edit applies to the next connection.
379    pub fn client_config(&self) -> forge_primitives::ClientConfig {
380        self.workspace.client_config()
381    }
382
383    /// Apply a batched settings save. The refusal is answered by the
384    /// connection that asked, on the settings refusal frame.
385    pub fn save_settings(
386        &self,
387        fingerprint: &str,
388        edits: &[forge_primitives::settings::SettingsEditRequest],
389    ) -> Result<(), forge_workspace::SettingsSaveError> {
390        self.workspace.save_settings(fingerprint, edits)
391    }
392
393    /// One document, read out of the seat's own tree for a viewer's route.
394    ///
395    /// `path` resolves against the session's working directory and
396    /// `reviewable` is what the hand-off flagged; both go to the read in
397    /// `forge_agent::env::documents`, whose refusals are the sentences a
398    /// route draws and a refused hand-off returns. The seat's project's
399    /// `document_roots` go with them, so a document the hand-off accepted
400    /// opens here too. The read is blocking (a file off disk), so a
401    /// caller offloads it rather than running it in a handler.
402    pub fn read_document(
403        &self,
404        seat: &SessionSlot,
405        path: &str,
406        reviewable: bool,
407    ) -> Result<DocumentRead, String> {
408        let Some(cwd) = self.roster().cwd_for(seat) else {
409            return Err(format!("forge holds no session for {}", seat.display()));
410        };
411        forge_workspace::env::documents::read(
412            &cwd,
413            &self.roster().document_roots_for(seat),
414            path,
415            reviewable,
416            self.workspace.document_max_bytes(),
417        )
418        .map_err(|refusal| refusal.to_string())
419    }
420
421    /// The byte ceiling every whole-file read takes, which the record's
422    /// whole-file anchor fallback shares with the review page's expansion.
423    pub fn document_max_bytes(&self) -> u64 {
424        self.workspace.document_max_bytes()
425    }
426
427    /// One file's whole content at a revision, for a review page's
428    /// between-hunk expansion.
429    ///
430    /// `sha` names the revision - a commit's own tree for a commit scope -
431    /// and `None` reads the seat's branch tip, which is the new side of the
432    /// all-changes diff. The read is bounded by the same ceiling a document
433    /// read is, and a file cut at that ceiling answers the lines it carried,
434    /// flagged, rather than refusing: the gaps those lines reach are still
435    /// worth drawing.
436    ///
437    /// **`None` is the branch tip read NOW, and it is stated rather than
438    /// pinned for want of anything to pin it to.** The record's diff was
439    /// scanned from this same seat, but it carries no revision for its
440    /// branch layer - its commit chain names only the commits ahead of the
441    /// default - so a branch that moves between the record's read and the
442    /// reader's ask can draw newer lines against frozen hunks. The window is
443    /// the record's age, the page draws its own controls only for lines the
444    /// read reaches, and the alternative (carrying the scanned revision on
445    /// the record, and a wire field with it) buys a race nobody has hit for
446    /// a field every reader would pay.
447    pub async fn file_content(
448        &self,
449        seat: &SessionSlot,
450        path: &str,
451        sha: Option<&str>,
452    ) -> Result<forge_workspace::env::git_diff::file::FileLines, String> {
453        let Some(cwd) = self.roster().cwd_for(seat) else {
454            return Err(format!("forge holds no session for {}", seat.display()));
455        };
456        forge_workspace::env::git_diff::file::read_file_lines(
457            &cwd,
458            sha.unwrap_or("HEAD"),
459            path,
460            self.workspace.document_max_bytes(),
461        )
462        .await
463        .map_err(|refusal| refusal.to_string())
464    }
465
466    /// The conversation the session at `slot` holds, read from its own
467    /// transcript on disk: the same read a resume performs, for a view
468    /// that arrived after the session was already running or one opening a
469    /// seat that is asleep (the id is the live occupant's when there is one,
470    /// and the store's own row otherwise - `transcript_place` resolves it).
471    ///
472    /// Subscribe first, then call this. The read is the baseline the stream
473    /// is applied on top of, so a message may be in both: a view resolves
474    /// that by dropping the stream's copy when the read already carries its
475    /// id, and reading first instead would lose whatever arrived in between,
476    /// which no later read brings back.
477    ///
478    /// The one class the two halves cannot reconcile by id is the prompts
479    /// the workspace injects. A cron, a Gotify delivery, a Slack bundle or a
480    /// peer comm reaches the stream as text alone (`CronPromptAppended`,
481    /// `GotifyNotificationAppended`, `SlackMessageAppended`) and reaches this
482    /// read as the envelope-wrapped row the CLI persisted, with no id shared
483    /// between them, so a view holding both has to match on the envelope's
484    /// source and body instead.
485    ///
486    /// This reads a whole transcript off disk on the calling thread, with no
487    /// size cap: a real session costs milliseconds, and the largest
488    /// transcript on this machine 2.5 s in a release build. A caller must
489    /// offload it rather than run it in a handler, the way this project's
490    /// other async work does with a spawned task on its own channel.
491    ///
492    /// `cwd_raw` is the session's own cwd, which a git worker's worktree
493    /// overrides. An empty `cwd_raw` resolves to the slot's recorded path,
494    /// because the read's no-directory arm walks every `projects/` directory
495    /// for the first file named after the session: that pays the walk per
496    /// call and takes whichever copy of the same session it meets first. A
497    /// slot with no live session, or one whose cwd no record places, reads as
498    /// an empty conversation, and records which of the two it was, so a view
499    /// drawing nothing can say which of them it met rather than only that it
500    /// has nothing to draw.
501    ///
502    /// **This is the surface's read for a view that does not hold its own,
503    /// and the transport reads it only for a seat it holds nothing for.**
504    /// A live seat's conversation is the one the session task seeds, so no
505    /// request walks a file for it; a sleeping one has no task to ask and
506    /// this read is what answers it (`transport::wire::seed_from_transcript`).
507    /// A view keeps its own copy in session state. The verb's other
508    /// callers are the surface's own tests and the measurement harness, which
509    /// is what a surface test is for rather than a sign it is unused.
510    pub fn conversation(&self, slot: &SessionSlot, cwd_raw: &Path) -> ConversationHistory {
511        let Some((session_id, cwd)) = self.transcript_place(slot, cwd_raw) else {
512            return ConversationHistory::default();
513        };
514        // **A dispatch row's expansion is drawn from these frames.** The
515        // page carries the sub-agent's own rows so the chat can render the
516        // timeline under the call; the row list skips them client-side
517        // (#1763).
518        let read = forge_workspace::session_history(self.workspace.config_dir(), &session_id, &cwd);
519        ConversationHistory {
520            messages: crate::transcript::notices_as_blocks(read.messages),
521            compaction_count: read.compaction_count,
522            session_id: Some(session_id),
523        }
524    }
525
526    /// The schedules the fired prompts in `uuids` came from, keyed by the
527    /// prompt's own id: the read half of the pairing the delivery frame
528    /// carries once.
529    pub fn cron_names(
530        &self,
531        slot: &SessionSlot,
532        uuids: &[&str],
533    ) -> std::collections::BTreeMap<String, forge_primitives::cron::CronName> {
534        self.workspace.cron_names(slot, uuids)
535    }
536
537    /// The rows of `slot`'s transcript that sit strictly before `below`, for a
538    /// paging read that has walked below the held window's floor.
539    ///
540    /// **A bounded row read.** `below` is the row the page above stopped at -
541    /// the id it carries and, once a read has seen it, the byte it starts at -
542    /// and `None` reads the file's own newest rows, which is what a walk asks
543    /// for on a window that holds no row to name. `None` when the transcript
544    /// cannot answer: no session for the slot, no file, or an anchor the file
545    /// does not carry.
546    pub fn transcript_rows(
547        &self,
548        slot: &SessionSlot,
549        cwd_raw: &Path,
550        below: Option<&forge_primitives::TranscriptAnchor>,
551        rows: usize,
552    ) -> Option<forge_primitives::TranscriptSpan> {
553        let (session_id, cwd) = self.transcript_place(slot, cwd_raw)?;
554        forge_workspace::transcript_rows(
555            self.workspace.config_dir(),
556            &session_id,
557            &cwd,
558            below,
559            rows,
560        )
561    }
562
563    /// The session id and the working directory a transcript read for `slot`
564    /// needs, each unmet case recorded as it was met.
565    ///
566    /// **The id is the live occupant's when there is one, and the store's own
567    /// row otherwise.** A sleeping seat still has a session - the one a wake
568    /// resumes - and its transcript is what a view opening on it reads, so a
569    /// read that resolved only pooled sessions drew a sleeping seat empty.
570    fn transcript_place(&self, slot: &SessionSlot, cwd_raw: &Path) -> Option<(String, String)> {
571        let Some(session_id) = self.workspace.transcript_session_id_for(slot) else {
572            tracing::debug!(
573                event_name = "conversation_no_session",
574                org = slot.org(),
575                project = slot.project(),
576                label = slot.label(),
577                "no session is recorded for this slot - nothing live and no stored id - \
578                 so nothing here can find its transcript; drawing the conversation empty",
579            );
580            return None;
581        };
582        let cwd_raw = if cwd_raw.as_os_str().is_empty() {
583            let Some(recorded) = self.workspace.cwd_for_session(slot) else {
584                tracing::debug!(
585                    event_name = "conversation_cwd_unplaced",
586                    org = slot.org(),
587                    project = slot.project(),
588                    label = slot.label(),
589                    session_id = %session_id,
590                    "no record places this session's working directory, so its transcript \
591                     cannot be looked up; drawing the conversation empty",
592                );
593                return None;
594            };
595            PathBuf::from(recorded)
596        } else {
597            cwd_raw.to_path_buf()
598        };
599        let cwd = self.workspace.git_scan_cwd_for_session(slot, &cwd_raw);
600        Some((session_id, cwd.to_string_lossy().into_owned()))
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use std::path::Path;
607    use std::sync::Arc;
608
609    use forge_primitives::SessionSlot;
610    use forge_workspace::Workspace;
611
612    use super::ViewSurface;
613    use crate::test_support::logged;
614
615    /// The claude versions are facts about the world rather than about a
616    /// viewer, so a view reads the core's answer instead of probing for
617    /// itself: nothing probed yet reads as nothing.
618    #[tokio::test]
619    async fn the_surface_reads_the_claude_version_the_core_holds() {
620        let (workspace, _dir) = crate::surface::testing::workspace();
621        let surface = ViewSurface::new(Arc::clone(&workspace));
622
623        assert!(surface.cli_version().is_none(), "nothing probed yet reads as nothing");
624
625        workspace.seed_test_cli_version(Some("2.1.156"), Some("2.1.201"));
626        let held = surface.cli_version().expect("the core's snapshot reaches the view");
627
628        assert_eq!(
629            held.installed.as_deref(),
630            Some("2.1.156"),
631            "the verb carries the installed version the core holds",
632        );
633        assert_eq!(held.latest.as_deref(), Some("2.1.201"), "and the published one beside it");
634    }
635
636    /// The mirror is the call site, not the fanout. Both verbs sit on one
637    /// `UpdateFanout`, whose own test pins its two halves; what this pins
638    /// is which half each one calls.
639    ///
640    /// **The role decides whether the core may park a prompt on a reply.**
641    /// A view that draws a dock while declaring itself an observer draws
642    /// prompts that are already dead - the workspace resolves a permission,
643    /// question or Slack draft `Cancelled` when no subscriber can answer -
644    /// and nothing in a build or a test would say so. This is the one place
645    /// in the tree that tells the two apart.
646    #[tokio::test]
647    async fn a_surface_client_subscribes_with_the_role_it_declared() {
648        let (workspace, _dir) = crate::surface::testing::workspace();
649        let surface = ViewSurface::new(Arc::clone(&workspace));
650
651        let _answering = surface.subscribe_client(true);
652        let _mirror = surface.subscribe_client(false);
653        let _fold = surface.subscribe_mirror();
654
655        assert_eq!(
656            workspace.test_answering_count(),
657            1,
658            "only the client that declared it answers counts as an answerer",
659        );
660        assert_eq!(workspace.test_subscriber_count(), 3, "each verb registers a stream of its own");
661    }
662
663    const SESSION_A: &str = "5b1c2d3e-4f50-4a61-b728-9c0d1e2f3a45";
664    const SESSION_B: &str = "6c2d3e4f-5061-4b72-8839-ad1e2f3a4b56";
665
666    /// Write `turns` user rows as the transcript of `session_id`, under the
667    /// project key `cwd` resolves to: where the CLI would have left them.
668    fn seed_transcript(projects: &Path, session_id: &str, turns: usize) {
669        use std::fmt::Write as _;
670        let mut body = String::new();
671        for i in 0..turns {
672            let _ = writeln!(
673                body,
674                "{{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"turn {i}\"}}}}"
675            );
676        }
677        std::fs::write(projects.join(format!("{session_id}.jsonl")), body).expect("write");
678    }
679
680    /// A worker's transcript is written under its worktree's project key,
681    /// not the project root's, so the verb has to resolve the cwd the way
682    /// the session verb beside it does: a caller handing it the project
683    /// root still reads the worker's own conversation rather than an
684    /// empty one.
685    #[test]
686    fn conversation_resolves_a_workers_worktree() {
687        let dir = tempfile::tempdir().expect("tempdir");
688        let (workspace, _updates) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
689        let project_root = dir.path().join("project");
690        std::fs::create_dir_all(&project_root).expect("project dir");
691        workspace.seed_test_project("forge", &project_root.to_string_lossy());
692
693        let worker = SessionSlot::worker("TestOrg", "forge", "probe-a");
694        let worktree = project_root.join(".claude/worktrees/probe-a");
695        std::fs::create_dir_all(&worktree).expect("worktree");
696        workspace.seed_test_running_session_id(&worker, SESSION_A);
697        let project = ViewSurface::new(Arc::clone(&workspace))
698            .roster()
699            .project_named("forge")
700            .expect("seeded project")
701            .key
702            .clone();
703        workspace.insert_live_worker(
704            &project,
705            forge_workspace::WorkerEntry {
706                label: "probe-a".to_owned(),
707                charter: "charter".to_owned(),
708                slot: worker.clone(),
709                session_id: None,
710                status: forge_primitives::WorkerLiveness::Running,
711                spawned_at: std::time::SystemTime::UNIX_EPOCH,
712                spawned_by: SessionSlot::lead("TestOrg", "forge"),
713                needs_tag: false,
714                is_git_repo_at_spawn: true,
715                cwd: None,
716                diagnostic: None,
717                kick: None,
718            },
719        );
720
721        let worktree_str = worktree.to_string_lossy().into_owned();
722        let projects = dir.path().join("projects").join(
723            forge_workspace::userdata::catalog::scan::project_key_for_directory(Some(
724                &worktree_str,
725            )),
726        );
727        std::fs::create_dir_all(&projects).expect("projects dir");
728        seed_transcript(&projects, SESSION_A, 2);
729
730        let read = ViewSurface::new(Arc::clone(&workspace)).conversation(&worker, &project_root);
731
732        assert_eq!(read.messages.len(), 2, "the worker reads its worktree transcript");
733    }
734
735    /// An empty `cwd_raw` is a state a project picker produces, and the read
736    /// must not carry it on. The slot's own record places the read instead,
737    /// so a caller that holds no path still gets the session's conversation.
738    /// The fixture writes a real `forge.toml`: the project has to be one the
739    /// workspace loaded, not one only the test overlay knows.
740    #[test]
741    fn conversation_places_an_empty_cwd_from_the_session_record() {
742        let (workspace, _dir) = crate::surface::testing::workspace();
743        let lead = SessionSlot::lead("TestOrg", "forge");
744        workspace.seed_test_running_session_id(&lead, SESSION_A);
745
746        // The fixture's one project lives at `/tmp`; the transcript lands
747        // under its key inside the fixture's own config dir.
748        let projects = workspace.config_dir().join("projects").join(
749            forge_workspace::userdata::catalog::scan::project_key_for_directory(Some("/tmp")),
750        );
751        std::fs::create_dir_all(&projects).expect("projects dir");
752        seed_transcript(&projects, SESSION_A, 2);
753
754        let read = ViewSurface::new(Arc::clone(&workspace)).conversation(&lead, Path::new(""));
755
756        assert_eq!(read.messages.len(), 2, "the record places the read the caller could not");
757    }
758
759    /// **A dispatch row's expansion is drawn from these frames.** The read
760    /// folds a sidecar in under its dispatch, and the page must carry the
761    /// sub-agent's own rows so the chat can render the call's timeline; the
762    /// client's row list is what skips them, not this read (#1763).
763    #[test]
764    fn conversation_carries_subagent_frames() {
765        let (workspace, _dir) = crate::surface::testing::workspace();
766        let lead = SessionSlot::lead("TestOrg", "forge");
767        workspace.seed_test_running_session_id(&lead, SESSION_A);
768        let projects = workspace.config_dir().join("projects").join(
769            forge_workspace::userdata::catalog::scan::project_key_for_directory(Some("/tmp")),
770        );
771        std::fs::create_dir_all(&projects).expect("projects dir");
772        std::fs::write(
773            projects.join(format!("{SESSION_A}.jsonl")),
774            "{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_a\",\"name\":\"Agent\",\"input\":{\"prompt\":\"x\"}}]},\"uuid\":\"a1\"}\n\
775             {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_a\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n",
776        )
777        .expect("write");
778        let sidecars = projects.join(SESSION_A).join("subagents");
779        std::fs::create_dir_all(&sidecars).expect("subagents dir");
780        std::fs::write(
781            sidecars.join("agent-abc.meta.json"),
782            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_a\"}",
783        )
784        .expect("meta");
785        std::fs::write(
786            sidecars.join("agent-abc.jsonl"),
787            "{\"type\":\"assistant\",\"message\":{\"id\":\"msg_2\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[{\"type\":\"text\",\"text\":\"child\"}]},\"uuid\":\"c1\"}\n",
788        )
789        .expect("sidecar");
790
791        let read = ViewSurface::new(Arc::clone(&workspace)).conversation(&lead, Path::new(""));
792
793        assert_eq!(
794            read.messages.len(),
795            3,
796            "the transcript's two rows plus the sidecar frame the row expansion draws",
797        );
798        assert!(
799            read.messages.iter().any(|message| matches!(
800                message,
801                forge_primitives::Message::Assistant {
802                    parent_tool_use_id: Some(parent),
803                    ..
804                } if parent == "toolu_a"
805            )),
806            "the sub-agent frame is carried and names the dispatch it ran under",
807        );
808    }
809
810    /// **A sub-agent's refused result survives the read with its error mark.**
811    /// The sidecar fold itself is #1763's; this pins the half a resumed page
812    /// needs: the denial comes through as an errored `tool_result` whose
813    /// content still carries the refusal's words, under the expansion that
814    /// owns it. No measured capture keeps a `permission_denied` row for a
815    /// sub-agent's call, so the sidecar row is the only thing a reload can
816    /// draw it from.
817    #[test]
818    fn conversation_carries_a_subagents_denied_result() {
819        let (workspace, _dir) = crate::surface::testing::workspace();
820        let lead = SessionSlot::lead("TestOrg", "forge");
821        workspace.seed_test_running_session_id(&lead, SESSION_A);
822        let projects = workspace.config_dir().join("projects").join(
823            forge_workspace::userdata::catalog::scan::project_key_for_directory(Some("/tmp")),
824        );
825        std::fs::create_dir_all(&projects).expect("projects dir");
826        std::fs::write(
827            projects.join(format!("{SESSION_A}.jsonl")),
828            "{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_a\",\"name\":\"Agent\",\"input\":{\"prompt\":\"x\"}}]},\"uuid\":\"a1\"}\n\
829             {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_a\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n",
830        )
831        .expect("write");
832        let sidecars = projects.join(SESSION_A).join("subagents");
833        std::fs::create_dir_all(&sidecars).expect("subagents dir");
834        std::fs::write(
835            sidecars.join("agent-abc.meta.json"),
836            "{\"agentType\":\"general-purpose\",\"toolUseId\":\"toolu_a\"}",
837        )
838        .expect("meta");
839        std::fs::write(
840            sidecars.join("agent-abc.jsonl"),
841            "{\"type\":\"assistant\",\"agentId\":\"agent-abc\",\"message\":{\"id\":\"msg_2\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_refused\",\"name\":\"Bash\",\"input\":{\"command\":\"rm -rf /tmp/x\"}}]},\"uuid\":\"c1\"}\n\
842             {\"type\":\"user\",\"agentId\":\"agent-abc\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"content\":\"Permission to use Bash with command rm -rf /tmp/x has been denied.\",\"is_error\":true,\"tool_use_id\":\"call_refused\"}]},\"uuid\":\"c2\"}\n",
843        )
844        .expect("sidecar");
845
846        let read = ViewSurface::new(Arc::clone(&workspace)).conversation(&lead, Path::new(""));
847
848        let denied = read
849            .messages
850            .iter()
851            .find_map(|message| match message {
852                forge_primitives::Message::User { parent_tool_use_id, message, .. }
853                    if parent_tool_use_id.as_deref() == Some("toolu_a") =>
854                {
855                    message.content.iter().find_map(|block| match block {
856                        forge_primitives::ContentBlock::ToolResult {
857                            tool_use_id,
858                            is_error,
859                            content,
860                            ..
861                        } if tool_use_id == "call_refused" && *is_error => Some(content.clone()),
862                        _ => None,
863                    })
864                }
865                _ => None,
866            })
867            .expect("the sidecar's refused result, under the dispatch it ran under");
868        assert!(
869            format!("{denied:?}").contains("has been denied"),
870            "and it carries the refusal's own words: {denied:?}",
871        );
872    }
873
874    /// A slot whose cwd no record places reads as empty, and not as whatever
875    /// a walk of every project happened to find: given no directory, the scan
876    /// answers with the first `<session_id>.jsonl` on disk, which here is
877    /// another project directory's copy of the same session.
878    #[test]
879    fn conversation_does_not_scan_every_project_for_an_empty_cwd() {
880        let dir = tempfile::tempdir().expect("tempdir");
881        let (workspace, _updates) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
882        let stranger = dir.path().join("projects").join("some-other-project");
883        std::fs::create_dir_all(&stranger).expect("projects dir");
884        seed_transcript(&stranger, SESSION_A, 2);
885
886        let unplaced = SessionSlot::lead("TestOrg", "not-a-project");
887        workspace.seed_test_running_session_id(&unplaced, SESSION_A);
888
889        let read = ViewSurface::new(Arc::clone(&workspace)).conversation(&unplaced, Path::new(""));
890
891        assert!(read.messages.is_empty(), "no recorded cwd reads as empty, not as a stranger's");
892    }
893
894    /// The verb reads the slot's own transcript, and a slot with no session
895    /// reads as empty: a view arriving before a session exists must render an
896    /// empty conversation, not another session's.
897    #[test]
898    fn conversation_reads_the_slot_and_not_a_neighbour() {
899        let dir = tempfile::tempdir().expect("tempdir");
900        let (workspace, _updates) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
901        let cwd = dir.path().join("project");
902        std::fs::create_dir_all(&cwd).expect("project dir");
903        let cwd_str = cwd.to_string_lossy().into_owned();
904        let projects = dir.path().join("projects").join(
905            forge_workspace::userdata::catalog::scan::project_key_for_directory(Some(&cwd_str)),
906        );
907        std::fs::create_dir_all(&projects).expect("projects dir");
908        seed_transcript(&projects, SESSION_A, 2);
909        seed_transcript(&projects, SESSION_B, 3);
910
911        // One project, three seats: the neighbours differ by label rather
912        // than by project, so a read that resolves the wrong seat is the
913        // one this catches.
914        let a = SessionSlot::lead("TestOrg", "forge");
915        let b = SessionSlot::worker("TestOrg", "forge", "probe-a");
916        let idle = SessionSlot::worker("TestOrg", "forge", "asleep");
917        workspace.seed_test_running_session_id(&a, SESSION_A);
918        workspace.seed_test_running_session_id(&b, SESSION_B);
919
920        let surface = ViewSurface::new(Arc::clone(&workspace));
921        let read_a = surface.conversation(&a, &cwd);
922        let read_b = surface.conversation(&b, &cwd);
923
924        assert_eq!(read_a.messages.len(), 2, "each slot reads its own transcript");
925        assert_eq!(read_b.messages.len(), 3, "and the neighbour reads its own, not this one");
926        let none = surface.conversation(&idle, &cwd);
927        assert!(none.messages.is_empty(), "a slot with no session reads as empty");
928    }
929
930    /// A view drawing an empty conversation with nothing recorded gives a
931    /// reader no way to tell a session with no transcript from a read that
932    /// never happened, so each of the two silent paths says which it was,
933    /// and names the slot it was silent for.
934    #[test]
935    fn an_empty_conversation_records_which_kind_of_empty_it_was() {
936        let dir = tempfile::tempdir().expect("tempdir");
937        let (workspace, _updates) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
938        let surface = ViewSurface::new(Arc::clone(&workspace));
939
940        // Whether one record carries `event_name`, at debug, naming all three
941        // parts of `slot`: the org, project and label a reader acts on.
942        let names = |events: &[(tracing::Level, String)], event_name: &str, slot: &SessionSlot| {
943            events.iter().any(|(level, fields)| {
944                *level == tracing::Level::DEBUG
945                    && fields.contains(event_name)
946                    && fields.contains(&format!(r#"org="{}""#, slot.org()))
947                    && fields.contains(&format!(r#"project="{}""#, slot.project()))
948                    && fields.contains(&format!(r#"label="{}""#, slot.label()))
949            })
950        };
951
952        let idle = SessionSlot::lead("TestOrg", "forge");
953        let no_session = logged(|| {
954            let read = surface.conversation(&idle, Path::new("/tmp"));
955            assert!(read.messages.is_empty(), "a slot with no session still reads as empty");
956        });
957        assert!(
958            names(&no_session, "conversation_no_session", &idle),
959            "the slot with no session at all is named, and so is what was missing: \
960             {no_session:?}",
961        );
962
963        let unplaced = SessionSlot::lead("TestOrg", "not-a-project");
964        workspace.seed_test_running_session_id(&unplaced, SESSION_A);
965        let no_cwd = logged(|| {
966            let read = surface.conversation(&unplaced, Path::new(""));
967            assert!(read.messages.is_empty(), "an unplaced cwd still reads as empty");
968        });
969        let read_as_the_other =
970            no_cwd.iter().any(|(_, fields)| fields.contains("conversation_no_session"));
971        assert!(
972            names(&no_cwd, "conversation_cwd_unplaced", &unplaced)
973                && no_cwd.iter().any(|(_, fields)| fields.contains(SESSION_A))
974                && !read_as_the_other,
975            "a cwd no record places is named as its own reason and not as the other one, \
976             for the session it could not place: {no_cwd:?}",
977        );
978    }
979
980    /// The viewer's route hands the seat's project's document roots to the
981    /// read, so a document the hand-off accepted opens here too - and a
982    /// path outside the tree and every root keeps the refusal. Dropping
983    /// the roots from the read call fails this on the first read.
984    #[test]
985    fn the_viewer_route_reads_a_document_under_the_seats_document_roots() {
986        let tree = tempfile::tempdir().expect("tempdir");
987        let documents = tempfile::tempdir().expect("tempdir");
988        std::fs::write(documents.path().join("plan.md"), b"# plan\n").expect("the document");
989        let elsewhere = tempfile::tempdir().expect("tempdir");
990        std::fs::write(elsewhere.path().join("x.md"), b"x").expect("the outside document");
991        let (workspace, _dir) =
992            crate::surface::testing::workspace_with_document_roots(tree.path(), documents.path());
993        let surface = ViewSurface::new(Arc::clone(&workspace));
994        let seat = SessionSlot::lead("TestOrg", "forge");
995
996        let plan = documents.path().join("plan.md");
997        let document = surface
998            .read_document(&seat, &plan.to_string_lossy(), false)
999            .expect("the document opens");
1000        assert_eq!(document.bytes, b"# plan\n");
1001        assert_eq!(document.path, plan.canonicalize().expect("canonical"), "resolved");
1002
1003        let outside = elsewhere.path().join("x.md");
1004        let refusal = surface
1005            .read_document(&seat, &outside.to_string_lossy(), false)
1006            .expect_err("outside the tree and every root");
1007        assert!(
1008            refusal.contains("resolves outside the session's tree"),
1009            "the refusal sentence, unchanged: {refusal}"
1010        );
1011    }
1012}
1013
1014#[cfg(test)]
1015pub(crate) mod testing {
1016    use std::sync::Arc;
1017
1018    use forge_workspace::Workspace;
1019
1020    const CONFIG: &str = "\
1021[[orgs]]
1022name = \"TestOrg\"
1023accounts = [\"Granite\", \"OpenRouter-TM\"]
1024
1025[[orgs.projects]]
1026name = \"forge\"
1027path = \"/tmp\"
1028
1029[[accounts]]
1030display_name = \"Granite\"
1031token = \"t\"
1032models = [\"claude-sonnet-5\"]
1033provider = \"anthropic\"
1034
1035[[accounts]]
1036display_name = \"OpenRouter-TM\"
1037token = \"t\"
1038models = [\"deepseek-v4.1-flash\"]
1039provider = \"openrouter\"
1040base_url = \"https://openrouter.ai/api\"
1041
1042[dictate]
1043enabled = true
1044models_dir = \"/tmp/forge-dictate-models\"
1045";
1046
1047    /// A workspace over a tempdir config with two accounts and one
1048    /// project, with its store open, plus the tempdir that must outlive
1049    /// it.
1050    pub(crate) fn workspace() -> (Arc<Workspace>, tempfile::TempDir) {
1051        let dir = tempfile::tempdir().expect("tempdir");
1052        let forge = dir.path().join("forge");
1053        std::fs::create_dir_all(&forge).expect("forge/");
1054        std::fs::write(forge.join("forge.toml"), CONFIG).expect("write forge.toml");
1055        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
1056        workspace.install_db_for_test(
1057            forge_workspace::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1058        );
1059        (Arc::new(workspace), dir)
1060    }
1061
1062    /// A workspace whose one project is rooted at `root` and declares
1063    /// `document_roots`, its store open. Both come from `forge.toml`, so
1064    /// the seat's cwd and its document roots resolve the way production's
1065    /// do.
1066    pub(crate) fn workspace_with_document_roots(
1067        root: &std::path::Path,
1068        documents: &std::path::Path,
1069    ) -> (Arc<Workspace>, tempfile::TempDir) {
1070        let dir = tempfile::tempdir().expect("tempdir");
1071        let forge = dir.path().join("forge");
1072        std::fs::create_dir_all(&forge).expect("forge/");
1073        let config = format!(
1074            "\
1075[[orgs]]
1076name = \"TestOrg\"
1077accounts = [\"Granite\"]
1078
1079[[orgs.projects]]
1080name = \"forge\"
1081path = \"{}\"
1082document_roots = [\"{}\"]
1083
1084[[accounts]]
1085display_name = \"Granite\"
1086token = \"t\"
1087models = [\"claude-sonnet-5\"]
1088provider = \"anthropic\"
1089",
1090            root.display(),
1091            documents.display(),
1092        );
1093        std::fs::write(forge.join("forge.toml"), config).expect("write forge.toml");
1094        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
1095        workspace.install_db_for_test(
1096            forge_workspace::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
1097        );
1098        (Arc::new(workspace), dir)
1099    }
1100
1101    /// The fixture with the pricing refill driven by `fetcher`: the cache
1102    /// starts empty, so a scan answers unpriced and kicks the fetch, and
1103    /// the test owns what the fetch answers and when.
1104    pub(crate) fn workspace_with_pricing_fetcher(
1105        fetcher: forge_workspace::PricingFetcher,
1106    ) -> (Arc<Workspace>, tempfile::TempDir) {
1107        let dir = tempfile::tempdir().expect("tempdir");
1108        let forge = dir.path().join("forge");
1109        std::fs::create_dir_all(&forge).expect("forge/");
1110        std::fs::write(forge.join("forge.toml"), CONFIG).expect("write forge.toml");
1111        let workspace =
1112            Workspace::new_for_test_with_pricing_fetcher(dir.path().to_owned(), fetcher)
1113                .expect("workspace");
1114        (Arc::new(workspace), dir)
1115    }
1116
1117    /// The same fixture with a pricing snapshot already cached: the row is
1118    /// written into the store before the workspace opens it, the way the
1119    /// boot load expects to find it, so a scan prices from it with nothing
1120    /// left to fetch.
1121    pub(crate) fn workspace_with_pricing(json: &str) -> (Arc<Workspace>, tempfile::TempDir) {
1122        let dir = tempfile::tempdir().expect("tempdir");
1123        let forge = dir.path().join("forge");
1124        std::fs::create_dir_all(&forge).expect("forge/");
1125        std::fs::write(forge.join("forge.toml"), CONFIG).expect("write forge.toml");
1126        let app_support = dir.path().join("app-support");
1127        std::fs::create_dir_all(&app_support).expect("app-support/");
1128        {
1129            let db_path = forge_workspace::store::path_for(&app_support, dir.path());
1130            std::fs::create_dir_all(db_path.parent().expect("stores/")).expect("stores/");
1131            let db = forge_workspace::store::Db::open(&db_path).expect("open db");
1132            forge_workspace::store::pricing::store(
1133                &db,
1134                &forge_workspace::store::pricing::CachedPricing {
1135                    fetched_at: std::time::SystemTime::now(),
1136                    json: json.to_owned(),
1137                },
1138            )
1139            .expect("seed the pricing cache");
1140        }
1141        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
1142        (Arc::new(workspace), dir)
1143    }
1144
1145    /// A workspace whose durable Gotify and Slack subscriptions were
1146    /// written before it opened its store, so the boot load puts them in
1147    /// memory and the reads see real rows.
1148    pub(crate) fn workspace_with_connector_subs() -> (Arc<Workspace>, tempfile::TempDir) {
1149        let dir = tempfile::tempdir().expect("tempdir");
1150        let forge = dir.path().join("forge");
1151        std::fs::create_dir_all(&forge).expect("forge/");
1152        std::fs::write(forge.join("forge.toml"), CONFIG).expect("write forge.toml");
1153        let app_support = dir.path().join("app-support");
1154        std::fs::create_dir_all(&app_support).expect("app-support/");
1155        {
1156            // `new_for_test` opens this exact path and loads both sets at
1157            // construction, so the rows have to be there first.
1158            let db_path = forge_workspace::store::path_for(&app_support, dir.path());
1159            std::fs::create_dir_all(db_path.parent().expect("stores/")).expect("stores/");
1160            let db = forge_workspace::store::Db::open(&db_path).expect("open db");
1161            forge_workspace::store::gotify::insert(&db, &gotify_sub("forge"))
1162                .expect("insert gotify sub");
1163            forge_workspace::store::slack::insert(&db, &slack_sub("forge"))
1164                .expect("insert slack sub");
1165        }
1166        let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
1167        (Arc::new(workspace), dir)
1168    }
1169
1170    pub(crate) fn gotify_sub(project: &str) -> forge_primitives::GotifySubscription {
1171        forge_primitives::GotifySubscription {
1172            id: uuid::Uuid::new_v4(),
1173            project: project.to_owned(),
1174            team_role: None,
1175            applications: vec!["forge".to_owned()],
1176            min_priority: None,
1177            created_at: std::time::SystemTime::UNIX_EPOCH,
1178        }
1179    }
1180
1181    pub(crate) fn slack_sub(project: &str) -> forge_primitives::slack::SlackSubscription {
1182        forge_primitives::slack::SlackSubscription {
1183            id: uuid::Uuid::new_v4(),
1184            workspace: "acme".to_owned(),
1185            project: project.to_owned(),
1186            team_role: None,
1187            target: forge_primitives::slack::SlackSubscriptionTarget::DirectMessages,
1188            created_at: std::time::SystemTime::UNIX_EPOCH,
1189        }
1190    }
1191
1192    /// **The config's KB crosses as bytes, and `0` as whole** (#2374): the
1193    /// primitives test pins the field's parse, and this one pins the mapping
1194    /// the served-page cap is read through - dropping the `* 1024` would omit
1195    /// every record over 64 bytes with the suite green.
1196    #[test]
1197    fn the_record_cap_crosses_kilobytes_and_zero_means_whole() {
1198        assert_eq!(super::cap_bytes(64), 65_536, "64 KB is 65536 bytes");
1199        assert_eq!(super::cap_bytes(0), usize::MAX, "0 serves the record whole");
1200    }
1201}