1pub 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;
37pub 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
48pub struct ViewSurface {
50 workspace: Arc<Workspace>,
51 usage_cache:
55 std::sync::Mutex<Option<(std::time::Instant, forge_primitives::token_usage::UsageReport)>>,
56 pricing_refill: std::sync::atomic::AtomicBool,
60 usage_scan: tokio::sync::Mutex<()>,
63 #[cfg(any(test, feature = "testing"))]
65 usage_scans: std::sync::atomic::AtomicU64,
66 #[cfg(any(test, feature = "testing"))]
69 usage_gate: std::sync::Mutex<Option<Arc<tokio::sync::Barrier>>>,
70}
71
72pub use forge_workspace::SeatUpdate;
76pub use forge_workspace::SessionUpdate;
77
78pub fn is_success_result(is_error: bool, subtype: &str) -> bool {
82 !is_error && subtype == "success"
83}
84
85fn 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 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 None => self.workspace.open_terminal(seat)?,
125 };
126 entry.attached.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
127 Ok(entry)
128 }
129
130 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 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 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 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 pub fn terminal_held(&self, seat: &SessionSlot) -> bool {
170 self.workspace.terminal_held(seat)
171 }
172
173 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 #[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 #[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 pub fn tool_record_cap_bytes(&self) -> usize {
208 cap_bytes(self.workspace.server_config().tool_record_cap_kb)
209 }
210
211 pub fn first_frame_cap_bytes(&self) -> usize {
215 cap_bytes(self.workspace.server_config().first_frame_cap_kb)
216 }
217
218 pub fn more_page_cap_bytes(&self) -> usize {
222 cap_bytes(self.workspace.server_config().more_page_cap_kb)
223 }
224
225 pub fn dispatch(&self, command: Command) -> Result<(), DispatchError> {
232 self.workspace.dispatch_from_view(command)
235 }
236
237 pub fn fatal_error(&self) -> Option<forge_primitives::error::AppError> {
243 self.workspace.last_fatal_error()
244 }
245
246 pub fn service_status(&self) -> Option<forge_primitives::cloud::service_status::ServiceIssue> {
253 self.workspace.service_status()
254 }
255
256 pub fn pending_asks(&self, slot: &SessionSlot) -> Vec<PendingAsk> {
260 self.workspace.pending_asks(slot)
261 }
262
263 pub fn subscribe_mirror(&self) -> tokio::sync::mpsc::UnboundedReceiver<SeatUpdate> {
273 self.workspace.subscribe_mirror()
274 }
275
276 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 pub fn roster(&self) -> Roster {
300 Roster::collect(Arc::clone(&self.workspace))
301 }
302
303 pub fn session(&self, slot: &SessionSlot, cwd_raw: &Path) -> SessionState {
306 SessionState::collect(&self.workspace, slot, cwd_raw)
307 }
308
309 pub fn seat_next_seq(&self, slot: &SessionSlot) -> u64 {
314 self.workspace.seat_next_seq(slot)
315 }
316
317 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 pub fn seat_oldest_kept_seq(&self, slot: &SessionSlot) -> Option<u64> {
328 self.workspace.seat_oldest_kept_seq(slot)
329 }
330
331 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 pub fn seat_epoch_opened_with_rows(&self, slot: &SessionSlot) -> bool {
342 self.workspace.seat_epoch_opened_with_rows(slot)
343 }
344
345 pub fn workers(&self) -> Workers {
347 Workers::collect(Arc::clone(&self.workspace))
348 }
349
350 pub fn cli_version(&self) -> Option<CliVersionInfo> {
355 self.workspace.cli_version()
356 }
357
358 pub fn client_attached(&self) {
362 self.workspace.note_client_attached();
363 }
364
365 pub fn client_detached(&self) {
367 self.workspace.note_client_detached();
368 }
369
370 pub fn server_config(&self) -> forge_primitives::ServerConfig {
374 self.workspace.server_config()
375 }
376
377 pub fn client_config(&self) -> forge_primitives::ClientConfig {
380 self.workspace.client_config()
381 }
382
383 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 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 pub fn document_max_bytes(&self) -> u64 {
424 self.workspace.document_max_bytes()
425 }
426
427 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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 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 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 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 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 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 #[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}