1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use forge_workspace::Workspace;
12
13pub fn resolve_config_dir() -> anyhow::Result<PathBuf> {
23 if let Ok(value) = std::env::var("CLAUDE_CONFIG_DIR") {
24 let trimmed = value.trim_end_matches('/');
25 if !trimmed.is_empty() {
26 return Ok(PathBuf::from(trimmed));
27 }
28 }
29 dirs::home_dir().map(|h| h.join(".claude")).ok_or_else(|| {
30 anyhow::anyhow!("$CLAUDE_CONFIG_DIR unset and could not resolve home directory")
31 })
32}
33
34struct PanicRecord {
36 thread: String,
37 payload: String,
38 location: String,
39 backtrace: Option<String>,
40}
41
42pub fn install_panic_hook() {
49 static INSTALLED: std::sync::Once = std::sync::Once::new();
50 INSTALLED.call_once(|| {
51 let previous = std::panic::take_hook();
52 std::panic::set_hook(Box::new(move |info| {
53 let thread = std::thread::current();
54 let record =
55 panic_record(thread.name().unwrap_or("unnamed"), info.payload(), info.location());
56 tracing::error!(
57 target: crate::logging::targets::APP_LIFECYCLE,
58 event_name = "panic",
59 thread = %record.thread,
60 payload = %record.payload,
61 location = %record.location,
62 backtrace = %record.backtrace.as_deref().unwrap_or(""),
63 "a thread panicked",
64 );
65 previous(info);
66 }));
67 });
68}
69
70fn panic_record(
73 thread: &str,
74 payload: &(dyn std::any::Any + Send),
75 location: Option<&std::panic::Location<'_>>,
76) -> PanicRecord {
77 let payload = payload
78 .downcast_ref::<&str>()
79 .map(|text| (*text).to_owned())
80 .or_else(|| payload.downcast_ref::<String>().cloned())
81 .unwrap_or_else(|| "a panic with no message".to_owned());
82 let location = location
83 .map_or_else(String::new, |location| format!("{}:{}", location.file(), location.line()));
84 let backtrace = std::env::var_os("RUST_BACKTRACE")
85 .filter(|value| value.to_str() != Some("0"))
86 .map(|_| std::backtrace::Backtrace::force_capture().to_string());
87 PanicRecord { thread: thread.to_owned(), payload, location, backtrace }
88}
89
90fn install_crypto_provider() {
98 let already_installed =
99 rustls::crypto::aws_lc_rs::default_provider().install_default().is_err();
100 tracing::debug!(
101 target: crate::logging::targets::APP_LIFECYCLE,
102 event_name = "rustls_crypto_provider",
103 already_installed,
104 "the process-level rustls crypto provider is in place",
105 );
106}
107
108pub fn start_core(config_dir: &Path, project: Option<&str>) -> anyhow::Result<Arc<Workspace>> {
122 install_crypto_provider();
126 install_panic_hook();
127 let workspace = match Workspace::new(config_dir.to_path_buf()) {
128 Ok(w) => Arc::new(w),
129 Err(err) => return Err(anyhow::anyhow!("forge: {err}")),
130 };
131 workspace.set_settings_version(crate::FORGE_VERSION_SHORT);
135
136 if let Some(name) = project
141 && let Err(err) = workspace.validate_project_name(name)
142 {
143 return Err(anyhow::anyhow!("forge: {err}"));
144 }
145
146 install_shipped_skills(config_dir);
147 start_subsystems(&workspace);
148 #[cfg(target_os = "macos")]
149 {
150 let fleet = install_browser(&workspace)?;
151 start_config_watch(&workspace, move |workspace| {
152 refresh_browser(workspace, &fleet);
153 });
154 }
155 #[cfg(not(target_os = "macos"))]
156 {
157 install_browser(&workspace);
158 start_config_watch(&workspace, |_workspace| {});
159 }
160
161 Ok(workspace)
162}
163
164fn install_shipped_skills(config_dir: &Path) {
171 for skill in forge_workspace::shipped_skills() {
172 let dir = forge_workspace::skill_dir(config_dir, skill.name);
173 match forge_workspace::install_skill(config_dir, skill) {
174 Ok(outcome) => {
175 let Some((action, message)) = install_line(outcome) else { continue };
176 tracing::info!(
177 target: "forge_workspace::skills",
178 event_name = skill_installed_event(skill.name),
179 skill = skill.name,
180 path = %dir.display(),
181 action,
182 "{message}",
183 );
184 }
185 Err(error) => {
186 tracing::warn!(
187 target: "forge_workspace::skills",
188 event_name = skill_install_failed_event(skill.name),
189 skill = skill.name,
190 path = %dir.display(),
191 consequence = skill_failure_consequence(skill.name),
192 %error,
193 "the skill could not be installed",
194 );
195 }
196 }
197 }
198}
199
200fn install_line(outcome: forge_workspace::SkillInstall) -> Option<(&'static str, &'static str)> {
205 match outcome {
206 forge_workspace::SkillInstall::Created => {
207 Some(("created", "the skill was written at boot"))
208 }
209 forge_workspace::SkillInstall::Replaced => {
210 Some(("replaced", "the skill was written at boot"))
211 }
212 forge_workspace::SkillInstall::Swept => {
213 Some(("swept", "the skill's leftover files were due for removal at boot"))
217 }
218 forge_workspace::SkillInstall::Unchanged => None,
219 }
220}
221
222fn skill_failure_consequence(name: &str) -> &'static str {
229 match name {
230 "handoff" => "a handoff turn falls back to the prompt's own steps",
231 _ => "the skill may be missing or partly updated until a later boot writes it",
232 }
233}
234
235fn skill_installed_event(name: &str) -> &'static str {
239 match name {
240 "handoff" => "handoff_skill_installed",
241 "wizard" => "wizard_skill_installed",
242 _ => "skill_installed",
243 }
244}
245
246fn skill_install_failed_event(name: &str) -> &'static str {
247 match name {
248 "handoff" => "handoff_skill_install_failed",
249 "wizard" => "wizard_skill_install_failed",
250 _ => "skill_install_failed",
251 }
252}
253
254fn start_config_watch<F>(workspace: &Arc<Workspace>, apply: F)
262where
263 F: Fn(&Workspace) + Send + Sync + 'static,
264{
265 let path = workspace.config_path();
266 let weak = Arc::downgrade(workspace);
267 let apply = Arc::new(apply);
268 let (changed, edits) = tokio::sync::mpsc::unbounded_channel();
273 let first = match forge_workspace::env::config_watch::watch(path.clone(), changed) {
274 Ok(watch) => Some((watch, edits)),
275 Err(error) => {
276 tracing::warn!(
277 target: crate::logging::targets::APP_LIFECYCLE,
278 event_name = "config_watch_failed",
279 path = %path.display(),
280 error = %error,
281 "forge.toml is not being watched; an edit needs a restart this run",
282 );
283 return;
284 }
285 };
286 let first = Arc::new(std::sync::Mutex::new(first));
287 workspace.supervisor().spawn("config_watch", forge_supervise::Policy::backoff(), move || {
288 let path = path.clone();
289 let weak = weak.clone();
290 let apply = Arc::clone(&apply);
291 let first = Arc::clone(&first);
292 async move {
293 let first_watch =
294 first.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
295 let (watch, mut edits) = if let Some(pair) = first_watch {
296 pair
297 } else {
298 let (changed, edits) = tokio::sync::mpsc::unbounded_channel();
301 match forge_workspace::env::config_watch::watch(path.clone(), changed) {
302 Ok(watch) => (watch, edits),
303 Err(error) => {
304 tracing::warn!(
305 target: crate::logging::targets::APP_LIFECYCLE,
306 event_name = "config_watch_failed",
307 path = %path.display(),
308 error = %error,
309 "forge.toml is not being watched; an edit needs a restart this run",
310 );
311 return;
312 }
313 }
314 };
315 let _watch = watch;
319 loop {
320 let _ = edits.recv().await;
321 let Some(workspace) = weak.upgrade() else { break };
322 match workspace.reload_config() {
323 Ok(reload) => {
324 if reload.changed.is_empty() && reload.restart_only.is_empty() {
326 continue;
327 }
328 tracing::info!(
329 target: crate::logging::targets::APP_LIFECYCLE,
330 event_name = "config_reloaded",
331 changed = ?reload.changed,
332 restart_only = ?reload.restart_only,
333 "forge.toml applied to the running forge",
334 );
335 apply(&workspace);
336 }
337 Err(error) => {
338 tracing::warn!(
339 target: crate::logging::targets::APP_LIFECYCLE,
340 event_name = "config_reload_rejected",
341 path = %workspace.config_path().display(),
342 error = %error,
343 "forge.toml was not applied; the last good config stays live",
344 );
345 }
346 }
347 }
348 }
349 });
350}
351
352#[cfg(target_os = "macos")]
354fn refresh_browser(workspace: &Workspace, fleet: &Arc<forge_browser::service::BrowserService>) {
355 let browser = workspace.browser_settings();
356 fleet.configure_encoding(browser.fps, browser.quality);
357 fleet.configure_idle(browser.idle_minutes);
358 configure_fleet_profiles(workspace, fleet);
359}
360
361#[cfg(target_os = "macos")]
373fn configure_fleet_profiles(workspace: &Workspace, fleet: &forge_browser::service::BrowserService) {
374 let (orgs, projects) = workspace.browser_profile_config();
375 fleet.configure_profiles(orgs, projects);
376}
377
378#[cfg(target_os = "macos")]
379fn install_browser(
380 workspace: &Arc<Workspace>,
381) -> anyhow::Result<Arc<forge_browser::service::BrowserService>> {
382 let fleet = forge_browser::service::BrowserService::installed()
383 .map_err(|why| anyhow::anyhow!("forge: {why}"))?;
384 let browser = workspace.browser_settings();
387 fleet.configure_encoding(browser.fps, browser.quality);
388 fleet.configure_idle(browser.idle_minutes);
389 configure_fleet_profiles(workspace, &fleet);
392 fleet.set_supervisor(Arc::clone(workspace.supervisor()));
393 fleet.start_idle();
394 fleet.start_pairing();
395 let reaper = Arc::clone(&fleet);
408 let prune_workspace = Arc::downgrade(workspace);
409 let reap = async move { reaper.reap_stale().await };
410 tokio::spawn(reap_then_prune(reap, move || {
411 forge_browser::host_bundle::prune_other_versions(&move || {
412 prune_workspace.upgrade().is_some_and(|workspace| workspace.sole_instance())
413 });
414 }));
415 workspace.install_browser_host(Arc::new(HostedBrowser(Arc::clone(&fleet))));
416 Ok(fleet)
417}
418
419#[cfg(target_os = "macos")]
426async fn reap_then_prune<F>(reap: impl std::future::Future<Output = ()>, prune: F)
427where
428 F: FnOnce() + Send + 'static,
429{
430 reap.await;
431 let _ = tokio::task::spawn_blocking(prune).await;
432}
433
434#[cfg(not(target_os = "macos"))]
437fn install_browser(_workspace: &Arc<Workspace>) {}
438
439#[cfg(target_os = "macos")]
441struct HostedBrowser(Arc<forge_browser::service::BrowserService>);
442
443#[cfg(target_os = "macos")]
444#[async_trait::async_trait]
445impl forge_workspace::browser::BrowserHost for HostedBrowser {
446 async fn call(
447 &self,
448 seat: &forge_primitives::SessionSlot,
449 tool: &str,
450 args: serde_json::Value,
451 ) -> Result<Vec<forge_primitives::browser::BrowserPart>, String> {
452 self.0.call(seat, tool, args).await
453 }
454
455 async fn attach(
456 &self,
457 viewer: u64,
458 seat: &forge_primitives::SessionSlot,
459 tab: Option<u16>,
460 ) -> Result<forge_workspace::browser::BrowserAttachment, String> {
461 let attachment = self.0.attach(viewer, seat, tab).await?;
462 Ok(forge_workspace::browser::BrowserAttachment {
463 key: attachment.key,
464 id: attachment.id,
465 tab: attachment.tab,
466 frames: attachment.frames,
467 view: attachment.view,
468 tabs: attachment.tabs,
469 nav: attachment.nav,
470 failures: attachment.failures,
471 files: attachment.files,
472 downloads: attachment.downloads,
473 audio: attachment.audio,
474 })
475 }
476
477 async fn attach_page(
478 &self,
479 viewer: u64,
480 seat: &forge_primitives::SessionSlot,
481 target: &str,
482 ) -> Result<forge_workspace::browser::BrowserAttachment, String> {
483 let attachment = self.0.attach_page(viewer, seat, target).await?;
484 Ok(forge_workspace::browser::BrowserAttachment {
485 key: attachment.key,
486 id: attachment.id,
487 tab: attachment.tab,
488 frames: attachment.frames,
489 view: attachment.view,
490 tabs: attachment.tabs,
491 nav: attachment.nav,
492 failures: attachment.failures,
493 files: attachment.files,
494 downloads: attachment.downloads,
495 audio: attachment.audio,
496 })
497 }
498
499 async fn answer_files(
500 &self,
501 profile: &str,
502 tab: u16,
503 id: u64,
504 files: Vec<forge_primitives::browser::BrowserUpload>,
505 ) -> Result<(), String> {
506 self.0.answer_files(profile, tab, id, files).await
507 }
508
509 async fn detach(&self, viewer: u64) {
510 self.0.detach(viewer).await;
511 }
512
513 async fn input(
514 &self,
515 profile: &str,
516 tab: u16,
517 viewer: u64,
518 seq: u64,
519 event: &forge_primitives::browser::BrowserInputEvent,
520 ) -> Result<(), String> {
521 self.0.input(profile, tab, viewer, seq, event).await
522 }
523
524 async fn take_control(&self, profile: &str, tab: u16, viewer: u64) -> Result<(), String> {
525 self.0.take_control(profile, tab, viewer).await
526 }
527
528 async fn release_control(&self, profile: &str, tab: u16, viewer: u64) {
529 self.0.release_control(profile, tab, viewer).await;
530 }
531
532 async fn resize(
533 &self,
534 profile: &str,
535 tab: u16,
536 viewer: u64,
537 width: u32,
538 height: u32,
539 scale: f64,
540 ) {
541 self.0.resize(profile, tab, viewer, width, height, scale).await;
542 }
543
544 async fn nav(
545 &self,
546 profile: &str,
547 tab: u16,
548 viewer: u64,
549 back: bool,
550 ) -> Result<forge_primitives::browser::BrowserNavState, String> {
551 self.0.nav(profile, tab, viewer, back).await
552 }
553
554 async fn nav_state(
555 &self,
556 profile: &str,
557 tab: u16,
558 ) -> Option<forge_primitives::browser::BrowserNavState> {
559 self.0.nav_state(profile, tab).await
560 }
561
562 async fn tabs(
563 &self,
564 seat: &forge_primitives::SessionSlot,
565 ) -> Result<forge_primitives::browser::BrowserTabList, String> {
566 self.0.tabs(seat).await
567 }
568
569 async fn tab_stream(
570 &self,
571 seat: &forge_primitives::SessionSlot,
572 ) -> Result<tokio::sync::watch::Receiver<forge_primitives::browser::BrowserTabList>, String>
573 {
574 self.0.tab_stream(seat).await
575 }
576
577 async fn sweep_sessions(&self, seats: &[forge_primitives::SessionSlot], generation: u64) {
578 self.0.sweep_sessions(seats, generation).await;
579 }
580
581 async fn replace_seat(&self, seat: &forge_primitives::SessionSlot) {
582 self.0.replace_seat(seat).await;
583 }
584
585 fn session_ended(&self, seat: &forge_primitives::SessionSlot) {
586 self.0.session_ended(seat);
587 }
588
589 async fn hand_off_started(
590 &self,
591 seat: &forge_primitives::SessionSlot,
592 target: &str,
593 ) -> Result<u16, String> {
594 self.0.hand_off_started(seat, target).await
595 }
596
597 async fn hand_off_finished(&self, seat: &forge_primitives::SessionSlot, tab: u16) {
598 self.0.hand_off_finished(seat, tab).await;
599 }
600
601 async fn warm(&self, seat: &forge_primitives::SessionSlot) {
602 self.0.warm(seat).await;
603 }
604
605 async fn stop(&self) -> Vec<String> {
606 self.0.stop_all().await
607 }
608}
609
610fn start_subsystems(workspace: &Arc<Workspace>) {
616 workspace.start_account_loading_tasks();
622
623 workspace.start_usage_poller();
628
629 workspace.start_rotation_announcer();
633
634 workspace.start_gateway_listener();
639
640 workspace.start_dictate_catalogue();
645
646 workspace.start_dictate_preflight();
650
651 workspace.start_voice_prewarm();
654
655 workspace.start_kick_dispatcher();
661
662 workspace.fire_due_crons(std::time::SystemTime::now());
667
668 workspace.start_cron_scheduler();
671
672 workspace.start_chase_sweep();
676
677 workspace.start_idle_sweep();
682
683 workspace.start_gotify_subsystem();
686
687 workspace.start_slack_verification();
691
692 workspace.start_slack_subsystem();
696
697 workspace.start_review_branch_sweep();
701}
702
703pub enum SocketServing {
709 Disabled,
711 Bound,
713 BindFailed,
715}
716
717pub async fn serve_socket(workspace: &Arc<Workspace>) -> anyhow::Result<SocketServing> {
725 let server = workspace.server_config();
726 if !server.enabled {
727 tracing::debug!(
728 target: crate::logging::targets::APP_LIFECYCLE,
729 event_name = "server_socket_disabled",
730 "[server] enabled = false; nothing is serving this run",
731 );
732 return Ok(SocketServing::Disabled);
733 }
734 let addr = std::net::SocketAddr::new(server.bind, server.port);
735 let state = Arc::new(crate::transport::TransportState::for_workspace(workspace));
739 match tokio::net::TcpListener::bind(addr).await {
740 Ok(listener) => {
741 let bound = listener.local_addr().unwrap_or(addr);
742 tracing::info!(
743 target: crate::logging::targets::APP_LIFECYCLE,
744 event_name = "server_socket_listening",
745 %bound,
746 "the server's socket is listening",
747 );
748 let updates = state.surface.subscribe_mirror();
752 let listener = Arc::new(std::sync::Mutex::new(Some(listener)));
756 let updates = Arc::new(std::sync::Mutex::new(Some(updates)));
757 workspace.supervisor().spawn(
758 "socket_serve",
759 forge_supervise::Policy::Never,
760 move || {
761 let listener = Arc::clone(&listener);
762 let updates = Arc::clone(&updates);
763 let state = Arc::clone(&state);
764 async move {
765 let Some(listener) = listener
766 .lock()
767 .unwrap_or_else(std::sync::PoisonError::into_inner)
768 .take()
769 else {
770 return;
771 };
772 let Some(updates) = updates
773 .lock()
774 .unwrap_or_else(std::sync::PoisonError::into_inner)
775 .take()
776 else {
777 return;
778 };
779 if let Err(error) = crate::transport::serve(state, listener, updates).await
780 {
781 tracing::error!(
782 target: crate::logging::targets::APP_LIFECYCLE,
783 event_name = "server_socket_failed",
784 error = %error,
785 "the socket stopped serving",
786 );
787 }
788 }
789 },
790 );
791 Ok(SocketServing::Bound)
792 }
793 Err(error) => {
794 tracing::error!(
795 target: crate::logging::targets::APP_LIFECYCLE,
796 event_name = "server_socket_bind_failed",
797 error = %error,
798 "the socket is not serving",
799 );
800 Ok(SocketServing::BindFailed)
801 }
802 }
803}
804
805pub const BOOT_WAVE_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
814
815async fn wait_for_accounts(workspace: &Arc<Workspace>, timeout: std::time::Duration) -> bool {
822 let deadline = tokio::time::Instant::now() + timeout;
823 while !workspace.all_accounts_loaded() && tokio::time::Instant::now() < deadline {
824 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
825 }
826 workspace.all_accounts_loaded()
827}
828
829pub fn start_boot_wave(workspace: Arc<Workspace>, project: Option<String>, force_new: bool) {
847 tokio::spawn(async move {
848 if !wait_for_accounts(&workspace, BOOT_WAVE_WAIT).await {
849 tracing::warn!(
850 target: crate::logging::targets::APP_LIFECYCLE,
851 event_name = "boot_wave_wait_expired",
852 gateway_ready = workspace.gateway_ready(),
853 gateway_bind_error = workspace.gateway_bind_error().as_deref().unwrap_or(""),
854 "the boot spawn is going out with the accounts or the gateway listener not ready \
855 (a bound listener here means the account map is what held it); a refused spawn \
856 carries its own reason",
857 );
858 }
859 let targets = workspace.boot_wave_targets(project.as_deref());
866 for target in targets {
867 let launch_settings = forge_workspace::SessionLaunchSettings {
868 force_new,
869 resume_kick: target.resume_kick.clone(),
870 ..Default::default()
871 };
872 let project_name = target.project_name.clone();
873 let cmd = forge_workspace::Command::SpawnProject {
874 project_name: project_name.clone(),
875 launch_settings,
876 };
877 if let Err(err) = workspace.dispatch(cmd) {
878 tracing::error!(
879 target: crate::logging::targets::APP_LIFECYCLE,
880 event_name = "boot_wave_dispatch_failed",
881 error = %err,
882 project = %project_name,
883 "boot wave dispatch failed",
884 );
885 }
886 }
887 });
888}
889
890pub fn stop_result(unready: &[forge_primitives::SessionSlot]) -> anyhow::Result<()> {
899 if unready.is_empty() {
900 return Ok(());
901 }
902 let named: Vec<String> = unready.iter().map(forge_primitives::SessionSlot::display).collect();
903 let (subject, tail) = if unready.len() == 1 {
904 ("1 session".to_owned(), "its process tree may outlive")
905 } else {
906 (format!("{} sessions", unready.len()), "their process trees may outlive")
907 };
908 anyhow::bail!(
909 "forge: {subject} did not finish teardown before the wait ran out ({}); {tail} this exit",
910 named.join(", "),
911 );
912}
913
914#[cfg(test)]
915mod tests {
916 use super::{
917 BOOT_WAVE_WAIT, install_crypto_provider, install_shipped_skills, panic_record,
918 start_boot_wave, start_config_watch, start_subsystems, stop_result, wait_for_accounts,
919 };
920 #[cfg(target_os = "macos")]
921 use super::{configure_fleet_profiles, reap_then_prune};
922 use std::sync::Arc;
923 use std::time::Duration;
924
925 use forge_workspace::Workspace;
926
927 #[test]
931 fn the_panic_record_names_the_thread_and_the_payload() {
932 let record = panic_record("forge-worker", &"the pump died", None);
933 assert_eq!(record.thread, "forge-worker", "the thread is named");
934 assert_eq!(record.payload, "the pump died", "the payload is the panic's own words");
935 }
936
937 #[test]
943 fn installing_the_rustls_provider_twice_is_clean() {
944 install_crypto_provider();
945
946 install_crypto_provider();
947
948 assert!(
949 rustls::crypto::CryptoProvider::get_default().is_some(),
950 "the boot's install leaves a process-level rustls provider in place",
951 );
952 let _ = rustls::ClientConfig::builder();
955 }
956
957 #[test]
963 fn the_boot_entry_installs_the_provider_before_it_builds_anything() {
964 let dir = tempfile::tempdir().expect("tempdir");
965
966 let events = crate::test_support::logged(|| {
967 let booted = super::start_core(dir.path(), None);
968 assert!(booted.is_err(), "an empty config dir does not build a workspace");
969 });
970
971 assert!(
972 events.iter().any(|(level, fields)| {
973 *level == tracing::Level::DEBUG
974 && fields.contains("event_name=\"rustls_crypto_provider\"")
975 }),
976 "the boot entry logs the provider install before it builds anything: {events:?}",
977 );
978 }
979
980 #[test]
985 fn the_boot_names_each_skills_install_event() {
986 let dir = tempfile::tempdir().expect("tempdir");
987 let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
988
989 for (skill, event) in
990 [("handoff", "handoff_skill_installed"), ("wizard", "wizard_skill_installed")]
991 {
992 assert!(
993 events.iter().any(|(level, fields)| {
994 *level == tracing::Level::INFO
995 && fields.contains(&format!("event_name=\"{event}\""))
996 && fields.contains(&format!("skill=\"{skill}\""))
997 && fields.contains("action=\"created\"")
998 }),
999 "{skill}'s install line is named {event}: {events:?}",
1000 );
1001 }
1002 assert_eq!(super::skill_install_failed_event("handoff"), "handoff_skill_install_failed");
1003 assert_eq!(super::skill_install_failed_event("wizard"), "wizard_skill_install_failed");
1004
1005 let second = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1006 assert!(
1007 !second.iter().any(|(_, fields)| fields.contains("_skill_installed")
1008 || fields.contains("_skill_install_failed")),
1009 "an unchanged boot writes no install line: {second:?}",
1010 );
1011 }
1012
1013 #[test]
1016 fn a_replaced_install_names_its_action() {
1017 let dir = tempfile::tempdir().expect("tempdir");
1018 install_shipped_skills(dir.path());
1019 std::fs::write(
1020 forge_workspace::skill_dir(dir.path(), "wizard").join("SKILL.md"),
1021 b"edited",
1022 )
1023 .expect("edit");
1024
1025 let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1026 assert!(
1027 events.iter().any(|(level, fields)| {
1028 *level == tracing::Level::INFO
1029 && fields.contains("event_name=\"wizard_skill_installed\"")
1030 && fields.contains("action=\"replaced\"")
1031 }),
1032 "the rewrite is named: {events:?}",
1033 );
1034 }
1035
1036 #[test]
1039 fn a_sweep_names_what_it_removed() {
1040 let dir = tempfile::tempdir().expect("tempdir");
1041 install_shipped_skills(dir.path());
1042 std::fs::write(
1043 forge_workspace::skill_dir(dir.path(), "wizard").join("SKILL.md.tmp"),
1044 "half a write",
1045 )
1046 .expect("leftover");
1047
1048 let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1049 let has = |event: &str, level: tracing::Level| {
1050 events.iter().any(|(seen, fields)| {
1051 *seen == level && fields.contains(&format!("event_name=\"{event}\""))
1052 })
1053 };
1054 assert!(has("skill_file_removed", tracing::Level::DEBUG), "the path is named: {events:?}");
1055 assert!(has("skill_files_swept", tracing::Level::INFO), "the count is logged: {events:?}");
1056 assert!(
1057 events.iter().any(|(_, fields)| {
1058 fields.contains("event_name=\"wizard_skill_installed\"")
1059 && fields.contains("action=\"swept\"")
1060 }),
1061 "a sweep is not a write: {events:?}",
1062 );
1063 }
1064
1065 #[test]
1068 fn a_failed_removal_warns_and_the_install_still_reports() {
1069 use std::os::unix::fs::PermissionsExt;
1070 let dir = tempfile::tempdir().expect("tempdir");
1071 install_shipped_skills(dir.path());
1072 let skill_dir = forge_workspace::skill_dir(dir.path(), "wizard");
1073 std::fs::write(skill_dir.join("SKILL.md.tmp"), "half a write").expect("leftover");
1074 std::fs::set_permissions(&skill_dir, std::fs::Permissions::from_mode(0o555)).expect("mode");
1075 let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1076 std::fs::set_permissions(&skill_dir, std::fs::Permissions::from_mode(0o755)).expect("mode");
1077
1078 assert!(
1079 events.iter().any(|(level, fields)| {
1080 *level == tracing::Level::WARN
1081 && fields.contains("event_name=\"skill_file_remove_failed\"")
1082 }),
1083 "the failed removal warns: {events:?}",
1084 );
1085 assert!(
1086 events.iter().any(|(level, fields)| {
1087 *level == tracing::Level::INFO
1088 && fields.contains("event_name=\"wizard_skill_installed\"")
1089 && fields.contains("action=\"swept\"")
1090 }),
1091 "the install still reports: {events:?}",
1092 );
1093 }
1094
1095 #[test]
1098 fn a_symlinked_skill_directory_is_refused_with_a_warning() {
1099 use std::os::unix::fs::symlink;
1100 let dir = tempfile::tempdir().expect("tempdir");
1101 let outside = tempfile::tempdir().expect("outside");
1102 std::fs::create_dir_all(dir.path().join("skills")).expect("skills dir");
1103 symlink(outside.path(), forge_workspace::skill_dir(dir.path(), "wizard")).expect("symlink");
1104
1105 let events = crate::test_support::logged(|| install_shipped_skills(dir.path()));
1106 assert!(
1107 events.iter().any(|(level, fields)| {
1108 *level == tracing::Level::WARN
1109 && fields.contains("event_name=\"skill_dir_is_a_symlink\"")
1110 && fields.contains("skill=\"wizard\"")
1111 }),
1112 "the refusal is a warning naming the skill: {events:?}",
1113 );
1114 assert!(!outside.path().join("SKILL.md").exists(), "nothing is written through it");
1115 }
1116
1117 #[test]
1120 fn a_teardown_that_did_not_finish_stops_with_a_failure() {
1121 assert!(stop_result(&[]).is_ok(), "a clean stop is a success");
1122
1123 let slot = forge_primitives::SessionSlot::new("org", "proj", "slow");
1124 let error = stop_result(std::slice::from_ref(&slot)).expect_err("a left tree is a failure");
1125 let rendered = format!("{error}");
1126 assert!(rendered.contains("org/proj/slow"), "the failure names the session: {rendered}");
1127 assert!(rendered.contains("its process tree"), "one session reads as one: {rendered}");
1128
1129 let second = forge_primitives::SessionSlot::new("org", "proj", "slower");
1130 let both = [slot, second];
1131 let error = stop_result(&both).expect_err("two left trees are a failure");
1132 let rendered = format!("{error}");
1133 assert!(rendered.contains("2 sessions"), "two sessions read as two: {rendered}");
1134 assert!(rendered.contains("their process trees"), "and take the plural: {rendered}");
1135 }
1136
1137 fn boot_fixture() -> (Arc<Workspace>, tempfile::TempDir) {
1144 fixture(false)
1145 }
1146
1147 fn socket_fixture() -> (Arc<Workspace>, tempfile::TempDir) {
1149 fixture(true)
1150 }
1151
1152 fn fixture(serve: bool) -> (Arc<Workspace>, tempfile::TempDir) {
1157 let dir = tempfile::tempdir().expect("tempdir");
1158 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port");
1159 let port = listener.local_addr().expect("the port the OS handed out").port();
1160 drop(listener);
1161 let socket = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port");
1162 let socket_port = socket.local_addr().expect("the port the OS handed out").port();
1163 drop(socket);
1164 let forge = dir.path().join("forge");
1165 std::fs::create_dir_all(&forge).expect("forge/");
1166 std::fs::write(
1167 forge.join("forge.toml"),
1168 format!(
1169 "\
1170[[orgs]]
1171name = \"TestOrg\"
1172accounts = [\"Stub\"]
1173
1174[[orgs.projects]]
1175name = \"forge\"
1176path = \"/tmp\"
1177
1178[[orgs.projects]]
1179name = \"notes\"
1180path = \"/tmp\"
1181
1182[[accounts]]
1183display_name = \"Stub\"
1184token = \"t\"
1185provider = \"openrouter\"
1186base_url = \"http://127.0.0.1:9/api\"
1187models = [\"claude-sonnet-5\"]
1188
1189[server]
1190enabled = {serve}
1191port = {socket_port}
1192
1193[gateway]
1194port = {port}
1195",
1196 ),
1197 )
1198 .expect("write forge.toml");
1199 let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
1200 (Arc::new(workspace), dir)
1201 }
1202
1203 async fn spawned_projects(
1207 workspace: &Arc<Workspace>,
1208 ) -> Vec<(String, forge_workspace::SessionLaunchSettings)> {
1209 tokio::time::timeout(Duration::from_secs(10), async {
1210 loop {
1211 let spawned: Vec<_> = workspace
1212 .drain_test_dispatch_buffer()
1213 .into_iter()
1214 .filter_map(|command| match command {
1215 forge_workspace::Command::SpawnProject {
1216 project_name,
1217 launch_settings,
1218 } => Some((project_name, launch_settings)),
1219 _ => None,
1220 })
1221 .collect();
1222 if !spawned.is_empty() {
1223 return spawned;
1224 }
1225 tokio::task::yield_now().await;
1226 }
1227 })
1228 .await
1229 .expect("the boot wave dispatches its spawns")
1230 }
1231
1232 #[tokio::test]
1238 async fn the_boot_wave_opens_the_seats_that_were_open() {
1239 use forge_workspace::store::sessions::SeatState;
1240
1241 let (workspace, _dir) = fixture(false);
1242 workspace.enable_test_dispatch_intercept();
1243 workspace.seed_test_ready_account("Stub");
1244 workspace.seed_test_gateway_ready(true);
1245 assert!(
1246 wait_for_accounts(&workspace, Duration::from_secs(30)).await,
1247 "the fixture's accounts settle before the wave",
1248 );
1249 workspace
1250 .seed_seat_state_for_test("TestOrg", "forge", "lead", SeatState::Idle)
1251 .expect("seed the parked seat");
1252 workspace
1253 .seed_seat_state_for_test("TestOrg", "notes", "lead", SeatState::InTurn)
1254 .expect("seed the interrupted seat");
1255
1256 start_boot_wave(Arc::clone(&workspace), None, false);
1257
1258 let spawned = spawned_projects(&workspace).await;
1259 assert_eq!(spawned.len(), 2, "both open projects are in the wave: {spawned:?}");
1260 assert_eq!(spawned[0].0, "forge", "declaration order is the wave's order");
1261 assert_eq!(spawned[1].0, "notes");
1262 assert!(!spawned[0].1.force_new, "a normal launch is not a --new one");
1263 assert_eq!(
1264 spawned[0].1.resume_kick, None,
1265 "a seat that was parked is resumed with nothing to do",
1266 );
1267 assert_eq!(
1268 spawned[1].1.resume_kick.as_deref(),
1269 Some(forge_workspace::LEAD_RESTART_NOTE),
1270 "the seat whose turn was cut off takes the restart note",
1271 );
1272 }
1273
1274 #[tokio::test]
1278 async fn the_boot_wave_leaves_a_closed_project_shut() {
1279 use forge_workspace::store::sessions::SeatState;
1280
1281 let (workspace, _dir) = fixture(false);
1282 workspace.enable_test_dispatch_intercept();
1283 workspace.seed_test_ready_account("Stub");
1284 workspace.seed_test_gateway_ready(true);
1285 assert!(
1286 wait_for_accounts(&workspace, Duration::from_secs(30)).await,
1287 "the fixture's accounts settle before the wave",
1288 );
1289 workspace
1290 .seed_seat_state_for_test("TestOrg", "forge", "lead", SeatState::Idle)
1291 .expect("seed the open seat");
1292 workspace
1293 .seed_seat_state_for_test("TestOrg", "notes", "lead", SeatState::Closed)
1294 .expect("seed the closed seat");
1295
1296 start_boot_wave(Arc::clone(&workspace), None, false);
1297
1298 let dispatched = tokio::time::timeout(Duration::from_secs(10), async {
1299 loop {
1300 let drained = workspace.drain_test_dispatch_buffer();
1301 if drained
1302 .iter()
1303 .any(|command| matches!(command, forge_workspace::Command::SpawnProject { .. }))
1304 {
1305 return drained;
1306 }
1307 tokio::task::yield_now().await;
1308 }
1309 })
1310 .await
1311 .expect("the boot wave dispatches its spawns");
1312 let lead_spawns: Vec<&String> = dispatched
1313 .iter()
1314 .filter_map(|command| match command {
1315 forge_workspace::Command::SpawnProject { project_name, .. } => Some(project_name),
1316 _ => None,
1317 })
1318 .collect();
1319 assert_eq!(lead_spawns, vec!["forge"], "the closed project's lead is not spawned");
1320 }
1321
1322 #[tokio::test]
1325 async fn the_boot_wave_asks_for_the_restart_note_under_new_as_well() {
1326 use forge_workspace::store::sessions::SeatState;
1327
1328 let (workspace, _dir) = fixture(false);
1329 workspace.enable_test_dispatch_intercept();
1330 workspace.seed_test_ready_account("Stub");
1331 workspace.seed_test_gateway_ready(true);
1332 assert!(
1333 wait_for_accounts(&workspace, Duration::from_secs(30)).await,
1334 "the fixture's accounts settle before the wave",
1335 );
1336 workspace
1337 .seed_seat_state_for_test("TestOrg", "notes", "lead", SeatState::InTurn)
1338 .expect("seed the interrupted seat");
1339
1340 start_boot_wave(Arc::clone(&workspace), None, true);
1341
1342 let spawned = spawned_projects(&workspace).await;
1343 assert_eq!(spawned.len(), 1, "only the open project is in the wave: {spawned:?}");
1344 assert!(spawned[0].1.force_new, "--new reaches the command");
1345 assert_eq!(
1346 spawned[0].1.resume_kick.as_deref(),
1347 Some(forge_workspace::LEAD_RESTART_NOTE),
1348 "a --new wave still asks for the note; the spawn decides delivery",
1349 );
1350 }
1351
1352 #[tokio::test]
1357 async fn the_boot_wave_starts_a_named_closed_project() {
1358 use forge_workspace::store::sessions::SeatState;
1359
1360 let (workspace, _dir) = fixture(false);
1361 workspace.enable_test_dispatch_intercept();
1362 workspace.seed_test_ready_account("Stub");
1363 workspace.seed_test_gateway_ready(true);
1364 assert!(
1365 wait_for_accounts(&workspace, Duration::from_secs(30)).await,
1366 "the fixture's accounts settle before the wave",
1367 );
1368 workspace
1369 .seed_seat_state_for_test("TestOrg", "forge", "lead", SeatState::Closed)
1370 .expect("seed the closed seat");
1371
1372 start_boot_wave(Arc::clone(&workspace), Some("forge".to_owned()), false);
1373
1374 let spawned = spawned_projects(&workspace).await;
1375 assert_eq!(spawned.len(), 1, "the named project alone: {spawned:?}");
1376 assert_eq!(spawned[0].0, "forge", "the named project starts though it is closed");
1377 assert_eq!(
1378 spawned[0].1.resume_kick, None,
1379 "a closed seat has no interrupted turn to report",
1380 );
1381 }
1382
1383 #[tokio::test]
1390 async fn the_socket_returns_with_the_fold_subscribed() {
1391 let (workspace, _dir) = socket_fixture();
1392 assert_eq!(workspace.test_subscriber_count(), 0, "nothing is attached before the socket");
1393
1394 let serving = super::serve_socket(&workspace).await.expect("the socket serves");
1395 let super::SocketServing::Bound = serving else {
1396 panic!("the socket binds on a port the OS handed out");
1397 };
1398 assert_eq!(
1400 workspace.test_subscriber_count(),
1401 1,
1402 "the fold's subscription is held when serve_socket returns",
1403 );
1404 assert!(
1408 workspace.emit_for_test_reported(forge_workspace::SessionUpdate::CatalogLoaded),
1409 "the fold's own stream takes the frame the boot wave would emit",
1410 );
1411 workspace.supervisor().stop("socket_serve");
1412 }
1413
1414 const CONFIG_EDIT_WEDGE: Duration = Duration::from_secs(100);
1419
1420 #[cfg_attr(target_os = "macos", ignore = "fsevents stalls under a full gate")]
1428 #[tokio::test]
1429 async fn a_config_edit_reaches_the_running_workspace() {
1430 let (workspace, _dir) = boot_fixture();
1431 let mut updates = workspace.subscribe_answerer();
1432 start_config_watch(&workspace, |_workspace| {});
1433
1434 let path = workspace.config_path();
1435 let raw = std::fs::read_to_string(&path).expect("read");
1436 std::fs::write(&path, raw.replace("Stub", "Renamed")).expect("write");
1437
1438 let applied = tokio::time::timeout(CONFIG_EDIT_WEDGE, async {
1442 loop {
1443 let frame = updates.recv().await.expect("the update stream stays open");
1444 if matches!(&*frame.update, forge_workspace::SessionUpdate::AccountsChanged { .. })
1445 {
1446 break;
1447 }
1448 }
1449 })
1450 .await;
1451 assert!(
1452 applied.is_ok(),
1453 "the watched file's edit reaches the running workspace within {CONFIG_EDIT_WEDGE:?}",
1454 );
1455 assert_eq!(
1456 workspace.list_projects()[0].accounts,
1457 vec!["Renamed".to_owned()],
1458 "the running workspace holds the edited org pin",
1459 );
1460 }
1461
1462 #[cfg(target_os = "macos")]
1466 #[test]
1467 fn the_fleets_profile_names_are_the_configs() {
1468 use forge_primitives::SessionSlot;
1469
1470 let dir = tempfile::tempdir().expect("tempdir");
1471 let forge = dir.path().join("forge");
1472 std::fs::create_dir_all(&forge).expect("forge/");
1473 std::fs::write(
1474 forge.join("forge.toml"),
1475 "\
1476[[orgs]]
1477name = \"Busytools\"
1478accounts = [\"Stub\"]
1479browser_profile = \"work\"
1480
1481[[orgs.projects]]
1482name = \"forge\"
1483path = \"/tmp\"
1484
1485[[orgs.projects]]
1486name = \"apply\"
1487path = \"/tmp\"
1488browser_profile = \"job\"
1489
1490[[accounts]]
1491display_name = \"Stub\"
1492token = \"t\"
1493provider = \"openrouter\"
1494base_url = \"http://127.0.0.1:9/api\"
1495models = [\"claude-sonnet-5\"]
1496
1497[server]
1498enabled = false
1499
1500[gateway]
1501port = 49201
1502",
1503 )
1504 .expect("write forge.toml");
1505 let workspace = Workspace::new_for_test(dir.path().to_owned()).expect("workspace");
1506 let fleet = forge_browser::service::BrowserService::at(
1507 dir.path().join("browser"),
1508 dir.path().join("browser/stack"),
1509 );
1510
1511 configure_fleet_profiles(&workspace, &fleet);
1512
1513 let key = |project: &str| {
1514 fleet
1515 .profile_for(&SessionSlot::lead("Busytools", project))
1516 .expect("a seat resolves")
1517 .as_str()
1518 .to_owned()
1519 };
1520 assert_eq!(key("forge"), "Busytools/work", "the org's name arrives");
1521 assert_eq!(key("apply"), "Busytools/job", "the project's override arrives");
1522 }
1523
1524 #[tokio::test]
1533 async fn the_boot_wave_starts_the_usage_poller() {
1534 let (workspace, _dir) = boot_fixture();
1535 assert!(
1536 !workspace.test_usage_poller_started(),
1537 "the fixture starts with the poller unstarted, or this test proves nothing",
1538 );
1539
1540 start_subsystems(&workspace);
1541
1542 assert!(workspace.test_usage_poller_started(), "the boot wave starts the usage poller");
1543 }
1544
1545 #[cfg(target_os = "macos")]
1549 #[tokio::test]
1550 async fn the_prune_runs_after_the_reap() {
1551 let order = Arc::new(std::sync::Mutex::new(Vec::new()));
1552 let reaped = Arc::clone(&order);
1553 let pruned = Arc::clone(&order);
1554 reap_then_prune(
1555 async move {
1556 reaped.lock().expect("the order").push("reap");
1557 },
1558 move || {
1559 pruned.lock().expect("the order").push("prune");
1560 },
1561 )
1562 .await;
1563 assert_eq!(*order.lock().expect("the order"), vec!["reap", "prune"]);
1564 }
1565
1566 #[tokio::test]
1570 async fn the_boot_wave_starts_the_rotation_announcer() {
1571 let (workspace, _dir) = boot_fixture();
1572 assert!(
1573 !workspace.test_rotation_announcer_started(),
1574 "the fixture starts with the announcer unstarted, or this test proves nothing",
1575 );
1576
1577 start_subsystems(&workspace);
1578
1579 assert!(
1580 workspace.test_rotation_announcer_started(),
1581 "the boot wave starts the rotation announcer",
1582 );
1583 }
1584
1585 #[tokio::test]
1588 async fn the_boot_wave_starts_the_idle_sweep() {
1589 let (workspace, _dir) = boot_fixture();
1590 assert!(
1591 !workspace.supervisor().snapshot().iter().any(|member| member.name == "idle_sweep"),
1592 "the fixture starts with the idle sweep unstarted, or this test proves nothing",
1593 );
1594
1595 start_subsystems(&workspace);
1596
1597 assert!(
1598 workspace.supervisor().snapshot().iter().any(|member| member.name == "idle_sweep"),
1599 "the boot wave starts the idle sweep",
1600 );
1601 }
1602
1603 #[tokio::test]
1610 async fn the_boot_wave_starts_the_dictate_prewarm() {
1611 let (workspace, _dir) = boot_fixture();
1612 assert!(
1613 !workspace.test_dictate_preflight_started(),
1614 "the fixture starts with the prewarm unstarted, or this test proves nothing",
1615 );
1616
1617 start_subsystems(&workspace);
1618
1619 assert!(
1620 workspace.test_dictate_preflight_started(),
1621 "the boot wave asks for the prewarm; dropping the call leaves every take \
1622 paying the load",
1623 );
1624 }
1625
1626 #[tokio::test]
1635 async fn the_boot_wave_starts_the_voice_prewarm() {
1636 let (workspace, _dir) = boot_fixture();
1637 assert!(
1638 !workspace.test_voice_prewarm_started(),
1639 "the fixture starts with the prewarm unstarted, or this test proves nothing",
1640 );
1641
1642 start_subsystems(&workspace);
1643
1644 assert!(
1645 workspace.test_voice_prewarm_started(),
1646 "the boot wave asks for the prewarm; dropping the call leaves the first say \
1647 paying the load",
1648 );
1649 }
1650
1651 #[tokio::test]
1656 async fn the_boot_wave_hold_expires_rather_than_waiting_for_ever() {
1657 let (workspace, _dir) = crate::surface::testing::workspace();
1658 assert!(
1659 !workspace.all_accounts_loaded(),
1660 "the fixture's accounts start unsettled, or this test proves nothing",
1661 );
1662 assert_eq!(BOOT_WAVE_WAIT, Duration::from_secs(60), "the production bound");
1663
1664 let settled = wait_for_accounts(&workspace, Duration::from_millis(1)).await;
1665 assert!(!settled, "the hold gives up at its deadline instead of waiting for ever");
1666 }
1667
1668 #[tokio::test]
1671 async fn the_boot_wave_hold_releases_once_the_accounts_settle() {
1672 let (workspace, _dir) = crate::surface::testing::workspace();
1673 workspace.seed_test_ready_account("Granite");
1674 workspace.seed_test_ready_account("OpenRouter-TM");
1675 assert!(workspace.all_accounts_loaded(), "both accounts terminal and the listener ready");
1676
1677 let started = std::time::Instant::now();
1678 let settled = wait_for_accounts(&workspace, Duration::from_secs(60)).await;
1679 assert!(settled, "a settled boot is released at once");
1680 assert!(
1681 started.elapsed() < Duration::from_secs(1),
1682 "and it does not wait the bound out: {:?}",
1683 started.elapsed(),
1684 );
1685 }
1686}