diff --git a/Cargo.toml b/Cargo.toml index 090e8f6..226dfd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["Christopher Sardegna "] categories = ["command-line-interface", "command-line-utilities"] -description = "A fleet-view supervisor for concurrent shell commands." +description = "A fleet-view supervisor for shell commands and managed agents." edition = "2024" exclude = [".github", "docs", "tests", ".gitignore", ".gitattributes"] keywords = ["cli", "tui", "pty", "supervisor", "process"] diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 412eaa7..0d7953d 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -172,7 +172,7 @@ Implement `Harness` in [`src/harness/mod.rs`](../src/harness/mod.rs) for each to - `shape`: supply the program word and resume selector. Use the word to identify the tool on the Agent page and in session files. In `harness::intent_args`, place the selector and ID first in resume argv. - `session_flag`: specify the flag for pinning a session ID on a fresh launch, or return `None` (the default) for tool-assigned IDs. In `harness::plan`, build the conversation-selection argv from this flag and the resume selector. - `overlay`: return argv elements to append after conversation selection, environment entries, and any launch notice. Pass the elements directly to the binary and report the notice through the supervisor after a successful spawn. -- `parse_capture`: read an ID from the capture file's contents. The task leader's PID and the launch-time home are supplied; when other processes can reach the capture channel, use them to refuse a payload the task's own process did not write. +- `parse_capture`: read an ID from the capture file's contents. The task leader's PID is supplied; when other processes can reach the capture channel, use it to refuse a payload the task's own process did not write. - `live_session_id`: read the ID published on disk for a live session. Return `None` by default when no registry is available. - `live_blocked_status`: read that registry for blocked-on-user status. Return preview text, never an ID; return `None` by default. - `resolve_home`: resolve configuration needed by instrumentation, capture parsing, or the live registry. Return `None` by default. diff --git a/src/app.rs b/src/app.rs index e4d57a1..efe260d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -247,7 +247,7 @@ pub struct App { pub agent_sel: usize, /// Id of the attached task, if any: by id (not index) so it survives the /// task list changing underneath it. - pub focused_id: Option, + focused_id: Option, /// Return destination for `Ctrl-]`. Set on attachment only if the target /// is marked as flagship in the current snapshot. Retain after the /// flagship's exit so the user can still return. Clear on every exit from @@ -291,7 +291,8 @@ pub struct App { pub session_page: SessionPage, /// Selection in the recovery list, clamped independently of `session_sel`. pub recovery_sel: usize, - /// Transient one-line notice (save/load result), dismissed on the next key. + /// One-line status message for daemon events, reconnect and paste outcomes, or + /// the ignored-scrollback warning. Clear it on the next key. pub status: Option, /// Ephemeral notice text, priority, and creation time. notice: Option<(String, NoticeLevel, Instant)>, @@ -638,7 +639,7 @@ impl App { } /// Flattened section order: the sequence the selection cursor moves through. - pub fn display_order(&self) -> Vec { + fn display_order(&self) -> Vec { self.sections().into_iter().flat_map(|(_, v)| v).collect() } @@ -1176,7 +1177,7 @@ impl App { } /// Return whether nonempty input matches no existing group. - pub(crate) fn group_is_new(&self) -> bool { + pub fn group_is_new(&self) -> bool { !self.group_input.is_empty() && self.group_candidates.len() < 2 } @@ -1237,7 +1238,7 @@ impl App { } fn on_key(&mut self, out: &mut impl Write, k: KeyEvent) { - // Any key dismisses a lingering save/load notice. + // Clear the previous status notice before handling a key. self.status = None; // Global escape hatch, except while attached (Ctrl-C belongs to the child). // Ctrl-C disconnects: it leaves the daemon and tasks running. @@ -1684,22 +1685,22 @@ impl App { } } Mode::Spawn if self.spawn_page == SpawnPage::Agent => { - paste_into(&mut self.agent_input, s); + self.agent_input.paste(s); self.refresh_agent_candidates(); } Mode::Spawn | Mode::SaveSession | Mode::Rename(_) => { - paste_into(&mut self.input, s); + self.input.paste(s); } Mode::PickDir => { - paste_into(&mut self.dir_input, s); + self.dir_input.paste(s); self.refresh_dir_candidates(); } Mode::PickGroup(_) => { - paste_into(&mut self.group_input, s); + self.group_input.paste(s); self.refresh_group_candidates(); } Mode::Find => { - paste_into(&mut self.find_input, s); + self.find_input.paste(s); self.refresh_find_candidates(); } _ => {} @@ -2086,13 +2087,6 @@ fn task_matches(v: &TaskView, needle: &str) -> bool { .any(|field| field.to_lowercase().contains(needle)) } -/// Insert pasted text at the caret after removing control characters. -fn paste_into(buf: &mut EditBuffer, s: &str) { - for c in s.chars().filter(|c| !c.is_control()) { - buf.insert(c); - } -} - /// Split a typed path into its directory prefix and trailing search fragment. Apply the /// matching rules for each candidate type to the fragment. fn split_input(input: &str) -> (&str, &str) { diff --git a/src/app_tests.rs b/src/app_tests.rs index 12b1112..6ebf8e0 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::{ protocol::{Preview, PreviewSource}, supervisor::Supervisor, - testutil::{Scratch, temp, wait_until, write_executable}, + testutil::{Scratch, rows, screen, temp, wait_until, write_executable}, transport::LocalTransport, ui::scroll_window, }; @@ -70,6 +70,16 @@ impl App { panic!("spawn never landed: {:?}", self.status); } + /// Pump until task `id` reaches `Ok` or `Failed`; panic after 5 s. + fn wait_finished(&mut self, id: u64) { + let exited = wait_until(Duration::from_secs(5), || { + self.pump(); + let lifecycle = self.task_index(id).map(|i| self.views[i].lifecycle); + matches!(lifecycle, Some(Lifecycle::Ok | Lifecycle::Failed)) + }); + assert!(exited, "task {id} never exited: {:?}", self.views); + } + /// Return section labels with task ids instead of `views` indices. fn section_ids(&self) -> Vec<(String, Vec)> { self.sections() @@ -78,13 +88,11 @@ impl App { .collect() } - /// Spawn `cmd`, resolve its dashboard selection, and attach to it. + /// Spawn `cmd`, then attach to the newly selected task. fn attached(rows: u16, cols: u16, cmd: &str) -> (Self, u64) { let mut app = Self::new_local(rows, cols); let dir = app.invocation_dir.clone(); app.spawn_in(cmd, dir); - app.pump(); - app.resolve_selection(); app.attach(); let id = app.focused_id.expect("attached"); (app, id) @@ -104,6 +112,121 @@ fn shift(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::SHIFT) } +/// Pass each character of `text` through `handler`, the prompt's +/// `on_key_*` method. +fn type_into(app: &mut App, handler: fn(&mut App, KeyEvent), text: &str) { + for c in text.chars() { + handler(app, key(KeyCode::Char(c))); + } +} + +/// Build an app attached to task `id` without a task view. The clipboard and paste +/// handlers check only `mode` and `focused_id`. +fn attached_stub(id: u64) -> App { + let mut app = App::new_local(30, 100); + app.mode = Mode::Attached; + app.focused_id = Some(id); + app +} + +/// Paint one frame of the current mode and return it as text. +fn painted(app: &mut App) -> String { + app.last_frame.clear(); // identical frames are skipped + let mut out = Vec::new(); + crate::ui::render(&mut out, app).unwrap(); + String::from_utf8_lossy(&out).into_owned() +} + +/// Provide core events without a running core. Queue `events` for one `poll`; +/// discard commands sent through `send`. +struct Scripted { + events: Vec, + connected: bool, +} + +impl Scripted { + /// Queue `events` for the next poll with a connected fake transport. + fn events(events: Vec) -> Box { + Box::new(Self { + events, + connected: true, + }) + } + + /// Mark the fake transport disconnected with no events to deliver. Let + /// `check_connection` switch the app to `Mode::Disconnected`. + fn unplugged() -> Box { + Box::new(Self { + events: Vec::new(), + connected: false, + }) + } +} + +impl Transport for Scripted { + fn send(&mut self, _cmd: Command) {} + + fn poll(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + fn connected(&self) -> bool { + self.connected + } + + fn shutdown(&mut self, _intent: ExitIntent) {} +} + +/// Pass commands through to a real core and log each one. +struct Recording { + inner: Box, + sent: Arc>>, +} + +impl Transport for Recording { + fn send(&mut self, cmd: Command) { + self.sent.lock().unwrap().push(cmd.clone()); + self.inner.send(cmd); + } + + fn poll(&mut self) -> Vec { + self.inner.poll() + } + + fn connected(&self) -> bool { + self.inner.connected() + } + + fn shutdown(&mut self, intent: ExitIntent) { + self.inner.shutdown(intent); + } +} + +impl App { + /// Wrap the transport in `Recording` and return its log. + fn record_sends(&mut self) -> Arc>> { + let sent = Arc::default(); + let inner = std::mem::replace(&mut self.transport, Scripted::unplugged()); + self.transport = Box::new(Recording { + inner, + sent: Arc::clone(&sent), + }); + sent + } + + /// Dispatch `k` through `on_key`, the run loop's entry point. + fn hit(&mut self, k: KeyEvent) { + self.on_key(&mut io::sink(), k); + } + + /// Flush pending clipboard stores and return the bytes written. + fn flushed(&mut self) -> Vec { + let mut out = Vec::new(); + self.flush_clipboard(&mut out).unwrap(); + out + } +} + /// Construct a stable live task without spawning a child. `Active` keeps an /// untagged view in Running; a child can exit during the test /// and move its row to Completed. @@ -153,7 +276,6 @@ fn spawn_moves_selection_to_the_new_task() { let mut app = App::new_local(30, 100); let dir = app.invocation_dir.clone(); app.spawn_in("sleep 30", dir.clone()); - app.resolve_selection(); let first = app.selected_id.expect("first spawn selected"); app.spawn_in("sleep 31", dir); @@ -301,7 +423,6 @@ fn custom_sections_collate_case_insensitively() { app.spawn_grouped("sleep 5", inv.clone(), "API"); // id 2 app.spawn_grouped("sleep 5", inv.clone(), "Review"); // id 3 app.spawn_grouped("sleep 5", inv, "api"); // id 4 - app.pump(); app.group_mode = GroupMode::Custom; assert_eq!( @@ -326,7 +447,6 @@ fn dir_sections_collate_case_insensitively() { std::fs::create_dir_all(&lower).unwrap(); app.spawn_in("sleep 5", upper.clone()); // id 1 app.spawn_in("sleep 5", lower.clone()); // id 2 - app.pump(); app.group_mode = GroupMode::Dir; let (z, a) = (path::abbreviate(&upper), path::abbreviate(&lower)); @@ -361,7 +481,6 @@ fn custom_mode_groups_by_name_with_unassigned_last() { app.spawn_grouped("sleep 5", inv.clone(), "beta"); // id 1 app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 app.spawn_in("sleep 5", inv); // id 3, no group - app.pump(); app.group_mode = GroupMode::Custom; assert_eq!( @@ -379,39 +498,17 @@ fn custom_mode_groups_by_name_with_unassigned_last() { fn custom_mode_unassigned_tracks_membership() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.pump(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 app.group_mode = GroupMode::Custom; assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1])]); let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); // id 1, no group - app.pump(); app.group_mode = GroupMode::Custom; assert_eq!(app.section_ids(), vec![("Unassigned".to_string(), vec![1])]); } -/// In Custom mode, tagged tasks sort first within their existing group. -#[test] -fn custom_mode_tag_floats_within_group() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv, "alpha"); // id 2 - app.pump(); - app.group_mode = GroupMode::Custom; - assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]); - - app.transport.send(Command::Tag { id: 2, on: true }); - app.pump(); - assert_eq!( - app.section_ids(), - vec![("alpha".to_string(), vec![2, 1])], - "tag floats id 2 to the top of alpha, not into an In use section" - ); -} - /// Group reassignment can reorder sections without changing the selected id. #[test] fn custom_mode_selection_survives_group_move() { @@ -419,7 +516,6 @@ fn custom_mode_selection_survives_group_move() { let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 app.spawn_grouped("sleep 5", inv, "beta"); // id 2 - app.pump(); app.group_mode = GroupMode::Custom; // Exercise moving id 1 rather than the selected second spawn. app.selected_id = Some(1); @@ -449,10 +545,9 @@ fn custom_mode_clusters_by_dir_within_group() { std::fs::create_dir_all(&dir_a).unwrap(); std::fs::create_dir_all(&dir_b).unwrap(); - app.spawn_grouped("sleep 5", dir_b.clone(), "alpha"); // id 1, dir b + app.spawn_grouped("sleep 5", dir_b, "alpha"); // id 1, dir b app.spawn_grouped("sleep 5", dir_a.clone(), "alpha"); // id 2, dir a app.spawn_grouped("sleep 5", dir_a, "alpha"); // id 3, dir a - app.pump(); app.group_mode = GroupMode::Custom; assert_eq!( @@ -469,13 +564,7 @@ fn tagging_a_finished_task_moves_it_to_in_use() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("true", inv); // exits ~immediately - wait_until(Duration::from_secs(5), || { - app.pump(); - app.views - .first() - .map(|v| matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) - .unwrap_or(false) - }); + app.wait_finished(1); assert!(matches!( app.views[0].lifecycle, Lifecycle::Ok | Lifecycle::Failed @@ -500,16 +589,11 @@ fn idle_task_lands_in_idle_between_running_and_completed() { app.spawn_in("sleep 5", inv.clone()); // id 2: idle below app.spawn_in("sleep 5", inv.clone()); // id 3: tagged below app.spawn_in("true", inv); // id 4: exits ~immediately - wait_until(Duration::from_secs(5), || { - app.pump(); - app.views - .iter() - .any(|v| v.id == 4 && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) - }); + app.wait_finished(4); app.transport.send(Command::Tag { id: 3, on: true }); app.pump(); - let i = app.views.iter().position(|v| v.id == 2).unwrap(); + let i = app.task_index(2).unwrap(); app.views[i].lifecycle = Lifecycle::Idle; assert_eq!( @@ -530,11 +614,10 @@ fn tagged_task_stays_in_use_across_lifecycles() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv.clone()); // id 1: tagged + idle app.spawn_in("sleep 5", inv); // id 2: running - app.pump(); app.transport.send(Command::Tag { id: 1, on: true }); app.pump(); - let i = app.views.iter().position(|v| v.id == 1).unwrap(); + let i = app.task_index(1).unwrap(); for lifecycle in [ Lifecycle::Active, Lifecycle::Idle, @@ -561,11 +644,10 @@ fn selection_follows_task_across_idle_rebucket() { let dir = app.invocation_dir.clone(); app.spawn_in("sleep 5", dir.clone()); // id 1 app.spawn_in("sleep 5", dir); // id 2 - app.pump(); app.selected_id = Some(1); // Idle id 1 -> it sinks into "Idle", below id 2's "Running". - let i = app.views.iter().position(|v| v.id == 1).unwrap(); + let i = app.task_index(1).unwrap(); app.views[i].lifecycle = Lifecycle::Idle; let order = app.display_order(); @@ -592,108 +674,64 @@ fn selection_follows_task_across_idle_rebucket() { assert_eq!(app.views[app.selected_task().unwrap()].id, 1); } -/// Idle state does not affect row order within a custom group. -#[test] -fn custom_mode_idle_task_holds_its_row() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv, "alpha"); // id 2 - app.pump(); - app.group_mode = GroupMode::Custom; - assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]); - - let i = app.views.iter().position(|v| v.id == 1).unwrap(); - app.views[i].lifecycle = Lifecycle::Idle; - assert_eq!( - app.section_ids(), - vec![("alpha".to_string(), vec![1, 2])], - "idle id 1 keeps its row above id 2 within alpha" - ); -} - -/// Idle state does not affect row order within a directory section. -#[test] -fn dir_mode_idle_task_holds_its_row() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv.clone()); // id 1 - app.spawn_in("sleep 5", inv); // id 2 - app.pump(); - app.group_mode = GroupMode::Dir; - let label = path::abbreviate(&app.invocation_dir); - assert_eq!(app.section_ids(), vec![(label.clone(), vec![1, 2])]); - - let i = app.views.iter().position(|v| v.id == 1).unwrap(); - app.views[i].lifecycle = Lifecycle::Idle; - assert_eq!( - app.section_ids(), - vec![(label, vec![1, 2])], - "idle id 1 keeps its row above id 2 within its directory" - ); -} - -/// Entering and leaving idle state preserves row order. +/// Verify row position after a task enters and leaves idle state, in both a +/// custom group and a directory section. Dir mode ignores the group, so both +/// cases can use the same spawn setup. #[test] fn idle_round_trip_leaves_row_order_identical() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 - app.spawn_grouped("sleep 5", inv, "alpha"); // id 3 - app.pump(); - app.group_mode = GroupMode::Custom; - let want = vec![("alpha".to_string(), vec![1, 2, 3])]; - assert_eq!(app.section_ids(), want); - - let i = app.views.iter().position(|v| v.id == 2).unwrap(); - app.views[i].lifecycle = Lifecycle::Idle; - assert_eq!(app.section_ids(), want, "quiet does not move id 2"); + for mode in [GroupMode::Custom, GroupMode::Dir] { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 + app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 3 + app.group_mode = mode; + let label = match mode { + GroupMode::Custom => "alpha".to_string(), + GroupMode::Dir => path::abbreviate(&inv), + GroupMode::State => unreachable!(), + }; + let want = vec![(label, vec![1, 2, 3])]; + assert_eq!(app.section_ids(), want, "{mode:?}"); - app.views[i].lifecycle = Lifecycle::Active; - assert_eq!(app.section_ids(), want, "waking does not move id 2 back"); -} + let i = app.task_index(2).unwrap(); + app.views[i].lifecycle = Lifecycle::Idle; + assert_eq!( + app.section_ids(), + want, + "{mode:?}: quiet does not move id 2" + ); -/// Completed tasks sort after live tasks within a custom group. -#[test] -fn custom_mode_finished_sinks_within_group() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_grouped("true", inv.clone(), "alpha"); // id 1: exits ~immediately - app.spawn_grouped("sleep 30", inv, "alpha"); // id 2: stays live - wait_until(Duration::from_secs(5), || { - app.pump(); - app.views - .iter() - .any(|v| v.id == 1 && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) - }); - app.group_mode = GroupMode::Custom; - assert_eq!( - app.section_ids(), - vec![("alpha".to_string(), vec![2, 1])], - "finished id 1 sinks below live id 2 within alpha" - ); + app.views[i].lifecycle = Lifecycle::Active; + assert_eq!( + app.section_ids(), + want, + "{mode:?}: waking does not move id 2 back" + ); + } } -/// Completed tasks sort after live tasks within a directory section. +/// Verify completion ordering in both custom-group and directory sections. #[test] -fn dir_mode_finished_sinks_within_section() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("true", inv.clone()); // id 1: exits ~immediately - app.spawn_in("sleep 30", inv); // id 2: stays live - wait_until(Duration::from_secs(5), || { - app.pump(); - app.views - .iter() - .any(|v| v.id == 1 && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) - }); - app.group_mode = GroupMode::Dir; - assert_eq!( - app.section_ids(), - vec![(path::abbreviate(&app.invocation_dir), vec![2, 1])], - "finished id 1 sinks below live id 2 within its directory" - ); +fn finished_task_sinks_within_its_section() { + for mode in [GroupMode::Custom, GroupMode::Dir] { + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_grouped("true", inv.clone(), "alpha"); // id 1: exits ~immediately + app.spawn_grouped("sleep 30", inv.clone(), "alpha"); // id 2: stays live + app.wait_finished(1); + app.group_mode = mode; + let label = match mode { + GroupMode::Custom => "alpha".to_string(), + GroupMode::Dir => path::abbreviate(&inv), + GroupMode::State => unreachable!(), + }; + assert_eq!( + app.section_ids(), + vec![(label, vec![2, 1])], + "{mode:?}: finished id 1 sinks below live id 2" + ); + } } /// Tagged tasks sort first within a custom group, including while idle. @@ -702,14 +740,19 @@ fn custom_mode_tagged_task_floats_and_holds_while_idle() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 - app.spawn_grouped("sleep 5", inv, "alpha"); // id 2: tagged - app.pump(); + app.spawn_grouped("sleep 5", inv, "alpha"); // id 2: tagged below + app.group_mode = GroupMode::Custom; + assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![1, 2])]); + app.transport.send(Command::Tag { id: 2, on: true }); app.pump(); - app.group_mode = GroupMode::Custom; - assert_eq!(app.section_ids(), vec![("alpha".to_string(), vec![2, 1])]); + assert_eq!( + app.section_ids(), + vec![("alpha".to_string(), vec![2, 1])], + "tag floats id 2 to the top of alpha, not into an In use section" + ); - let i = app.views.iter().position(|v| v.id == 2).unwrap(); + let i = app.task_index(2).unwrap(); app.views[i].lifecycle = Lifecycle::Idle; assert_eq!( app.section_ids(), @@ -737,14 +780,8 @@ fn state_mode_ordering_survives_the_row_key_split() { app.spawn_in("sleep 30", dir_a.clone()); // id 6: tagged + idle, dir a app.spawn_in("true", dir_b.clone()); // id 7: finished, dir b app.spawn_in("true", dir_a.clone()); // id 8: finished, dir a - wait_until(Duration::from_secs(5), || { - app.pump(); - [7u64, 8].iter().all(|id| { - app.views - .iter() - .any(|v| v.id == *id && matches!(v.lifecycle, Lifecycle::Ok | Lifecycle::Failed)) - }) - }); + app.wait_finished(7); + app.wait_finished(8); for id in [5u64, 6] { app.transport.send(Command::Tag { id, on: true }); } @@ -759,7 +796,7 @@ fn state_mode_ordering_survives_the_row_key_split() { (5, Lifecycle::Active), (6, Lifecycle::Idle), ] { - let i = app.views.iter().position(|v| v.id == id).unwrap(); + let i = app.task_index(id).unwrap(); app.views[i].lifecycle = lifecycle; } @@ -913,37 +950,28 @@ fn session_selection_clamps_when_a_shorter_list_arrives() { assert_eq!(app.session_sel, 0); } -/// Write a recovery fixture whose commands run in `dir`. -fn write_recovery(dir: &Path, stem: &str, label: &str, cmds: &[&str]) { +/// Write one autosaved recovery snapshot for 2026-01-`day`, with stem +/// `202601DD-000000-1` and one task running `cmd` in `dir`. Return its path. +fn autosave(dir: &Path, day: u8, cmd: &str) -> PathBuf { let rec = dir.join("sessions").join("recovery"); std::fs::create_dir_all(&rec).unwrap(); - let list: Vec = cmds.iter().map(|c| format!("{c:?}")).collect(); + let path = rec.join(format!("202601{day:02}-000000-1.json")); std::fs::write( - rec.join(format!("{stem}.json")), + &path, format!( - r#"{{"version":1,"name":"{label}","dirs":{{".":[{}]}}}}"#, - list.join(",") + r#"{{"version":1,"name":"autosaved 2026-01-{day:02} 00:00","dirs":{{".":[{cmd:?}]}}}}"# ), ) .unwrap(); + path } /// Session events populate and independently clamp both picker lists. #[test] fn sessions_reply_populates_and_clamps_both_lists() { let dir = session_scratch("rec_lists", &["a"]); - write_recovery( - &dir, - "20260101-000000-1", - "autosaved 2026-01-01 00:00", - &["sleep 5"], - ); - write_recovery( - &dir, - "20260102-000000-1", - "autosaved 2026-01-02 00:00", - &["sleep 5"], - ); + let older = autosave(&dir, 1, "sleep 5"); + autosave(&dir, 2, "sleep 5"); let mut app = app_with_config_dir(&dir); app.on_key_dashboard(key(KeyCode::Char('o'))); @@ -960,12 +988,7 @@ fn sessions_reply_populates_and_clamps_both_lists() { // Refresh after removing the selected recovery entry. app.recovery_sel = 1; - std::fs::remove_file( - dir.join("sessions") - .join("recovery") - .join("20260101-000000-1.json"), - ) - .unwrap(); + std::fs::remove_file(older).unwrap(); app.transport.send(Command::ListSessions); app.pump(); assert_eq!(app.session_recovery.len(), 1); @@ -977,12 +1000,7 @@ fn sessions_reply_populates_and_clamps_both_lists() { #[test] fn o_key_resets_the_picker_to_the_saved_page() { let dir = session_scratch("rec_reset", &["a"]); - write_recovery( - &dir, - "20260101-000000-1", - "autosaved 2026-01-01 00:00", - &["sleep 5"], - ); + autosave(&dir, 1, "sleep 5"); let mut app = app_with_config_dir(&dir); app.on_key_dashboard(key(KeyCode::Char('o'))); @@ -1019,18 +1037,8 @@ fn tab_is_a_no_op_without_recovery_entries() { #[test] fn tab_toggles_pages_and_selections_stay_independent() { let dir = session_scratch("rec_tab", &["a", "b"]); - write_recovery( - &dir, - "20260101-000000-1", - "autosaved 2026-01-01 00:00", - &["sleep 5"], - ); - write_recovery( - &dir, - "20260102-000000-1", - "autosaved 2026-01-02 00:00", - &["sleep 5"], - ); + autosave(&dir, 1, "sleep 5"); + autosave(&dir, 2, "sleep 5"); let mut app = app_with_config_dir(&dir); app.on_key_dashboard(key(KeyCode::Char('o'))); app.pump(); @@ -1053,24 +1061,14 @@ fn tab_toggles_pages_and_selections_stay_independent() { #[test] fn emptied_recovery_list_returns_to_the_saved_page() { let dir = session_scratch("rec_empty", &["a"]); - write_recovery( - &dir, - "20260101-000000-1", - "autosaved 2026-01-01 00:00", - &["sleep 5"], - ); + let only = autosave(&dir, 1, "sleep 5"); let mut app = app_with_config_dir(&dir); app.on_key_dashboard(key(KeyCode::Char('o'))); app.pump(); app.on_key_loadsession(key(KeyCode::Tab)); assert_eq!(app.session_page, SessionPage::Recovery); - std::fs::remove_file( - dir.join("sessions") - .join("recovery") - .join("20260101-000000-1.json"), - ) - .unwrap(); + std::fs::remove_file(only).unwrap(); app.transport.send(Command::ListSessions); app.pump(); assert!(app.session_recovery.is_empty()); @@ -1081,18 +1079,8 @@ fn emptied_recovery_list_returns_to_the_saved_page() { #[test] fn enter_on_the_recovery_page_loads_the_selected_stem() { let dir = session_scratch("rec_load", &[]); - write_recovery( - &dir, - "20260101-000000-1", - "autosaved 2026-01-01 00:00", - &["sleep 7"], - ); - write_recovery( - &dir, - "20260102-000000-1", - "autosaved 2026-01-02 00:00", - &["sleep 9"], - ); + autosave(&dir, 1, "sleep 7"); + autosave(&dir, 2, "sleep 9"); let mut app = app_with_config_dir(&dir); app.on_key_dashboard(key(KeyCode::Char('o'))); app.pump(); @@ -1119,12 +1107,7 @@ fn enter_on_the_recovery_page_loads_the_selected_stem() { #[test] fn esc_closes_the_picker_from_the_recovery_page() { let dir = session_scratch("rec_esc", &[]); - write_recovery( - &dir, - "20260101-000000-1", - "autosaved 2026-01-01 00:00", - &["sleep 5"], - ); + autosave(&dir, 1, "sleep 5"); let mut app = app_with_config_dir(&dir); app.on_key_dashboard(key(KeyCode::Char('o'))); app.pump(); @@ -1142,7 +1125,6 @@ fn recent_dirs_are_distinct_and_newest_first() { app.spawn_in("sleep 5", PathBuf::from("/tmp")); // id 1 /tmp app.spawn_in("sleep 5", inv.clone()); // id 2 invocation app.spawn_in("sleep 5", PathBuf::from("/tmp")); // id 3 /tmp (dup) - app.pump(); let dirs = app.in_use_dirs(); assert_eq!(dirs.len(), 2, "duplicate dirs collapse"); @@ -1194,16 +1176,13 @@ fn recents_fixture(tag: &str) -> (App, Scratch) { for name in ["crabstep", "crabapple", "Logria"] { app.spawn_in("sleep 5", rust.join(name)); } - app.pump(); (app, root) } -/// Open the `@` picker and type `fragment` one key at a time. +/// Type `fragment` into the open `@` picker. fn type_pickdir(app: &mut App, fragment: &str) { app.on_key_dashboard(key(KeyCode::Char('@'))); - for c in fragment.chars() { - app.on_key_pickdir(key(KeyCode::Char(c))); - } + type_into(app, App::on_key_pickdir, fragment); } /// Current-task directory paths in picker order. @@ -1319,9 +1298,7 @@ fn pickdir_slash_suppresses_recents() { ); // Filtering the resolved path still omits current-task directory rows. - for c in "log".chars() { - app.on_key_pickdir(key(KeyCode::Char(c))); - } + type_into(&mut app, App::on_key_pickdir, "log"); assert!(jump_paths(&app).is_empty()); assert_eq!(app.dir_candidates[1].path, rust.join("Logria")); assert_eq!(app.dir_candidates[1].kind, DirKind::Into); @@ -1355,7 +1332,6 @@ fn pickdir_dedupes_a_recent_that_is_also_a_subdirectory() { let (mut app, root) = recents_fixture("pickdir_recent_dedupe"); let docs = root.join("Documents/Code/Rust/fleetcom/docs"); app.spawn_in("sleep 5", docs.clone()); - app.pump(); type_pickdir(&mut app, "doc"); @@ -1378,7 +1354,6 @@ fn focus_by_id_survives_index_shift() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv.clone()); // id 1 app.spawn_in("sleep 5", inv); // id 2 - app.pump(); app.focused_id = Some(2); assert_eq!(app.views[app.focused_task().unwrap()].id, 2); @@ -1397,9 +1372,8 @@ fn focus_by_id_survives_index_shift() { fn rows_interleave_headers_and_tasks() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); - app.spawn_in("a", inv.clone()); // id 1, invocation dir + app.spawn_in("a", inv); // id 1, invocation dir app.spawn_in("b", PathBuf::from("/tmp")); // id 2, /tmp - app.pump(); app.group_mode = GroupMode::Dir; let rows = app.list_rows(); @@ -1420,8 +1394,6 @@ fn dashboard_selection_stays_in_scroll_window() { for _ in 0..8 { app.spawn_in("sleep 5", inv.clone()); } - app.pump(); - app.resolve_selection(); // A 4-row window over 9 rows (1 header + 8 tasks): walking the whole // list down and back up must never let the selection leave the window. @@ -1458,7 +1430,6 @@ fn selection_wraps_at_list_edges() { app.spawn_in("sleep 5", inv.clone()); // id 1 app.spawn_in("sleep 5", inv.clone()); // id 2 app.spawn_in("sleep 5", inv); // id 3 - app.pump(); // Tag id 2 -> it sorts into a leading "In use" section, so the wrap // below crosses a section boundary. @@ -1486,20 +1457,25 @@ fn selection_wraps_at_list_edges() { ); } -/// With a single task, wrap degrades to a no-op in both directions. +/// With one task, verify that wrapping in either direction and stepping to +/// either section leave the selection unchanged. #[test] -fn selection_wrap_is_noop_with_one_task() { +fn navigation_is_noop_with_one_task() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); assert_eq!(app.selected_id, Some(1)); - app.select_up(); - assert_eq!(app.selected_id, Some(1)); - app.select_down(); - assert_eq!(app.selected_id, Some(1)); + let steps = [ + ("up", App::select_up as fn(&mut App)), + ("down", App::select_down), + ("next section", App::select_next_section), + ("prev section", App::select_prev_section), + ]; + for (name, step) in steps { + step(&mut app); + assert_eq!(app.selected_id, Some(1), "{name}"); + } } /// Build two two-task sections: In use [3, 4] and Running [1, 2]. @@ -1509,7 +1485,6 @@ fn app_with_two_sections() -> App { for _ in 0..4 { app.spawn_in("sleep 5", inv.clone()); } - app.pump(); app.transport.send(Command::Tag { id: 3, on: true }); app.transport.send(Command::Tag { id: 4, on: true }); app.pump(); @@ -1568,22 +1543,6 @@ fn backtab_jumps_to_previous_section_first_task() { ); } -/// Keep a single task selected during section navigation. -#[test] -fn section_nav_is_noop_with_one_task() { - let mut app = App::new_local(30, 100); - let inv = app.invocation_dir.clone(); - app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); - assert_eq!(app.selected_id, Some(1)); - - app.select_next_section(); - assert_eq!(app.selected_id, Some(1)); - app.select_prev_section(); - assert_eq!(app.selected_id, Some(1)); -} - /// Without a selection, navigation chooses the boundary section. #[test] fn section_nav_defaults_without_selection() { @@ -1621,7 +1580,6 @@ fn app_with_tagged_pair() -> App { for _ in 0..4 { app.spawn_in("sleep 5", inv.clone()); } - app.pump(); app.transport.send(Command::Tag { id: 2, on: true }); app.transport.send(Command::Tag { id: 4, on: true }); app.pump(); @@ -1644,7 +1602,6 @@ fn app_with_tags_split_across_groups() -> App { app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 app.spawn_grouped("sleep 5", inv.clone(), "beta"); // id 3 app.spawn_grouped("sleep 5", inv, "beta"); // id 4 - app.pump(); app.group_mode = GroupMode::Custom; app.transport.send(Command::Tag { id: 1, on: true }); app.transport.send(Command::Tag { id: 3, on: true }); @@ -1699,7 +1656,6 @@ fn cycle_tagged_is_noop_without_tags() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv.clone()); // id 1 app.spawn_in("sleep 5", inv); // id 2 - app.pump(); app.selected_id = Some(1); app.on_key_dashboard(key(KeyCode::Char('M'))); @@ -1731,7 +1687,6 @@ fn cycle_tagged_with_one_tag_holds_the_selection() { app.spawn_in("sleep 5", inv.clone()); // id 1 app.spawn_in("sleep 5", inv.clone()); // id 2 app.spawn_in("sleep 5", inv); // id 3 - app.pump(); app.transport.send(Command::Tag { id: 2, on: true }); app.pump(); app.selected_id = Some(2); // Start on the only tagged row. @@ -1863,10 +1818,8 @@ fn unencodable_modifiers_and_keys_are_dropped() { /// Refuse an oversized attached paste before sending it would close the connection. #[test] -fn oversized_paste_is_refused_with_a_notice() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(1); +fn oversized_paste_is_refused_with_a_status_line() { + let mut app = attached_stub(1); app.on_paste(&"x".repeat(MAX_PASTE + 1)); let status = app.status.clone().unwrap_or_default(); // MiB values are truncated, so both sizes display as 8 MiB. @@ -1892,15 +1845,10 @@ fn paste_into_text_entry_strips_controls() { #[test] fn input_modes_match_screen_type() { let screen = |wants_mouse, alt_screen, alt_scroll| ScreenView { - id: 1, - lines: Vec::new(), - formatted: Vec::new(), - cursor: (0, 0), - hide_cursor: false, wants_mouse, alt_screen, alt_scroll, - scrollback: 0, + ..crate::testutil::screen(1) }; // No attached screen: keep native selection available. assert!(!desired_mouse_capture(None, false)); @@ -1942,15 +1890,8 @@ fn input_modes_match_screen_type() { fn wheel_up_enters_scroll_view_for_inline_children() { let (mut app, id) = App::attached(30, 100, "sleep 5"); let screen = |wants_mouse| ScreenView { - id, - lines: Vec::new(), - formatted: Vec::new(), - cursor: (0, 0), - hide_cursor: false, wants_mouse, - alt_screen: false, - alt_scroll: false, - scrollback: 0, + ..crate::testutil::screen(id) }; let wheel_up = MouseEvent { kind: MouseEventKind::ScrollUp, @@ -2063,7 +2004,6 @@ fn wheel_moves_dashboard_selection() { let dir = app.invocation_dir.clone(); app.spawn_in("sleep 5", dir.clone()); // id 1 app.spawn_in("sleep 5", dir); // id 2 - app.pump(); app.selected_id = Some(1); let wheel = |kind| MouseEvent { @@ -2090,8 +2030,6 @@ fn group_picker_opens_on_g_only_with_a_selection() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('g'))); assert_eq!(app.mode, Mode::PickGroup(1)); } @@ -2106,7 +2044,6 @@ fn group_candidates_are_distinct_sorted_and_marked() { app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 2 app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 3, dup group app.spawn_in("sleep 5", inv); // id 4, no group - app.pump(); app.selected_id = Some(1); // group "beta" app.on_key_dashboard(key(KeyCode::Char('g'))); @@ -2142,7 +2079,6 @@ fn group_candidates_collate_case_insensitively() { app.spawn_grouped("sleep 5", inv.clone(), "Review"); // id 3 app.spawn_grouped("sleep 5", inv.clone(), "api"); // id 4 app.spawn_grouped("sleep 5", inv, "api"); // id 5, exact duplicate - app.pump(); app.selected_id = Some(1); app.on_key_dashboard(key(KeyCode::Char('g'))); @@ -2172,7 +2108,6 @@ fn group_filter_narrows_and_preselects_the_first_match() { let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 app.spawn_grouped("sleep 5", inv, "beta"); // id 2 - app.pump(); // Keep alpha selected so filtered beta is not marked "(current)". app.selected_id = Some(1); app.on_key_dashboard(key(KeyCode::Char('g'))); @@ -2206,7 +2141,6 @@ fn group_enter_on_a_candidate_assigns_it() { let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 app.spawn_in("sleep 5", inv); // id 2, no group - app.pump(); app.selected_id = Some(2); app.on_key_dashboard(key(KeyCode::Char('g'))); @@ -2224,12 +2158,8 @@ fn group_enter_on_novel_text_creates_the_group() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('g'))); - for c in "gamma".chars() { - app.on_key_pickgroup(key(KeyCode::Char(c))); - } + type_into(&mut app, App::on_key_pickgroup, "gamma"); assert_eq!(app.group_candidates.len(), 1, "nothing matches"); app.on_key_pickgroup(key(KeyCode::Enter)); app.pump(); @@ -2243,8 +2173,6 @@ fn group_enter_on_empty_input_clears_to_unassigned() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('g'))); assert_eq!(app.group_sel, 0, "empty input highlights the clear row"); app.on_key_pickgroup(key(KeyCode::Enter)); @@ -2259,12 +2187,8 @@ fn group_esc_cancels_without_sending() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('g'))); - for c in "gamma".chars() { - app.on_key_pickgroup(key(KeyCode::Char(c))); - } + type_into(&mut app, App::on_key_pickgroup, "gamma"); app.on_key_pickgroup(key(KeyCode::Esc)); assert_eq!(app.mode, Mode::Dashboard); assert!(app.group_input.is_empty() && app.group_candidates.is_empty()); @@ -2275,16 +2199,9 @@ fn group_esc_cancels_without_sending() { // --- `/` find palette --------------------------------------------------- -/// Candidate IDs for the current palette state. -fn find_ids(app: &App) -> Vec { - app.find_candidates.clone() -} - -/// Type `text` into the open palette one key at a time. +/// Type `text` into the open find palette. fn find_type(app: &mut App, text: &str) { - for c in text.chars() { - app.on_key_find(key(KeyCode::Char(c))); - } + type_into(app, App::on_key_find, text); } /// The find palette requires at least one task but no current selection. @@ -2297,12 +2214,11 @@ fn find_palette_opens_on_slash_only_with_tasks() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); // Exercise opening find without a current selection. app.selected_id = None; app.on_key_dashboard(key(KeyCode::Char('/'))); assert_eq!(app.mode, Mode::Find); - assert_eq!(find_ids(&app), vec![1]); + assert_eq!(app.find_candidates, vec![1]); } /// Find candidates follow dashboard order rather than task-ID order. @@ -2323,7 +2239,7 @@ fn find_candidates_follow_display_order() { assert_eq!(order, vec![3, 1, 2], "tag floats id 3 first"); app.on_key_dashboard(key(KeyCode::Char('/'))); - assert_eq!(find_ids(&app), order); + assert_eq!(app.find_candidates, order); assert_eq!(app.find_sel, 0, "the first match is highlighted"); } @@ -2337,15 +2253,15 @@ fn find_empty_input_lists_every_task() { view(2, inv, false, Some("alpha")), ]; app.on_key_dashboard(key(KeyCode::Char('/'))); - assert_eq!(find_ids(&app), vec![1, 2]); + assert_eq!(app.find_candidates, vec![1, 2]); // Type, then delete, to list the full fleet again. find_type(&mut app, "alpha"); - assert_eq!(find_ids(&app), vec![2]); + assert_eq!(app.find_candidates, vec![2]); for _ in 0.."alpha".len() { app.on_key_find(key(KeyCode::Backspace)); } - assert_eq!(find_ids(&app), vec![1, 2]); + assert_eq!(app.find_candidates, vec![1, 2]); } /// Matching ignores case and hits substrings anywhere in the field, not just @@ -2356,21 +2272,28 @@ fn find_matches_case_insensitive_substrings() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv.clone()); // id 1 app.spawn_in("true", inv); // id 2 - app.pump(); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "SLEEP"); - assert_eq!(find_ids(&app), vec![1], "case-insensitive"); + assert_eq!(app.find_candidates, vec![1], "case-insensitive"); app.on_key_find(key(KeyCode::Esc)); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "eep"); - assert_eq!(find_ids(&app), vec![1], "matches mid-command, not a prefix"); + assert_eq!( + app.find_candidates, + vec![1], + "matches mid-command, not a prefix" + ); app.on_key_find(key(KeyCode::Esc)); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "ru"); - assert_eq!(find_ids(&app), vec![2], "matches mid-command of \"true\""); + assert_eq!( + app.find_candidates, + vec![2], + "matches mid-command of \"true\"" + ); } /// A named task matches both its display name and command. @@ -2388,12 +2311,12 @@ fn find_matches_a_named_task_on_both_fields() { app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "api"); - assert_eq!(find_ids(&app), vec![1], "matches the name"); + assert_eq!(app.find_candidates, vec![1], "matches the name"); app.on_key_find(key(KeyCode::Esc)); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "sleep"); - assert_eq!(find_ids(&app), vec![1], "still matches the command"); + assert_eq!(app.find_candidates, vec![1], "still matches the command"); } /// Group names are a match field. @@ -2403,11 +2326,10 @@ fn find_matches_group_names() { let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv.clone(), "backend"); // id 1 app.spawn_in("sleep 5", inv); // id 2, no group - app.pump(); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "backend"); - assert_eq!(find_ids(&app), vec![1]); + assert_eq!(app.find_candidates, vec![1]); } /// Find does not match working-directory names. @@ -2417,22 +2339,21 @@ fn find_does_not_match_the_directory() { let dir = temp("findpalettedir"); let name = dir.file_name().unwrap().to_string_lossy().into_owned(); app.spawn_in("sleep 5", dir.to_path_buf()); // id 1 - app.pump(); assert!(name.contains("findpalettedir"), "scratch dir name: {name}"); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "findpalettedir"); assert!( - find_ids(&app).is_empty(), + app.find_candidates.is_empty(), "the directory must not match: {:?}", - find_ids(&app) + app.find_candidates ); // The command remains searchable. app.on_key_find(key(KeyCode::Esc)); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "sleep"); - assert_eq!(find_ids(&app), vec![1]); + assert_eq!(app.find_candidates, vec![1]); } /// On Enter, select the highlighted task on the dashboard and close the palette. @@ -2443,7 +2364,6 @@ fn find_enter_jumps_the_selection() { app.spawn_in("sleep 5", inv.clone()); // id 1 app.spawn_in("sleep 5", inv.clone()); // id 2 app.spawn_in("sleep 5", inv); // id 3 - app.pump(); app.selected_id = Some(1); app.on_key_dashboard(key(KeyCode::Char('/'))); @@ -2467,12 +2387,11 @@ fn find_esc_leaves_the_selection_alone() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv.clone()); // id 1 app.spawn_in("true", inv); // id 2 - app.pump(); app.selected_id = Some(1); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "true"); - assert_eq!(find_ids(&app), vec![2]); + assert_eq!(app.find_candidates, vec![2]); app.on_key_find(key(KeyCode::Esc)); assert_eq!(app.mode, Mode::Dashboard); assert_eq!(app.selected_id, Some(1), "Esc must not move the selection"); @@ -2486,12 +2405,10 @@ fn find_enter_without_candidates_keeps_the_panel_open() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('/'))); find_type(&mut app, "zzz"); - assert!(find_ids(&app).is_empty()); + assert!(app.find_candidates.is_empty()); app.on_key_find(key(KeyCode::Enter)); assert_eq!(app.mode, Mode::Find, "no match: Enter must not close"); assert_eq!(app.selected_id, Some(1), "selection is untouched"); @@ -2501,7 +2418,7 @@ fn find_enter_without_candidates_keeps_the_panel_open() { app.on_key_find(key(KeyCode::Backspace)); } find_type(&mut app, "sleep"); - assert_eq!(find_ids(&app), vec![1]); + assert_eq!(app.find_candidates, vec![1]); } /// Find rows include status, label, and section; empty results show a message. @@ -2533,24 +2450,15 @@ fn find_paste_filters_the_candidates() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv.clone()); // id 1 app.spawn_in("true", inv); // id 2 - app.pump(); app.on_key_dashboard(key(KeyCode::Char('/'))); app.on_paste("true"); assert_eq!(app.find_input.as_str(), "true"); - assert_eq!(find_ids(&app), vec![2]); + assert_eq!(app.find_candidates, vec![2]); } // --- `?` controls overlay ----------------------------------------------- -/// Paint one frame of the current mode and return it as text. -fn painted(app: &mut App) -> String { - app.last_frame.clear(); // identical frames are skipped - let mut out = Vec::new(); - crate::ui::render(&mut out, app).unwrap(); - String::from_utf8_lossy(&out).into_owned() -} - /// Highlight rows swap reverse video for a bright-black background while the /// host terminal is unfocused. #[test] @@ -2568,7 +2476,6 @@ fn unfocused_terminal_mutes_the_highlight_rows() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); app.selected_id = Some(1); // The dashboard's only reverse-video line is the selected row, so its @@ -2587,31 +2494,24 @@ fn unfocused_terminal_mutes_the_highlight_rows() { assert!(painted(&mut app).contains(&reverse)); } -/// Open the overlay on `?`; close on `?`, `Esc`, or `q`. +/// Open the overlay for either Shift-`/` event: `?` or `/` with Shift. +/// Close it with either event form, `Esc`, or `q`. #[test] -fn controls_overlay_opens_on_question_and_closes_on_peeks_key_set() { +fn controls_overlay_opens_on_question_and_closes_on_question_esc_q() { let mut app = App::new_local(30, 100); - for close in [KeyCode::Char('?'), KeyCode::Esc, KeyCode::Char('q')] { - app.on_key_dashboard(key(KeyCode::Char('?'))); - assert_eq!(app.mode, Mode::Controls, "? must open the overlay"); - app.on_key_controls(key(close)); - assert_eq!(app.mode, Mode::Dashboard, "{close:?} must close it"); - } -} - -/// Both accepted Shift-`/` event forms, `?` and `/` with Shift, open and close -/// the overlay. -#[test] -fn controls_overlay_accepts_both_spellings_of_the_chord() { - let mut app = App::new_local(30, 100); - for chord in [key(KeyCode::Char('?')), shift(KeyCode::Char('/'))] { - app.on_key_dashboard(chord); - assert!( - app.mode == Mode::Controls, - "{chord:?} must open the overlay" - ); - app.on_key_controls(chord); - assert_eq!(app.mode, Mode::Dashboard, "{chord:?} must close it"); + let chords = [key(KeyCode::Char('?')), shift(KeyCode::Char('/'))]; + for open in chords { + for close in [ + chords[0], + chords[1], + key(KeyCode::Esc), + key(KeyCode::Char('q')), + ] { + app.on_key_dashboard(open); + assert_eq!(app.mode, Mode::Controls, "{open:?} must open the overlay"); + app.on_key_controls(close); + assert_eq!(app.mode, Mode::Dashboard, "{close:?} must close it"); + } } } @@ -2621,7 +2521,6 @@ fn plain_slash_still_opens_the_find_palette() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); app.on_key_dashboard(key(KeyCode::Char('/'))); assert_eq!(app.mode, Mode::Find); } @@ -2632,8 +2531,6 @@ fn controls_overlay_ignores_dashboard_keys() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('?'))); app.on_key_controls(key(KeyCode::Char('m'))); @@ -2726,8 +2623,6 @@ fn rename_prompt_opens_on_shift_r_only_with_a_selection() { let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('R'))); assert_eq!(app.mode, Mode::Rename(1)); assert_eq!(app.input.as_str(), "", "an unnamed task prefills empty"); @@ -2749,12 +2644,8 @@ fn rename_enter_sends_the_typed_name() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_in("sleep 5", inv); - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('R'))); - for c in "api server".chars() { - app.on_key_rename(key(KeyCode::Char(c))); - } + type_into(&mut app, App::on_key_rename, "api server"); app.on_key_rename(key(KeyCode::Enter)); assert_eq!(app.mode, Mode::Dashboard); assert!(app.input.is_empty()); @@ -2774,7 +2665,6 @@ fn rename_enter_on_empty_input_clears_the_name() { name: Some("api".to_string()), }); app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('R'))); assert_eq!(app.input.as_str(), "api"); for _ in 0.."api".len() { @@ -2797,11 +2687,8 @@ fn rename_esc_cancels_without_sending() { name: Some("api".to_string()), }); app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('R'))); - for c in "junk".chars() { - app.on_key_rename(key(KeyCode::Char(c))); - } + type_into(&mut app, App::on_key_rename, "junk"); app.on_key_rename(key(KeyCode::Esc)); assert_eq!(app.mode, Mode::Dashboard); assert!(app.input.is_empty()); @@ -2824,7 +2711,6 @@ fn rename_caret_keys_edit_mid_name() { name: Some("api".to_string()), }); app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('R'))); assert_eq!(app.input.as_str(), "api"); @@ -2863,8 +2749,6 @@ fn ctrl_chords_never_insert_their_letter() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('w'))); app.on_key_savesession(ctrl(KeyCode::Char('k'))); @@ -2896,9 +2780,7 @@ fn pickdir_right_descends_only_from_the_end() { app.invocation_dir = dir.to_path_buf(); app.on_key_dashboard(key(KeyCode::Char('@'))); - for c in "al".chars() { - app.on_key_pickdir(key(KeyCode::Char(c))); - } + type_into(&mut app, App::on_key_pickdir, "al"); assert_eq!(app.dir_sel, 1, "the fragment preselects alpha"); // One position before the end, move the caret on Right without descending. @@ -2951,8 +2833,6 @@ fn pickgroup_caret_edits_refresh_the_filter() { let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv.clone(), "alpha"); // id 1 app.spawn_grouped("sleep 5", inv, "beta"); // id 2 - app.pump(); - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('g'))); app.on_key_pickgroup(key(KeyCode::Char('b'))); @@ -3012,9 +2892,7 @@ fn custom_mode_spawn_inherits_the_selected_group() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); app.group_mode = GroupMode::Custom; - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('n'))); assert_eq!(app.mode, Mode::Spawn); @@ -3037,9 +2915,7 @@ fn dir_picker_handoff_inherits_the_selected_group_in_custom_mode() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); app.group_mode = GroupMode::Custom; - app.resolve_selection(); app.on_key_dashboard(key(KeyCode::Char('@'))); assert_eq!(app.mode, Mode::PickDir); @@ -3055,8 +2931,6 @@ fn state_and_dir_mode_spawns_stay_unassigned() { let mut app = App::new_local(30, 100); let inv = app.invocation_dir.clone(); app.spawn_grouped("sleep 5", inv, "alpha"); // id 1 - app.pump(); - app.resolve_selection(); for mode in [GroupMode::State, GroupMode::Dir] { app.group_mode = mode; @@ -3076,23 +2950,6 @@ fn state_and_dir_mode_spawns_stay_unassigned() { // --- `n` spawn prompt: Agent page -------------------------------------- -/// Supply core events to the app without a running core. -struct Scripted(Vec); - -impl Transport for Scripted { - fn send(&mut self, _cmd: Command) {} - - fn poll(&mut self) -> Vec { - std::mem::take(&mut self.0) - } - - fn connected(&self) -> bool { - true - } - - fn shutdown(&mut self, _intent: ExitIntent) {} -} - impl App { /// Create a detached app with the supplied `agents` list and return the send log. Do /// not pump `new_local`, so the host's installed agents are never read into the list. @@ -3100,7 +2957,7 @@ impl App { fn with_agents(agents: &[&str]) -> (Self, Arc>>) { let mut app = Self::new_local(30, 100); app.agents = agents.iter().map(|a| a.to_string()).collect(); - app.transport = Box::new(Unplugged); + app.transport = Scripted::unplugged(); let sent = app.record_sends(); (app, sent) } @@ -3112,11 +2969,9 @@ impl App { assert_eq!(self.spawn_page, SpawnPage::Agent); } - /// Type `text` into the open spawn prompt one key at a time. + /// Type `text` into the open spawn prompt. fn type_spawn(&mut self, text: &str) { - for c in text.chars() { - self.on_key_spawn(key(KeyCode::Char(c))); - } + type_into(self, Self::on_key_spawn, text); } } @@ -3212,7 +3067,7 @@ fn session_load_status_keeps_capture_notices_and_failures() { env: vec![ ("PATH".into(), bin.as_os_str().to_os_string()), ( - path::FLEETCOM_RUNTIME_DIR.into(), + crate::protocol::FLEETCOM_RUNTIME_DIR.into(), dir.join("run").into_os_string(), ), ( @@ -3397,7 +3252,7 @@ fn agents_event_refilters_and_an_empty_list_leaves_the_agent_page() { app.open_agent_page(); app.type_spawn("c"); - app.transport = Box::new(Scripted(vec![Event::Agents(vec!["codex".to_string()])])); + app.transport = Scripted::events(vec![Event::Agents(vec!["codex".to_string()])]); app.sync(); assert_eq!(app.spawn_page, SpawnPage::Agent); assert_eq!( @@ -3406,7 +3261,7 @@ fn agents_event_refilters_and_an_empty_list_leaves_the_agent_page() { "the rows follow the new list" ); - app.transport = Box::new(Scripted(vec![Event::Agents(Vec::new())])); + app.transport = Scripted::events(vec![Event::Agents(Vec::new())]); app.sync(); assert_eq!(app.spawn_page, SpawnPage::Command); assert_eq!(app.mode, Mode::Spawn, "the prompt itself stays open"); @@ -3469,57 +3324,36 @@ fn agent_page_no_match_names_the_missing_agents() { // --- OSC 52 clipboard emission ------------------------------------------ -/// An attached clipboard store emits one BEL-terminated OSC 52 sequence. -#[test] -fn attached_clipboard_store_emits_the_osc52_envelope() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(1); - app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string()); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); - assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07"); - assert_eq!(app.notice(), Some("copied 5 chars")); - assert!( - app.status.is_none(), - "the copy confirmation is ephemeral; it must not occupy the status" - ); -} - -/// A selection store emits the `s` selector. -#[test] -fn selection_store_emits_its_own_kind_byte() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(1); - app.on_clipboard_copy(1, ClipboardKind::Selection, "hello".to_string()); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); - assert_eq!(out, b"\x1b]52;s;aGVsbG8=\x07"); -} - -/// A primary-selection store emits the `p` selector. +/// Verify that an attached store emits one BEL-terminated OSC 52 sequence and +/// that its selector byte identifies the store kind. #[test] -fn primary_store_emits_its_own_kind_byte() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(1); - app.on_clipboard_copy(1, ClipboardKind::Primary, "hello".to_string()); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); - assert_eq!(out, b"\x1b]52;p;aGVsbG8=\x07"); +fn attached_store_emits_the_osc52_envelope_with_its_kind_byte() { + for (kind, selector) in [ + (ClipboardKind::Clipboard, 'c'), + (ClipboardKind::Selection, 's'), + (ClipboardKind::Primary, 'p'), + ] { + let mut app = attached_stub(1); + app.on_clipboard_copy(1, kind, "hello".to_string()); + assert_eq!( + app.flushed(), + format!("\x1b]52;{selector};aGVsbG8=\x07").as_bytes() + ); + assert_eq!(app.notice(), Some("copied 5 chars")); + assert!( + app.status.is_none(), + "the copy confirmation is ephemeral; it must not occupy the status" + ); + } } /// Clipboard payloads are base64-encoded before reaching the host terminal. #[test] fn clipboard_payload_bytes_never_reach_the_terminal_raw() { let payload = "line1\nline2\x1b[31mred\x1b]52;c;evil\x07"; - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(1); + let mut app = attached_stub(1); app.on_clipboard_copy(1, ClipboardKind::Clipboard, payload.to_string()); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); + let out = app.flushed(); assert!(out.starts_with(b"\x1b]52;c;")); assert!(out.ends_with(b"\x07")); @@ -3545,8 +3379,7 @@ fn clipboard_stores_outside_attached_mode_are_dropped() { app.focused_id = Some(1); app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string()); assert!(app.pending_clipboard.is_empty(), "nothing may buffer"); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); + let out = app.flushed(); assert!(out.is_empty(), "nothing may emit"); assert!(app.notice().is_none(), "no notice without an emission"); } @@ -3555,9 +3388,7 @@ fn clipboard_stores_outside_attached_mode_are_dropped() { /// Stores from tasks other than the attached task are ignored. #[test] fn mismatched_id_clipboard_store_drops_at_receipt() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(7); + let mut app = attached_stub(7); app.on_clipboard_copy(3, ClipboardKind::Clipboard, "stale".to_string()); assert!( app.pending_clipboard.is_empty(), @@ -3582,7 +3413,6 @@ fn set_watch_resends_on_kind_change_with_the_same_id() { f = flag.display() ); app.spawn_in(&cmd, cwd); - app.pump(); let id = app.views[0].id; // Change only the attachment mode. @@ -3609,14 +3439,11 @@ fn set_watch_resends_on_kind_change_with_the_same_id() { /// Pending stores emit in order, and the notice counts the last store's characters. #[test] fn pending_stores_emit_in_order_and_notice_counts_last_entry_chars() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(1); + let mut app = attached_stub(1); app.on_clipboard_copy(1, ClipboardKind::Clipboard, "first".to_string()); app.on_clipboard_copy(1, ClipboardKind::Primary, "second".to_string()); app.on_clipboard_copy(1, ClipboardKind::Selection, "héllo日".to_string()); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); + let out = app.flushed(); let expected = format!( "\x1b]52;c;{}\x07\x1b]52;p;{}\x07\x1b]52;s;{}\x07", @@ -3638,8 +3465,7 @@ fn pending_stores_emit_in_order_and_notice_counts_last_entry_chars() { fn empty_clipboard_flush_writes_nothing() { let mut app = App::new_local(30, 100); app.mode = Mode::Attached; - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); + let out = app.flushed(); assert!(out.is_empty()); assert!(app.notice().is_none()); } @@ -3661,13 +3487,10 @@ fn notice_expires_lazily_after_the_ttl() { /// A copy confirmation does not replace an active warning. #[test] fn warning_notice_survives_the_copy_confirmation() { - let mut app = App::new_local(30, 100); - app.mode = Mode::Attached; - app.focused_id = Some(1); + let mut app = attached_stub(1); app.set_notice("clipboard copy dropped".to_string(), NoticeLevel::Warning); app.on_clipboard_copy(1, ClipboardKind::Clipboard, "hello".to_string()); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); + let out = app.flushed(); assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07", "the copy must still emit"); assert_eq!(app.notice(), Some("clipboard copy dropped")); } @@ -3709,27 +3532,23 @@ fn expired_warning_yields_to_info() { assert_eq!(app.notice(), Some("copied 5 chars")); } -/// Attached-mode status events update both the notice and persistent status. -#[test] -fn attached_status_event_mirrors_into_the_notice() { - let dir = session_scratch("status_mirror", &[]); - let mut app = app_with_config_dir(&dir); - app.mode = Mode::Attached; - app.save_session("mirror"); - app.pump(); - assert_eq!(app.status.as_deref(), Some("saved 'mirror': 0 command(s)")); - assert_eq!(app.notice(), Some("saved 'mirror': 0 command(s)")); -} - -/// Dashboard status events do not create an ephemeral notice. +/// Verify that status events set persistent status in every mode and mirror it +/// into the ephemeral notice only while attached. #[test] -fn dashboard_status_event_sets_only_the_status() { - let dir = session_scratch("status_dash", &[]); - let mut app = app_with_config_dir(&dir); - app.save_session("dash"); - app.pump(); - assert_eq!(app.status.as_deref(), Some("saved 'dash': 0 command(s)")); - assert!(app.notice().is_none(), "no mirror outside attached mode"); +fn status_event_mirrors_into_the_notice_only_while_attached() { + for (mode, name, mirrored) in [ + (Mode::Attached, "mirror", true), + (Mode::Dashboard, "dash", false), + ] { + let dir = session_scratch(&format!("status_{name}"), &[]); + let mut app = app_with_config_dir(&dir); + app.mode = mode; + app.save_session(name); + app.pump(); + let want = format!("saved '{name}': 0 command(s)"); + assert_eq!(app.status.as_deref(), Some(want.as_str())); + assert_eq!(app.notice(), mirrored.then_some(want.as_str()), "{mode:?}"); + } } // --- drag-copy selection ------------------------------------------------ @@ -3761,18 +3580,11 @@ impl App { /// `lines` the test controls. fn attached_with_lines(lines: &[&str]) -> Self { let (mut app, id) = Self::attached(30, 100, "sleep 5"); - let mut lines: Vec = lines.iter().map(|l| l.to_string()).collect(); + let mut lines = rows(lines); lines.resize(app.pane_rows() as usize, String::new()); app.focused_screen = Some(ScreenView { - id, lines, - formatted: Vec::new(), - cursor: (0, 0), - hide_cursor: false, - wants_mouse: false, - alt_screen: false, - alt_scroll: false, - scrollback: 0, + ..screen(id) }); app.mouse_captured = true; app @@ -3807,8 +3619,7 @@ fn drag_copy_gesture_emits_the_selection_via_osc52() { vec![(ClipboardKind::Clipboard, "hello".to_string())] ); assert!(app.selection.is_none(), "release must clear the selection"); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); + let out = app.flushed(); assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07"); assert_eq!(app.notice(), Some("copied 5 chars")); } @@ -3834,8 +3645,7 @@ fn click_without_drag_copies_nothing() { app.on_mouse(release(0, 3)); assert!(app.pending_clipboard.is_empty()); assert!(app.selection.is_none()); - let mut out = Vec::new(); - app.flush_clipboard(&mut out).unwrap(); + let out = app.flushed(); assert!(out.is_empty()); assert_eq!(app.notice(), None); } @@ -4120,15 +3930,8 @@ fn one_row_terminal_has_no_selectable_pane() { let (mut app, id) = App::attached(1, 80, "sleep 5"); app.mouse_captured = true; app.focused_screen = Some(ScreenView { - id, - lines: vec!["hidden".to_string()], - formatted: Vec::new(), - cursor: (0, 0), - hide_cursor: false, - wants_mouse: false, - alt_screen: false, - alt_scroll: false, - scrollback: 0, + lines: rows(&["hidden"]), + ..screen(id) }); app.on_mouse(press(0, 0)); assert!(app.selection().is_none(), "the bar row is not selectable"); @@ -4260,8 +4063,6 @@ fn frame_is_wrapped_in_one_synchronized_update() { let mut app = App::new_local(30, 100); let dir = app.invocation_dir.clone(); app.spawn_in("sleep 5", dir); - app.pump(); - app.resolve_selection(); for (label, mode) in [ ("dashboard", Mode::Dashboard), ("peek", Mode::Peek), @@ -4291,8 +4092,6 @@ fn unchanged_frame_emits_nothing() { let mut app = App::new_local(30, 100); let dir = app.invocation_dir.clone(); app.spawn_in("sleep 5", dir); - app.pump(); - app.resolve_selection(); app.mode = Mode::Peek; let mut first = Vec::new(); assert!( @@ -4354,65 +4153,7 @@ fn input_and_attached_echo_bypass_the_repaint_floor() { // --- flagship ---------------------------------------------------------------- -/// Simulate a disconnected core: discard commands and return false from `connected`. -struct Unplugged; - -impl Transport for Unplugged { - fn send(&mut self, _cmd: Command) {} - - fn poll(&mut self) -> Vec { - Vec::new() - } - - fn connected(&self) -> bool { - false - } - - fn shutdown(&mut self, _intent: ExitIntent) {} -} - -/// Pass commands through to a real core and log each one. -struct Recording { - inner: Box, - sent: Arc>>, -} - -impl Transport for Recording { - fn send(&mut self, cmd: Command) { - self.sent.lock().unwrap().push(cmd.clone()); - self.inner.send(cmd); - } - - fn poll(&mut self) -> Vec { - self.inner.poll() - } - - fn connected(&self) -> bool { - self.inner.connected() - } - - fn shutdown(&mut self, intent: ExitIntent) { - self.inner.shutdown(intent); - } -} - impl App { - /// Wrap the transport in `Recording` and return its log. - fn record_sends(&mut self) -> Arc>> { - let sent = Arc::default(); - let inner = std::mem::replace(&mut self.transport, Box::new(Unplugged)); - self.transport = Box::new(Recording { - inner, - sent: Arc::clone(&sent), - }); - sent - } - - /// Dispatch `k` through `on_key`, the run loop's entry point. - fn hit(&mut self, k: KeyEvent) { - self.on_key(&mut io::sink(), k); - } - /// Return the flagship id from the latest snapshot. fn flagship(&self) -> Option { self.views.iter().find(|v| v.flagship).map(|v| v.id) @@ -4762,7 +4503,7 @@ fn return_to_clears_on_disconnect() { let (mut app, _, _) = flagship_pair(); app.hit(chord()); assert!(app.return_to.is_some()); - app.transport = Box::new(Unplugged); + app.transport = Scripted::unplugged(); app.check_connection(); assert_eq!(app.mode, Mode::Disconnected); assert_eq!(app.return_to, None); diff --git a/src/daemon.rs b/src/daemon.rs index f0614b3..fb99e96 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -44,10 +44,9 @@ use nix::{ use crate::{ core::{LoopExit, Wake, run_loop}, frame::{MAX_FRAME, SEND_TIMEOUT, read_frame, write_frame}, - path::FLEETCOM_RUNTIME_DIR, protocol::{ - Command, Event, LaunchContext, PROTOCOL_VERSION, decode_command, decode_event, - decode_hello, encode_event, encode_hello, hello_version, + Command, Event, FLEETCOM_RUNTIME_DIR, LaunchContext, PROTOCOL_VERSION, decode_command, + decode_event, decode_hello, encode_event, encode_hello, hello_version, }, supervisor::{self, Supervisor}, }; @@ -94,9 +93,20 @@ fn resolve_runtime_dir( tmp.join(format!("fleetcom-{uid}")) } -/// Return the daemon socket path under `dir`. -fn socket_in(dir: &Path) -> PathBuf { - dir.join("default.sock") +/// Use these names for the runtime socket, lock, and log files. See +/// `docs/README.md` for their paths. +const SOCKET_FILE: &str = "default.sock"; +const LOCK_FILE: &str = "daemon.lock"; +const LOG_FILE: &str = "daemon.log"; + +/// Open or create the single-instance lock file without truncating it. The caller +/// writes this process's PID only after acquiring the flock. +fn open_lock(dir: &Path) -> io::Result { + fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(dir.join(LOCK_FILE)) } /// Create or validate a user-owned runtime directory with `0700` permissions. @@ -302,7 +312,7 @@ fn connect_or_autostart_in(dir: &Path) -> io::Result<(UnixStream, DaemonOrigin)> // unvalidated directory, we could follow a planted symlink and truncate an // attacker-chosen file before directory validation in the new daemon. ensure_runtime_dir(dir)?; - let path = socket_in(dir); + let path = dir.join(SOCKET_FILE); if let Ok(s) = UnixStream::connect(&path) { return Ok((s, DaemonOrigin::AlreadyRunning)); } @@ -321,7 +331,7 @@ fn connect_or_autostart_in(dir: &Path) -> io::Result<(UnixStream, DaemonOrigin)> ErrorKind::TimedOut, format!( "daemon did not come up; check {}", - dir.join("daemon.log").display() + dir.join(LOG_FILE).display() ), )) } @@ -330,7 +340,7 @@ fn connect_or_autostart_in(dir: &Path) -> io::Result<(UnixStream, DaemonOrigin)> /// `daemon.log` under `dir`, which the caller has validated. fn spawn_daemon(dir: &Path) -> io::Result<()> { let exe = std::env::current_exe()?; - let log = fs::File::create(dir.join("daemon.log")).ok(); + let log = fs::File::create(dir.join(LOG_FILE)).ok(); let mut cmd = std::process::Command::new(exe); cmd.arg("--daemon") .stdin(Stdio::null()) @@ -369,7 +379,7 @@ fn run_kill_in(dir: &Path) -> io::Result<()> { // The lock PID is a signal target, and the socket receives the client's // environment, so validate the directory before reading either file. ensure_runtime_dir(dir)?; - let lock_path = dir.join("daemon.lock"); + let lock_path = dir.join(LOCK_FILE); let Ok(file) = fs::OpenOptions::new() .read(true) .write(true) @@ -433,18 +443,14 @@ fn run_kill_in(dir: &Path) -> io::Result<()> { pub fn run_daemon() -> io::Result<()> { let dir = runtime_dir(); ensure_runtime_dir(&dir)?; // private 0700 directory - let path = socket_in(&dir); + let path = dir.join(SOCKET_FILE); // Only the holder of `daemon.lock` may own the // socket. A concurrent autostart (two clients racing to spawn a daemon) or a // spurious respawn fails this lock and exits, instead of unlinking a live // daemon's socket out from under it. flock releases automatically when this // process dies, so a crash leaves no stale lock. The next daemon reclaims. - let lock_file = fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) // rewritten below, only once the lock is ours - .open(dir.join("daemon.lock"))?; + let lock_file = open_lock(&dir)?; // `lock` is held for the whole function, so the flock lives until this // daemon exits, then releases on drop. let Ok(mut lock) = Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) else { @@ -674,7 +680,10 @@ fn serve_client(sup: &mut Supervisor, stream: UnixStream, stop: &AtomicBool) -> #[cfg(test)] mod tests { use super::*; - use crate::testutil::temp; + use crate::{ + protocol::ScreenView, + testutil::{screen, temp}, + }; /// A symlink at the runtime-dir path is the planted shared-`/tmp` attack: /// it must be rejected even when its target is a real directory, or the @@ -715,17 +724,9 @@ mod tests { /// Oversized events are skipped without preventing subsequent writes. #[test] fn oversized_event_is_skipped_not_fatal() { - use crate::protocol::ScreenView; let oversized = Event::Screen(ScreenView { - id: 1, - lines: Vec::new(), formatted: vec![b'x'; MAX_FRAME as usize + 1], - cursor: (0, 0), - hide_cursor: false, - wants_mouse: false, - alt_screen: false, - alt_scroll: false, - scrollback: 0, + ..screen(1) }); let mut buf: Vec = Vec::new(); assert!( @@ -849,12 +850,7 @@ mod tests { let base = temp("kill_lock_no_pid"); let dir = base.join("runtime"); ensure_runtime_dir(&dir).unwrap(); - let holder = fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(dir.join("daemon.lock")) - .unwrap(); + let holder = open_lock(&dir).unwrap(); // The second open creates a distinct open-file description, so it // contends with this lock even within one process. let _held = Flock::lock(holder, FlockArg::LockExclusiveNonblock).unwrap(); diff --git a/src/editbuf.rs b/src/editbuf.rs index 0de9958..e14bb33 100644 --- a/src/editbuf.rs +++ b/src/editbuf.rs @@ -49,6 +49,13 @@ impl EditBuffer { self.caret += c.len_utf8(); } + /// Insert pasted text at the caret after removing control characters. + pub fn paste(&mut self, s: &str) { + for c in s.chars().filter(|c| !c.is_control()) { + self.insert(c); + } + } + /// Remove the character before the caret; a no-op at the start. pub fn backspace(&mut self) { if let Some((i, _)) = self.text[..self.caret].char_indices().next_back() { @@ -94,17 +101,10 @@ impl Deref for EditBuffer { mod tests { use super::*; - /// Type each char of `s` into `buf`. - fn type_str(buf: &mut EditBuffer, s: &str) { - for c in s.chars() { - buf.insert(c); - } - } - #[test] fn insert_and_backspace_track_the_caret() { let mut b = EditBuffer::default(); - type_str(&mut b, "abc"); + b.paste("abc"); assert_eq!(b.as_str(), "abc"); assert!(b.at_end()); b.backspace(); @@ -178,7 +178,7 @@ mod tests { b.left(); b.clear(); assert!(b.is_empty() && b.at_end()); - type_str(&mut b, "ab"); + b.paste("ab"); b.left(); assert_eq!(b.take(), "ab"); assert!(b.is_empty() && b.at_end()); diff --git a/src/format.rs b/src/format.rs index 302f14f..6045417 100644 --- a/src/format.rs +++ b/src/format.rs @@ -77,7 +77,7 @@ pub fn pad(s: &str, width: usize) -> String { /// Return the longest UTF-8 prefix no longer than `max` bytes. Unlike /// [`truncate`], this limits bytes rather than display columns. -pub(crate) fn prefix_bytes(s: &str, max: usize) -> &str { +pub fn prefix_bytes(s: &str, max: usize) -> &str { if s.len() <= max { return s; } @@ -90,12 +90,12 @@ pub(crate) fn prefix_bytes(s: &str, max: usize) -> &str { /// Sort key for human-readable names. Sort by lowercase value, then by exact value to /// order case-distinct names deterministically without merging them. -pub(crate) fn collation_key(name: &str) -> (String, String) { +pub fn collation_key(name: &str) -> (String, String) { (name.to_lowercase(), name.to_string()) } /// Convert days since 1970-01-01 to a proleptic Gregorian date. -pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) { +pub fn civil_from_days(days: i64) -> (i64, u32, u32) { let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = z - era * 146_097; // [0, 146096] diff --git a/src/harness/assets.rs b/src/harness/assets.rs index 880ac80..b89db3f 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -209,9 +209,6 @@ fn reap_dead_namespaces(root: &Path) { /// Parse `-<12 lowercase hex characters>`. fn namespace_owner(name: &str) -> Option { let (pid, nonce) = name.split_once('-')?; - if pid.is_empty() || !pid.bytes().all(|b| b.is_ascii_digit()) { - return None; - } if nonce.len() != 12 || !nonce .bytes() @@ -326,8 +323,10 @@ mod tests { use super::*; use crate::{ harness::{ - BINARY_ENV, CAPTURE_ENV, Claude, Harness, NOTIFY_CHAIN_ENV, Omp, + BINARY_ENV, CAPTURE_ENV, Harness, NOTIFY_CHAIN_ENV, + claude::Claude, fixtures::{ID, OTHER}, + omp::Omp, }, testutil::{dead_pid, install_fake_notifier, temp, write_executable}, }; diff --git a/src/harness/claude.rs b/src/harness/claude.rs index a2fdb80..0b50786 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -16,13 +16,13 @@ use std::{ }; use super::summary::AWAITING_APPROVAL; -use super::{CapturePaths, Harness, SpawnPlan, capture_id, home_root, is_uuid, resolve_home}; +use super::{CapturePaths, Harness, SpawnPlan, capture_id, env_home, home_root}; pub struct Claude; impl Harness for Claude { fn resolve_home(&self, env: &dyn Fn(&str) -> Option) -> Option { - resolve_home(env, "CLAUDE_CONFIG_DIR", ".claude") + env_home(env, "CLAUDE_CONFIG_DIR", ".claude") } fn shape(&self) -> (&'static str, &'static str) { @@ -90,7 +90,7 @@ impl Harness for Claude { /// Validated fields used to correlate a registry record with a task and render /// its blocked status. struct SessionRecord { - /// `sessionId`, validated by [`is_uuid`]. + /// Validate `sessionId` with [`super::is_uuid`]. id: String, pid: i32, cwd: PathBuf, @@ -121,9 +121,8 @@ fn parse_record(text: &str) -> Option { if v["kind"].as_str()? != "interactive" { return None; } - let id = v["sessionId"].as_str().filter(|id| is_uuid(id))?; Some(SessionRecord { - id: id.to_string(), + id: capture_id(&v, "sessionId")?, pid: v["pid"].as_i32().filter(|p| *p > 0)?, cwd: PathBuf::from(v["cwd"].as_str()?), started_at: u128::from(v["startedAt"].as_u64()?), @@ -168,7 +167,7 @@ mod tests { fixtures::{ID, OTHER, argv, paths}, plan, }, - testutil::temp, + testutil::{hook_json, temp}, }; /// The overlay path from [`paths`]. @@ -239,14 +238,6 @@ mod tests { /// Task leader PID used by the capture-gate cases. const OWNER: u32 = 4242; - /// `SessionStart` JSON supplied to the hook: one object and a trailing - /// newline. - fn hook_json(id: &str, source: &str) -> String { - format!( - r#"{{"session_id":"{id}","transcript_path":"/t/x.jsonl","cwd":"/w","hook_event_name":"SessionStart","source":"{source}"}}"# - ) + "\n" - } - /// Parse `payload` as the capture file of a task led by [`OWNER`]. fn parse_owned(payload: &str) -> Option { Claude.parse_capture(payload, Some(OWNER)) @@ -257,7 +248,7 @@ mod tests { #[test] fn parse_capture_accepts_the_task_leaders_stamp() { for source in ["startup", "resume", "clear", "fork"] { - let payload = format!("{OWNER}\n{}", hook_json(ID, source)); + let payload = format!("{OWNER}\n{}\n", hook_json(ID, source)); assert_eq!(parse_owned(&payload).as_deref(), Some(ID), "{source}"); } } @@ -266,7 +257,7 @@ mod tests { /// Also reject captures when the task has no PID. #[test] fn parse_capture_refuses_a_foreign_stamp() { - let json = hook_json(ID, "startup"); + let json = format!("{}\n", hook_json(ID, "startup")); for foreign in [1, 424, 4243, 42420, 14242] { assert_eq!( parse_owned(&format!("{foreign}\n{json}")), @@ -284,7 +275,7 @@ mod tests { /// older, unstamped format, with JSON on the first line. #[test] fn parse_capture_refuses_a_missing_or_malformed_stamp() { - let json = hook_json(ID, "startup"); + let json = format!("{}\n", hook_json(ID, "startup")); assert_eq!(parse_owned(&json), None, "bare JSON"); assert_eq!(parse_owned(json.trim_end()), None, "bare JSON, no newline"); for stamp in [ @@ -298,7 +289,7 @@ mod tests { /// writes can return any prefix of the complete capture. #[test] fn parse_capture_refuses_empty_and_torn_payloads() { - let complete = format!("{OWNER}\n{}", hook_json(ID, "startup")); + let complete = format!("{OWNER}\n{}\n", hook_json(ID, "startup")); assert_eq!(parse_owned(&complete).as_deref(), Some(ID)); // The object is complete without its trailing newline. For every // shorter prefix, the stamp, its newline, or the closing brace is missing. diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 4b24617..06a7294 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -22,7 +22,7 @@ use std::{ use super::{ BINARY_ENV, CAPTURE_ENV, CapturePaths, Harness, NOTIFY_CHAIN_ENV, SpawnPlan, capture_id, - home_root, is_uuid, resolve_home, + env_home, home_root, is_uuid, }; /// Config override for explicitly launching in embedded mode. @@ -49,7 +49,7 @@ pub struct Codex; impl Harness for Codex { fn resolve_home(&self, env: &dyn Fn(&str) -> Option) -> Option { - resolve_home(env, "CODEX_HOME", ".codex") + env_home(env, "CODEX_HOME", ".codex") } fn shape(&self) -> (&'static str, &'static str) { @@ -438,7 +438,7 @@ mod tests { (NOTIFY_CHAIN_ENV.into(), chain.into()), ( BINARY_ENV.into(), - PathBuf::from("/tmp/Application Support/fleetcom").into_os_string(), + paths().fleetcom_binary.unwrap().into_os_string(), ), ] } diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 2e934c5..1f130cb 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -30,10 +30,7 @@ use std::{ time::SystemTime, }; -pub use claude::Claude; -pub use codex::{Codex, record_arrival}; -pub use grok::Grok; -pub use omp::Omp; +pub use codex::record_arrival; /// Environment variable naming the capture file used by injected assets. pub const CAPTURE_ENV: &str = "FLEETCOM_CAPTURE_FILE"; @@ -113,7 +110,7 @@ pub trait Harness: Sync { /// Resolve a tool-specific override before the launch environment's home. /// Without either, the consumer applies [`home_root`]'s platform fallback. -fn resolve_home( +fn env_home( env: &dyn Fn(&str) -> Option, override_var: &str, dot_dir: &str, @@ -137,30 +134,33 @@ struct Agent { /// Registered CLIs, in launcher order. static AGENTS: &[Agent] = &[ Agent { - harness: &Claude, + harness: &claude::Claude, summary: &summary::ClaudeSummary, }, Agent { - harness: &Codex, + harness: &codex::Codex, summary: &summary::CodexSummary, }, Agent { - harness: &Grok, + harness: &grok::Grok, summary: &summary::GrokSummary, }, Agent { - harness: &Omp, + harness: &omp::Omp, summary: &summary::OmpSummary, }, ]; -/// Look up a harness by its exact registered `program` word, as used for managed launches -/// and session entries. Do not match paths or basenames. +/// Look up an agent by its exact registered `program` word. Do not match paths or +/// basenames. +fn agent(program: &str) -> Option<&'static Agent> { + AGENTS.iter().find(|a| a.harness.shape().0 == program) +} + +/// Look up the harness registered for an exact program word used by a managed +/// launch or session entry. Do not match paths or basenames. pub fn registered(program: &str) -> Option<&'static dyn Harness> { - AGENTS - .iter() - .map(|a| a.harness) - .find(|h| h.shape().0 == program) + agent(program).map(|a| a.harness) } /// List every registered program word in registry order. Subtract `installed` to identify @@ -202,10 +202,7 @@ fn executable_file(path: &Path) -> bool { pub fn select(command: &str) -> Option<&'static dyn crate::preview::SummaryAdapter> { let first = command.split_whitespace().next()?; let name = Path::new(first).file_name()?.to_str()?; - AGENTS - .iter() - .find(|a| a.harness.shape().0 == name) - .map(|a| a.summary) + agent(name).map(|a| a.summary) } /// Conversation selection for a managed launch. @@ -276,10 +273,8 @@ pub struct CapturePaths { /// Extension module loaded by `omp -e`, which appends to the user's own /// extensions rather than replacing them. pub omp_capture: PathBuf, - /// The daemon's executable, checked at allocation for a regular file with an execute - /// bit. Use `None` when `current_exe` is unusable. On Linux, the path read through - /// `/proc/self/exe` ends in ` (deleted)` after the binary is replaced on disk under the - /// running daemon. + /// Store the executable path returned by the allocation-time probe; use + /// `None` when it finds no usable executable. pub fleetcom_binary: Option, } @@ -328,7 +323,7 @@ fn capture_id(v: &jzon::JsonValue, key: &str) -> Option { /// Generate a v4 UUID from `/dev/urandom`. Return `None` on a read failure; launch /// without pinning an ID in that case. -pub(crate) fn uuid_v4() -> Option { +pub fn uuid_v4() -> Option { use std::fmt::Write; let mut bytes = [0u8; 16]; File::open("/dev/urandom") @@ -349,22 +344,22 @@ pub(crate) fn uuid_v4() -> Option { /// Fixtures for harness launch and capture tests. #[cfg(test)] -pub(crate) mod fixtures { +pub mod fixtures { use std::{ffi::OsString, path::PathBuf}; use super::CapturePaths; /// Strict v4 UUID used wherever a valid session ID is needed. - pub(crate) const ID: &str = "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d"; + pub const ID: &str = "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d"; /// A second distinct ID for precedence cases. - pub(crate) const OTHER: &str = "11111111-2222-4333-8444-555555555555"; + pub const OTHER: &str = "11111111-2222-4333-8444-555555555555"; /// Thread IDs reported through one codex process's notifier: the conversation on /// screen, a sub-agent it spawned, and the hidden title thread. Codex thread IDs are /// v7; use these so fixtures match real rollouts (`is_uuid` checks no version field). - pub(crate) const CODEX_ROOT: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; - pub(crate) const CODEX_CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; - pub(crate) const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; + pub const CODEX_ROOT: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; + pub const CODEX_CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; + pub const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; /// Argv elements from string literals, for snapshot assertions. pub(super) fn argv(words: &[&str]) -> Vec { diff --git a/src/harness/summary.rs b/src/harness/summary.rs index d74a701..caeec72 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -36,7 +36,7 @@ use crate::preview::SummaryAdapter; /// Preview text shared by approval-menu matchers and Claude's registry /// permission prompt. -pub(crate) const AWAITING_APPROVAL: &str = "awaiting approval"; +pub const AWAITING_APPROVAL: &str = "awaiting approval"; /// Whether `c` is a Unicode Braille Patterns code point used as a spinner /// frame by the supported CLIs. @@ -51,6 +51,18 @@ fn after_frame(s: &str, is_frame: impl Fn(char) -> bool) -> Option<&str> { (is_frame(chars.next()?) && chars.next()? == ' ').then_some(chars.as_str()) } +/// Return the text after a leading run of one or more ASCII digits. +fn after_digits(s: &str) -> Option<&str> { + let rest = s.trim_start_matches(|c: char| c.is_ascii_digit()); + (rest.len() < s.len()).then_some(rest) +} + +/// Check whether the trimmed row starts with `open` and ends with `close`. +fn box_edge(row: &str, open: char, close: char) -> bool { + let t = row.trim(); + t.starts_with(open) && t.ends_with(close) +} + /// The status phrase of a spinner row: a frame char accepted by `is_frame`, /// a space, then text through the first `…` inclusive. The phrase must open /// alphanumeric; past that it is task-derived and unconstrained. Trailing @@ -194,11 +206,7 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati fn claude_waiting_text(row: &str) -> Option { let text = after_frame(row, |c| CLAUDE_SPINNER.contains(&c))?; let rest = text.strip_prefix("Waiting for ")?; - let digits = rest.chars().take_while(char::is_ascii_digit).count(); - if digits == 0 { - return None; - } - let middle = rest[digits..] + let middle = after_digits(rest)? .strip_prefix(' ')? .strip_suffix(" to finish")?; (1..=3) @@ -372,9 +380,7 @@ fn codex_menu_head(row: &str) -> bool { /// An unselected modal option: indented, `{digit}. `-headed. fn codex_numbered_option(row: &str) -> bool { - let t = row.trim_start(); - let digits = t.chars().take_while(char::is_ascii_digit).count(); - t.len() > digits && digits >= 1 && t[digits..].starts_with(". ") + after_digits(row.trim_start()).is_some_and(|r| r.starts_with(". ")) } /// Codex's approval modal: a selector row with an indented numbered sibling adjacent to @@ -623,11 +629,7 @@ fn codex_elapsed(s: &str) -> Option<&str> { let mut rest = s; let mut units = "hms"; loop { - let digits = rest.chars().take_while(char::is_ascii_digit).count(); - if digits == 0 { - return None; - } - let tail = &rest[digits..]; + let tail = after_digits(rest)?; let unit = tail.chars().next()?; let at = units.find(unit)?; units = &units[at + 1..]; @@ -691,14 +693,10 @@ impl SummaryAdapter for GrokSummary { /// sits higher), a `╭…╮` top border within six rows above it, and at least /// one `│`-headed row between. Returns `(top, bottom)` border indexes. fn grok_input_box(rows: &[String]) -> Option<(usize, usize)> { - let bottom = rows.iter().rposition(|r| { - let t = r.trim(); - t.starts_with('╰') && t.ends_with('╯') - })?; - let top = (bottom.saturating_sub(6)..bottom).rev().find(|&i| { - let t = rows[i].trim(); - t.starts_with('╭') && t.ends_with('╮') - })?; + let bottom = rows.iter().rposition(|r| box_edge(r, '╰', '╯'))?; + let top = (bottom.saturating_sub(6)..bottom) + .rev() + .find(|&i| box_edge(&rows[i], '╭', '╮'))?; rows[top + 1..bottom] .iter() .any(|r| r.trim_start().starts_with('│')) @@ -738,14 +736,9 @@ fn grok_still_running(t: &str) -> Option { /// One count segment: ASCII digits, a space, then one to three words. fn grok_still_running_count(seg: &str) -> bool { - let digits = seg.chars().take_while(char::is_ascii_digit).count(); - if digits == 0 { - return false; - } - let Some(words) = seg[digits..].strip_prefix(' ') else { - return false; - }; - (1..=3).contains(&words.split_whitespace().count()) + after_digits(seg) + .and_then(|r| r.strip_prefix(' ')) + .is_some_and(|words| (1..=3).contains(&words.split_whitespace().count())) } /// `╰──── Grok 4.5 (xhigh) · always-approve ─╯` → `Grok 4.5 (xhigh)`: the text grok @@ -772,9 +765,10 @@ const OMP_CURSORS: &[&str] = &["❯", "\u{f054}", ">"]; /// painted status row above it. Without the box, check for the approval selector /// displayed in its place. /// -/// Locate status by Unicode box corners. Do not use ASCII `+` and `-`: the same glyphs -/// are present in transcript tables and rules. Accept the plain-text approval selector -/// under ASCII. +/// Locate status with Unicode box corners; ASCII `+` and `-` also occur in +/// transcript tables and rules. Accept the ASCII approval selector when the +/// input box is absent. Do not extract a model label from omp's status line: +/// users can customize that text, so it does not identify a stable model. pub struct OmpSummary; impl SummaryAdapter for OmpSummary { @@ -786,11 +780,6 @@ impl SummaryAdapter for OmpSummary { } } - /// Model text is user-configurable status-line content, not a stable label. - fn model_label(&self, _rows: &[String]) -> Option { - None - } - /// Normalize omp's `π {separator} {label}` and `π: {label}` titles. Extract a /// nonempty label after `>` or `π:`, normalize braille frames to `⠋`, and retain /// `!` as the waiting marker. Return `None` for unsupported shapes and empty idle @@ -822,12 +811,8 @@ impl SummaryAdapter for OmpSummary { /// `╭…╮` border. Require adjacent borders to exclude preview boxes containing a /// command. fn omp_input_box(rows: &[String]) -> Option { - let bottom = rows.iter().rposition(|r| { - let t = r.trim(); - t.starts_with('╰') && t.ends_with('╯') - })?; - let t = rows[..bottom].last()?.trim(); - (t.starts_with('╭') && t.ends_with('╮')).then(|| bottom - 1) + let bottom = rows.iter().rposition(|r| box_edge(r, '╰', '╯'))?; + box_edge(rows[..bottom].last()?, '╭', '╮').then(|| bottom - 1) } /// The status row: the first painted row above the input box, shaped `{frame} {phrase} diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index e386f17..1f236f3 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -4,7 +4,7 @@ use super::*; use crate::{ emulator::Emulator, harness::select, - preview::{MARKER, PreviewState, SummaryAdapter}, + preview::{MARKER, PreviewState, ScreenFacts, SummaryAdapter}, protocol::PreviewSource, testutil::CORPUS_LINES, }; @@ -12,10 +12,7 @@ use crate::{ /// The ten braille spinner frames codex and omp cycle through their titles. const BRAILLE_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; -/// Build a synthetic live viewport for an adapter. -fn rs(rows: &[&str]) -> Vec { - rows.iter().map(|s| s.to_string()).collect() -} +use crate::testutil::rows as rs; /// Place status rows above a 120-column Claude input box. fn claude_screen>(above: &[S]) -> Vec { @@ -34,6 +31,14 @@ fn grok_screen>(above: &[S]) -> Vec { rows } +/// Place the supplied rows above the two-row omp input box. +fn omp_screen>(above: &[S]) -> Vec { + let mut rows: Vec = above.iter().map(|s| s.as_ref().to_string()).collect(); + rows.push("╭── π > ⬢ model · ◒ high > ◫ 12.2%/131K ▶──╮".to_string()); + rows.push("╰─ ─╯".to_string()); + rows +} + /// Resolve a corpus fixture at the corpus row count and `cols` columns; /// return its text, source, and rule. fn corpus( @@ -1201,14 +1206,6 @@ fn grok_still_running_shapes() { ); } -/// Place the provided rows above an adjacent two-row omp input box. -fn omp_screen>(above: &[S]) -> Vec { - let mut rows: Vec = above.iter().map(|s| s.as_ref().to_string()).collect(); - rows.push("╭── π > ⬢ model · ◒ high > ◫ 12.2%/131K ▶──╮".to_string()); - rows.push("╰─ ─╯".to_string()); - rows -} - /// Build an omp approval screen with `head` naming the tool. fn omp_selector(head: &str) -> Vec { omp_selector_row(" ❯ Approve", head) diff --git a/src/main.rs b/src/main.rs index 1715edc..e41b5b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,9 @@ #![forbid(unsafe_code)] +// Default to private. Use `pub(super)` when callers stay within the parent +// module's subtree, and `pub` when they need a broader scope. Every module here +// is private to this binary, so `pub` already means crate-visible; Clippy rejects +// redundant `pub(crate)`. +#![warn(clippy::redundant_pub_crate)] //! `fleetcom`: a fleet-view supervisor for shell commands and managed agents. Each //! task runs in its own PTY; the dashboard groups them by status, and you can @@ -36,7 +41,7 @@ mod terminal; #[cfg(test)] mod testutil; -pub(crate) use terminal::{ansi, emulator, input}; +pub use terminal::{ansi, emulator, input}; use std::{ io::{self, IsTerminal}, @@ -70,7 +75,7 @@ use app::App; static KITTY_PUSHED: AtomicBool = AtomicBool::new(false); const USAGE: &str = "\ -fleetcom - a fleet-view supervisor for arbitrary shell commands +fleetcom - a fleet-view supervisor for shell commands and managed agents Usage: fleetcom [] connect to the daemon (autostarting it), @@ -347,7 +352,7 @@ fn emit_restore_sequences(out: &mut impl io::Write, kitty_pushed: bool) -> io::R /// the terminal instead of dying in raw mode, and the daemon kills its tasks /// cleanly. `flag::register` only stores into an atomic, so it stays within /// `#![forbid(unsafe_code)]`. -pub(crate) fn install_signal_handlers(flag: Arc) -> io::Result<()> { +pub fn install_signal_handlers(flag: Arc) -> io::Result<()> { use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; signal_hook::flag::register(SIGTERM, Arc::clone(&flag))?; signal_hook::flag::register(SIGHUP, Arc::clone(&flag))?; diff --git a/src/path.rs b/src/path.rs index 88e2b86..e77bbd3 100644 --- a/src/path.rs +++ b/src/path.rs @@ -2,12 +2,6 @@ use std::path::{Component, Path, PathBuf}; -/// Shared env key for the runtime directory. For sockets and locks, read it -/// from the process environment in `resolve_runtime_dir`. For captures, read -/// it from the launch context, falling back to `runtime_root`. The fallback -/// directories differ between these uses. -pub const FLEETCOM_RUNTIME_DIR: &str = "FLEETCOM_RUNTIME_DIR"; - /// Shorten a path for display: `$HOME` collapses to `~`. Everything else stays /// absolute, so two directories never render as the same label. pub fn abbreviate(path: &Path) -> String { diff --git a/src/preview.rs b/src/preview.rs index 58afd35..8dd781f 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -4,19 +4,16 @@ use std::time::{Duration, Instant}; -use crate::{ - emulator::Emulator, - protocol::{Preview, PreviewSource}, -}; +use crate::protocol::{Preview, PreviewSource}; // Holds use elapsed time because tick intervals range from the 8 ms frame // minimum to the 200 ms idle backstop. /// Minimum interval between rendered title changes. -pub const TITLE_MIN_HOLD: Duration = Duration::from_millis(500); +const TITLE_MIN_HOLD: Duration = Duration::from_millis(500); /// Hold duration before rendering a lower-ranked preview source. -pub const DEMOTION_HOLD: Duration = Duration::from_millis(600); +const DEMOTION_HOLD: Duration = Duration::from_millis(600); /// Preview text for an alternate-screen child with no usable title. pub const MARKER: &str = "full-screen"; @@ -42,37 +39,9 @@ pub trait ScreenFacts { fn alt_leave_floor(&self) -> Option<&str>; } -impl ScreenFacts for Emulator { - fn revision(&self) -> u64 { - Self::revision(self) - } - - fn alternate_screen(&self) -> bool { - Self::alternate_screen(self) - } - - fn title(&self) -> Option<&str> { - Self::title(self) - } - - fn primary_title(&self) -> Option<&str> { - Self::primary_title(self) - } - - fn live_floor(&self) -> String { - Self::live_floor(self) - } - - fn live_rows(&self) -> Vec { - Self::live_rows(self) - } - - fn alt_leave_floor(&self) -> Option<&str> { - Self::alt_leave_floor(self) - } -} - /// Display-only status and model-label extraction for one agent CLI. +/// Require every adapter to implement `live_preview`. Return `None` from model-label +/// and title-normalization methods when an adapter has no value to report. pub trait SummaryAdapter: Sync { /// Return normalized live status and its matcher ID when the expected /// chrome is present. `rows` contains live rows with trailing padding @@ -81,7 +50,9 @@ pub trait SummaryAdapter: Sync { /// Return a model label from stable CLI chrome. Preview resolution prepends /// the label and ` · ` to the live status. - fn model_label(&self, rows: &[String]) -> Option; + fn model_label(&self, _rows: &[String]) -> Option { + None + } /// Normalize a title recognized as this CLI's output. On the alternate screen, /// preserve the captured title verbatim for `None`. On the primary screen, reject @@ -335,7 +306,10 @@ mod tests { use std::cell::Cell; use super::*; - use crate::harness::summary::{CodexSummary, OmpSummary}; + use crate::{ + emulator::Emulator, + harness::summary::{CodexSummary, OmpSummary}, + }; /// Synthetic screen facts with a floor-read counter for the /// revision-gate test. Every mutator bumps `revision`, matching the @@ -366,6 +340,15 @@ mod tests { } } + /// Build an alternate-screen fixture with `floor` on the primary screen and + /// `title` captured on the alternate screen. + fn alt_titled(floor: &str, title: &str) -> Self { + let mut s = Self::primary(floor); + s.enter_alt(); + s.set_title(title); + s + } + /// Bump the revision alone: the emulator counts every grid advance. fn advance(&mut self) { self.revision += 1; @@ -443,6 +426,16 @@ mod tests { label: Option<&'static str>, } + impl StubAdapter { + /// Build a stub adapter with a working status and the given model label. + fn working(label: Option<&'static str>) -> Self { + Self { + live: Some(("Working", "stub:working")), + label, + } + } + } + impl SummaryAdapter for StubAdapter { fn live_preview(&self, _rows: &[String]) -> Option<(String, &'static str)> { self.live.map(|(text, rule)| (text.to_string(), rule)) @@ -465,13 +458,8 @@ mod tests { fn anchor_outranks_title_and_prepends_the_label() { let now = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("app"); - let adapter = StubAdapter { - live: Some(("Working", "stub:working")), - label: Some("model-x"), - }; + let s = FakeScreen::alt_titled("shell", "app"); + let adapter = StubAdapter::working(Some("model-x")); let p = st.resolve(now, &s, Some(&adapter), None).clone(); assert_eq!( (p.text.as_str(), p.source, p.rule), @@ -483,10 +471,7 @@ mod tests { ); // Without a label, render the bare status for the anchor. - let bare = StubAdapter { - live: Some(("Working", "stub:working")), - label: None, - }; + let bare = StubAdapter::working(None); let mut st = PreviewState::new(); let p = st.resolve(now, &s, Some(&bare), None).clone(); assert_eq!(p.text, "Working"); @@ -497,13 +482,8 @@ mod tests { fn a_registry_anchor_outranks_the_adapters_anchor() { let now = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("app"); - let adapter = StubAdapter { - live: Some(("Working", "stub:working")), - label: Some("model-x"), - }; + let s = FakeScreen::alt_titled("shell", "app"); + let adapter = StubAdapter::working(Some("model-x")); let p = st .resolve( now, @@ -561,13 +541,8 @@ mod tests { fn anchor_loss_demotes_through_the_hold() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("app"); - let working = StubAdapter { - live: Some(("Working", "stub:working")), - label: None, - }; + let mut s = FakeScreen::alt_titled("shell", "app"); + let working = StubAdapter::working(None); st.resolve(t0, &s, Some(&working), None); let idle = StubAdapter { @@ -622,9 +597,7 @@ mod tests { ); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("app"); + let s = FakeScreen::alt_titled("shell", "app"); let p = st.resolve(now, &s, None, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); @@ -683,10 +656,7 @@ mod tests { let mut st = PreviewState::new(); let mut s = FakeScreen::primary("shell"); s.set_primary_title("π ⠋ fix parser"); - let adapter = StubAdapter { - live: Some(("Working", "stub:working")), - label: None, - }; + let adapter = StubAdapter::working(None); let p = st.resolve(now, &s, Some(&adapter), None).clone(); assert_eq!( (p.text.as_str(), p.source, p.rule), @@ -738,9 +708,7 @@ mod tests { fn demotion_commits_only_after_the_hold() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("app"); + let mut s = FakeScreen::alt_titled("shell", "app"); st.resolve(t0, &s, None, None); s.clear_title(); @@ -765,9 +733,7 @@ mod tests { let t0 = Instant::now(); let ms = Duration::from_millis; let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("app"); + let mut s = FakeScreen::alt_titled("shell", "app"); st.resolve(t0, &s, None, None); s.clear_title(); @@ -797,9 +763,7 @@ mod tests { fn flapping_pending_keeps_the_timer_and_commits_the_latest() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("app"); + let mut s = FakeScreen::alt_titled("shell", "app"); st.resolve(t0, &s, None, None); // First demoted candidate: the marker. @@ -828,9 +792,7 @@ mod tests { let t0 = Instant::now(); let ms = Duration::from_millis; let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("one"); + let mut s = FakeScreen::alt_titled("shell", "one"); assert_eq!(st.resolve(t0, &s, None, None).text, "one"); s.set_title("two"); @@ -915,9 +877,7 @@ mod tests { fn finalize_keeps_the_rendered_preview_across_alt_teardown() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("prelaunch junk"); - s.enter_alt(); - s.set_title("agent: working"); + let mut s = FakeScreen::alt_titled("prelaunch junk", "agent: working"); st.resolve(t0, &s, None, None); // Apply the exit's 1049l without an intervening live resolution. @@ -943,9 +903,7 @@ mod tests { fn finalize_keeps_the_render_when_a_resolve_saw_the_teardown() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("prelaunch junk"); - s.enter_alt(); - s.set_title("agent: working"); + let mut s = FakeScreen::alt_titled("prelaunch junk", "agent: working"); st.resolve(t0, &s, None, None); // Apply teardown and resolve on a tick before output completion. @@ -971,9 +929,7 @@ mod tests { fn finalize_trusts_the_screen_after_a_primary_commit() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("prelaunch junk"); - s.enter_alt(); - s.set_title("agent: working"); + let mut s = FakeScreen::alt_titled("prelaunch junk", "agent: working"); st.resolve(t0, &s, None, None); // The child returns to the primary screen and keeps printing; the @@ -1004,9 +960,7 @@ mod tests { fn finalize_freezes_primary_output_written_after_teardown() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("prelaunch junk"); - s.enter_alt(); - s.set_title("agent: working"); + let mut s = FakeScreen::alt_titled("prelaunch junk", "agent: working"); st.resolve(t0, &s, None, None); // Teardown observed (snapshot: "prelaunch junk"), title still held. @@ -1031,9 +985,7 @@ mod tests { fn finalize_holds_through_control_only_output_after_teardown() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("prelaunch junk"); - s.enter_alt(); - s.set_title("agent: working"); + let mut s = FakeScreen::alt_titled("prelaunch junk", "agent: working"); st.resolve(t0, &s, None, None); s.leave_alt(); @@ -1146,9 +1098,7 @@ mod tests { fn finalize_on_the_alt_screen_freezes_the_final_cascade() { let t0 = Instant::now(); let mut st = PreviewState::new(); - let mut s = FakeScreen::primary("shell"); - s.enter_alt(); - s.set_title("step 1"); + let mut s = FakeScreen::alt_titled("shell", "step 1"); st.resolve(t0, &s, None, None); s.set_title("step 2: done"); diff --git a/src/protocol.rs b/src/protocol.rs index c20203b..8a0784a 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -18,7 +18,7 @@ pub const PROTOCOL_VERSION: u32 = 14; pub const UNASSIGNED: &str = "Unassigned"; /// Environment and working directory supplied by the launching client. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchContext { pub env: Vec<(OsString, OsString)>, /// Base directory for relative session-recipe paths. @@ -43,11 +43,17 @@ pub fn env_get<'a>(env: &'a [(OsString, OsString)], key: &str) -> Option<&'a OsS .map(|(_, v)| v.as_os_str()) } +/// Share this environment key across runtime-directory lookups. Read it from the +/// process environment for sockets and locks in `resolve_runtime_dir`; read it +/// from the launch context for captures, then fall back to `runtime_root`. Keep +/// the fallback directories separate. +pub const FLEETCOM_RUNTIME_DIR: &str = "FLEETCOM_RUNTIME_DIR"; + /// A client→core request. Every mutation of the task set is one of these; the /// client never touches a `Task` directly. Fire-and-forget: results come back /// as `Event`s, never as return values. The handshake uses `KIND_HELLO`, not a /// command. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { /// Run `command` under `$SHELL -c` in `cwd`; `group` is the initial /// dashboard assignment. @@ -269,6 +275,29 @@ pub enum Lifecycle { Failed, } +impl Lifecycle { + /// Return the lowercase state value for the protocol's `life` tag. + pub fn label(self) -> &'static str { + match self { + Self::Active => "active", + Self::Idle => "idle", + Self::Ok => "ok", + Self::Failed => "failed", + } + } + + /// Parse only a string returned by [`Self::label`]. + pub fn from_label(s: &str) -> Option { + match s { + "active" => Some(Self::Active), + "idle" => Some(Self::Idle), + "ok" => Some(Self::Ok), + "failed" => Some(Self::Failed), + _ => None, + } + } +} + /// Where a preview's text came from. Declared in ascending authority so the /// derived `Ord` ranks provenance directly: `Anchor > Title > Marker > /// Floor`. @@ -288,7 +317,7 @@ pub enum PreviewSource { } impl PreviewSource { - /// Lowercase provenance identifier. + /// Return the lowercase provenance value for the protocol's `src` tag. pub fn label(self) -> &'static str { match self { Self::Floor => "floor", @@ -297,10 +326,21 @@ impl PreviewSource { Self::Anchor => "anchor", } } + + /// Parse only a string returned by [`Self::label`]. + pub fn from_label(s: &str) -> Option { + match s { + "floor" => Some(Self::Floor), + "marker" => Some(Self::Marker), + "title" => Some(Self::Title), + "anchor" => Some(Self::Anchor), + _ => None, + } + } } /// A resolved dashboard preview sent as part of [`TaskView`]. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Preview { pub text: String, pub source: PreviewSource, @@ -313,7 +353,7 @@ pub struct Preview { impl Preview { /// An unfrozen `Floor` preview of `text`. - pub(crate) fn floor(text: String) -> Self { + pub fn floor(text: String) -> Self { Self { text, source: PreviewSource::Floor, @@ -354,10 +394,11 @@ pub struct TaskView { pub finished_ago: Option, } -/// The watched task's screen, in both forms the UI needs: `lines` for the peek -/// overlay's plain-text box, `formatted` (+cursor) for full attached rendering. -/// Only ever produced for the single watched task, so carrying both is cheap. -#[derive(Debug, Clone, PartialEq)] +/// The watched task's screen in the two forms the UI uses. Read `lines` for the +/// peek box and selection; use `formatted` with `cursor` for styled attached +/// rendering. The supervisor sends both only for the watched task, which keeps +/// the extra payload small. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ScreenView { pub id: u64, pub lines: Vec, @@ -419,7 +460,7 @@ fn bool_flag(v: &jzon::JsonValue) -> Option { /// Decode an optional-string field: missing and null both mean the cleared /// state (`Some(None)`), a string is the set state, and any other type /// rejects the message (`None`). -pub(crate) fn opt_str(v: &jzon::JsonValue) -> Option> { +pub fn opt_str(v: &jzon::JsonValue) -> Option> { if v.is_null() { return Some(None); } @@ -428,7 +469,7 @@ pub(crate) fn opt_str(v: &jzon::JsonValue) -> Option> { /// Insert `key` only when the optional field is set; absence encodes `None` /// on the wire (see [`opt_str`]). -pub(crate) fn insert_opt_str(o: &mut jzon::JsonValue, key: &str, val: &Option) { +pub fn insert_opt_str(o: &mut jzon::JsonValue, key: &str, val: &Option) { if let Some(s) = val { let _ = o.insert(key, s.as_str()); } @@ -488,35 +529,6 @@ fn recovery_vec(v: &jzon::JsonValue) -> Vec { out } -fn lifecycle_str(l: Lifecycle) -> &'static str { - match l { - Lifecycle::Active => "active", - Lifecycle::Idle => "idle", - Lifecycle::Ok => "ok", - Lifecycle::Failed => "failed", - } -} - -fn lifecycle_from(s: &str) -> Option { - match s { - "active" => Some(Lifecycle::Active), - "idle" => Some(Lifecycle::Idle), - "ok" => Some(Lifecycle::Ok), - "failed" => Some(Lifecycle::Failed), - _ => None, - } -} - -fn source_from(s: &str) -> Option { - match s { - "floor" => Some(PreviewSource::Floor), - "marker" => Some(PreviewSource::Marker), - "title" => Some(PreviewSource::Title), - "anchor" => Some(PreviewSource::Anchor), - _ => None, - } -} - /// Serialize a launch context as a `KIND_HELLO` frame. pub fn encode_hello(ctx: &LaunchContext) -> (u8, Vec) { let mut pairs = jzon::JsonValue::new_array(); @@ -551,16 +563,13 @@ pub fn decode_hello(kind: u8, payload: &[u8]) -> Option<(u32, LaunchContext)> { )) } -/// Extract a claimed protocol version for mismatch reporting. -/// Accepts hello-kind frames and control frames with a `hello` discriminant, -/// even when the remaining fields do not satisfy [`decode_hello`]. +/// Extract a `KIND_HELLO` frame's claimed version for mismatch reporting, even +/// when its other fields fail [`decode_hello`]. pub fn hello_version(kind: u8, payload: &[u8]) -> Option { - let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?; - match kind { - KIND_HELLO => v["v"].as_u32(), - KIND_CONTROL if v["t"].as_str() == Some("hello") => v["v"].as_u32(), - _ => None, + if kind != KIND_HELLO { + return None; } + jzon::parse(std::str::from_utf8(payload).ok()?).ok()?["v"].as_u32() } /// Serialize a command to `(kind, payload)` for [`crate::frame::write_frame`]. @@ -631,12 +640,16 @@ pub fn encode_command(cmd: &Command) -> (u8, Vec) { MouseKind::Drag(b) => ("d", Some(*b)), MouseKind::Release(b) => ("r", Some(*b)), }; - let mut o = jzon::object! { "t": "mouse", "id": *id, "k": k }; + let mut o = jzon::object! { + "t": "mouse", + "id": *id, + "k": k, + "col": *col as u64, + "row": *row as u64, + }; if let Some(b) = btn { let _ = o.insert("b", b as u64); } - let _ = o.insert("col", *col as u64); - let _ = o.insert("row", *row as u64); o } Command::Key { id, code, mods } => { @@ -872,7 +885,7 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { // Group and name fields are present only when set. insert_opt_str(&mut o, "group", &tv.group); insert_opt_str(&mut o, "name", &tv.name); - let _ = o.insert("life", lifecycle_str(tv.lifecycle)); + let _ = o.insert("life", tv.lifecycle.label()); let _ = o.insert("preview", tv.preview.text.as_str()); // Matcher rules are process-local and omitted from the wire. let _ = o.insert("src", tv.preview.source.label()); @@ -957,10 +970,10 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { // Missing and null both mean unassigned/unnamed. group: opt_str(&tv["group"])?, name: opt_str(&tv["name"])?, - lifecycle: lifecycle_from(tv["life"].as_str()?)?, + lifecycle: Lifecycle::from_label(tv["life"].as_str()?)?, preview: Preview { text: tv["preview"].as_str()?.to_string(), - source: source_from(tv["src"].as_str()?)?, + source: PreviewSource::from_label(tv["src"].as_str()?)?, rule: None, frozen: tv["frozen"].as_bool()?, }, diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs index 5923ed1..1ac7925 100644 --- a/src/protocol_tests.rs +++ b/src/protocol_tests.rs @@ -462,99 +462,82 @@ fn tasks_round_trip() { assert_eq!(decode_event(k, &p), Some(tasks)); } -/// `SetGroup` emits `"g"` only for an assignment. A missing or null `"g"` -/// decodes as a clear. +/// Encode `SetGroup` assignments with key `"g"` and `SetName` assignments with +/// key `"n"`. Decode missing or null keys as clears; require strings for present values. #[test] -fn set_group_wire_form() { - let (k, p) = encode_command(&Command::SetGroup { - id: 3, - group: Some("infra".into()), - }); - assert_eq!(k, KIND_CONTROL); - assert_eq!( - std::str::from_utf8(&p).unwrap(), - r#"{"t":"group","id":3,"g":"infra"}"# - ); - let (_, p) = encode_command(&Command::SetGroup { id: 3, group: None }); - assert!(!String::from_utf8(p).unwrap().contains("\"g\"")); - // An explicit null clears, same as an omitted key. - assert_eq!( - decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":null}"#), - Some(Command::SetGroup { id: 3, group: None }) - ); - // A present group must be a string. - assert_eq!( - decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":5}"#), - None - ); -} - -/// `SetName` omits `"n"` when clearing; a missing or null `"n"` decodes as -/// a clear. -#[test] -fn set_name_wire_form() { - let (k, p) = encode_command(&Command::SetName { - id: 3, - name: Some("api".into()), - }); - assert_eq!(k, KIND_CONTROL); - assert_eq!( - std::str::from_utf8(&p).unwrap(), - r#"{"t":"name","id":3,"n":"api"}"# - ); - let (_, p) = encode_command(&Command::SetName { id: 3, name: None }); - assert!(!String::from_utf8(p).unwrap().contains("\"n\"")); - // An explicit null clears, same as an omitted key. - assert_eq!( - decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":null}"#), - Some(Command::SetName { id: 3, name: None }) - ); - // A present name must be a string. - assert_eq!( - decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":5}"#), - None - ); -} - -/// Task frames omit `"group"` when unassigned; an absent key decodes as -/// `None`. -#[test] -fn tasks_frame_group_key_is_optional() { - // "Lw==" is the base64 encoding of "/". - let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; - match decode_event(KIND_CONTROL, ungrouped.as_bytes()) { - Some(Event::Tasks(v)) => assert_eq!(v[0].group, None), - other => panic!("expected tasks event, got {other:?}"), - } - // Encoding an unassigned task omits the group key. - let (_, p) = encode_event(&Event::Tasks(vec![tv(1)])); - assert_eq!(std::str::from_utf8(&p).unwrap(), ungrouped); - - let grouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"group":"infra","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; - match decode_event(KIND_CONTROL, grouped.as_bytes()) { - Some(Event::Tasks(v)) => assert_eq!(v[0].group.as_deref(), Some("infra")), - other => panic!("expected tasks event, got {other:?}"), +fn set_group_and_set_name_wire_forms() { + for (set, cleared, key, set_wire, null_wire, bad_wire) in [ + ( + Command::SetGroup { + id: 3, + group: Some("infra".into()), + }, + Command::SetGroup { id: 3, group: None }, + "\"g\"", + r#"{"t":"group","id":3,"g":"infra"}"#, + r#"{"t":"group","id":3,"g":null}"#, + r#"{"t":"group","id":3,"g":5}"#, + ), + ( + Command::SetName { + id: 3, + name: Some("api".into()), + }, + Command::SetName { id: 3, name: None }, + "\"n\"", + r#"{"t":"name","id":3,"n":"api"}"#, + r#"{"t":"name","id":3,"n":null}"#, + r#"{"t":"name","id":3,"n":5}"#, + ), + ] { + let (k, p) = encode_command(&set); + assert_eq!(k, KIND_CONTROL); + assert_eq!(std::str::from_utf8(&p).unwrap(), set_wire); + let (_, p) = encode_command(&cleared); + let p = String::from_utf8(p).unwrap(); + assert!(!p.contains(key), "clearing must omit {key}; frame was {p}"); + // Treat explicit null the same as an omitted key when decoding. + assert_eq!( + decode_command(KIND_CONTROL, null_wire.as_bytes()), + Some(cleared), + "frame was {null_wire}" + ); + assert_eq!( + decode_command(KIND_CONTROL, bad_wire.as_bytes()), + None, + "frame was {bad_wire}" + ); } } -/// Task frames omit `"name"` when unnamed; an absent key decodes as -/// `None`. +/// Omit `group` and `name` when a task has no labels; decode missing keys as `None`. #[test] -fn tasks_frame_name_key_is_optional() { +fn tasks_frame_label_keys_are_optional() { + let labels = |v: &[TaskView]| (v[0].group.clone(), v[0].name.clone()); // "Lw==" is the base64 encoding of "/". - let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; - match decode_event(KIND_CONTROL, unnamed.as_bytes()) { - Some(Event::Tasks(v)) => assert_eq!(v[0].name, None), + let unlabelled = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + match decode_event(KIND_CONTROL, unlabelled.as_bytes()) { + Some(Event::Tasks(v)) => assert_eq!(labels(&v), (None, None)), other => panic!("expected tasks event, got {other:?}"), } - // Encoding an unnamed task omits the name key. + // Omit both keys when encoding an unassigned, unnamed task. let (_, p) = encode_event(&Event::Tasks(vec![tv(1)])); - assert_eq!(std::str::from_utf8(&p).unwrap(), unnamed); + assert_eq!(std::str::from_utf8(&p).unwrap(), unlabelled); - let named = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"name":"build","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; - match decode_event(KIND_CONTROL, named.as_bytes()) { - Some(Event::Tasks(v)) => assert_eq!(v[0].name.as_deref(), Some("build")), - other => panic!("expected tasks event, got {other:?}"), + for (labelled, expected) in [ + ( + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"group":"infra","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, + (Some("infra".to_string()), None), + ), + ( + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"name":"build","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, + (None, Some("build".to_string())), + ), + ] { + match decode_event(KIND_CONTROL, labelled.as_bytes()) { + Some(Event::Tasks(v)) => assert_eq!(labels(&v), expected, "frame was {labelled}"), + other => panic!("expected tasks event, got {other:?}"), + } } } @@ -654,7 +637,7 @@ fn preview_source_and_frozen_round_trip() { rule: Some("claude-status"), ..base.preview.clone() }, - ..base.clone() + ..base }]); let (k, p) = encode_event(&ruled); assert!( @@ -671,12 +654,21 @@ fn preview_source_and_frozen_round_trip() { } } -/// Same-version task frames require a known source and a boolean frozen flag. +/// Require a known source and boolean `frozen` and `managed` values in same-version task +/// frames. Round-trip the managed flag in both states. #[test] -fn tasks_frame_requires_valid_preview_metadata() { - let tasks = Event::Tasks(vec![tv(1)]); - let (k, p) = encode_event(&tasks); - assert_eq!(decode_event(k, &p), Some(tasks)); +fn tasks_frame_requires_row_fields() { + for managed in [false, true] { + let tasks = Event::Tasks(vec![TaskView { managed, ..tv(1) }]); + let (k, p) = encode_event(&tasks); + let s = std::str::from_utf8(&p).unwrap(); + assert!( + s.contains(&format!("\"managed\":{managed}")), + "frame was {s}" + ); + assert_eq!(decode_event(k, &p), Some(tasks)); + } + let (k, p) = encode_event(&Event::Tasks(vec![tv(1)])); let valid = String::from_utf8(p).unwrap(); for (field, value, invalid) in [ ( @@ -689,9 +681,11 @@ fn tasks_frame_requires_valid_preview_metadata() { "false", &["null", "0", "\"false\"", "[]", "{}"][..], ), + ("managed", "false", &["null", "0", "\"true\"", "[]"][..]), ] { let member = format!("\"{field}\":{value}"); let missing = valid.replace(&format!("{member},"), ""); + assert_ne!(missing, valid, "premise: the encoder writes {field}"); assert_eq!(decode_event(k, missing.as_bytes()), None, "missing {field}"); for value in invalid { let bad = valid.replace(&member, &format!("\"{field}\":{value}")); @@ -948,30 +942,6 @@ fn agents_event_round_trips_and_pins_the_wire_shape() { } } -/// Require a boolean `managed` field on every task row during encoding and decoding. -#[test] -fn tasks_frame_managed_flag_round_trips_and_is_required() { - for managed in [false, true] { - let tasks = Event::Tasks(vec![TaskView { managed, ..tv(1) }]); - let (k, p) = encode_event(&tasks); - let s = std::str::from_utf8(&p).unwrap(); - assert!( - s.contains(&format!("\"managed\":{managed}")), - "frame was {s}" - ); - assert_eq!(decode_event(k, &p), Some(tasks)); - } - let (k, p) = encode_event(&Event::Tasks(vec![tv(1)])); - let valid = String::from_utf8(p).unwrap(); - let missing = valid.replace("\"managed\":false,", ""); - assert_ne!(missing, valid, "premise: the encoder writes the key"); - assert_eq!(decode_event(k, missing.as_bytes()), None, "missing managed"); - for bad in ["null", "0", "\"true\"", "[]"] { - let frame = valid.replace("\"managed\":false", &format!("\"managed\":{bad}")); - assert_eq!(decode_event(k, frame.as_bytes()), None, "frame was {frame}"); - } -} - /// `LoadRecovery` requires a string stem in its wire representation. #[test] fn load_recovery_wire_form() { diff --git a/src/selection.rs b/src/selection.rs index 769fc96..b47f5d7 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -128,10 +128,7 @@ fn segment_span(row: &str, from: usize, to: Option) -> Option<(usize, &st #[cfg(test)] mod tests { use super::*; - - fn screen(rows: &[&str]) -> Vec { - rows.iter().map(|r| r.to_string()).collect() - } + use crate::testutil::rows as screen; /// A selection pressed at `a` and dragged to `b`, both `(row, col)`. fn drag(a: (u16, u16), b: (u16, u16)) -> Selection { diff --git a/src/session.rs b/src/session.rs index 7d980cb..c08011a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,5 +1,8 @@ -//! JSON session recipes stored one file per name in the user's config directory. On -//! recipe load, start new tasks without restoring live processes. +//! Serialize named recipes and automatic recovery snapshots with the same +//! `to_json`/`from_json` schema. Store one file per recipe name or recovery stem. +//! Let each caller choose the filesystem root. The supervisor reads its root +//! from the connection's launch context. On load, start new tasks without +//! restoring live processes. use std::{ collections::BTreeMap, @@ -43,7 +46,7 @@ pub struct SessionEntry { #[cfg(test)] impl SessionEntry { /// Unlabelled literal entry. - pub(crate) fn literal(cmd: &str) -> Self { + pub fn literal(cmd: &str) -> Self { Self { kind: EntryKind::Literal(cmd.into()), group: None, @@ -52,7 +55,7 @@ impl SessionEntry { } /// Unlabelled managed entry. - pub(crate) fn managed(agent: &str, resume: Option<&str>) -> Self { + pub fn managed(agent: &str, resume: Option<&str>) -> Self { Self { kind: EntryKind::Managed { agent: agent.into(), @@ -544,9 +547,6 @@ pub fn load_recovery_in(dir: &Path, stem: &str) -> io::Result { /// Parse a recovery stem's trailing positive `i32` process ID. fn stem_pid(stem: &str) -> Option { let (_, pid) = stem.rsplit_once('-')?; - if pid.is_empty() || !pid.bytes().all(|b| b.is_ascii_digit()) { - return None; - } positive_pid(pid) } @@ -602,6 +602,13 @@ mod tests { SessionEntry::managed(agent, resume) } + /// Build a one-entry recipe with `vim` in `~/p`. + fn vim_cfg() -> SessionConfig { + let mut cfg = SessionConfig::new(); + cfg.insert("~/p".into(), vec![e("vim")]); + cfg + } + /// Grouped literal: the `{"cmd", "group"}` member form. fn ge(cmd: &str, group: &str) -> SessionEntry { SessionEntry { @@ -1222,8 +1229,7 @@ mod tests { #[test] fn long_names_save_within_name_max() { let dir = temp("session_long_name"); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); + let cfg = vim_cfg(); let name = "n".repeat(255); let file = save_in(&dir, &name, &cfg).unwrap(); @@ -1253,8 +1259,7 @@ mod tests { fn sessions_dir_is_created_private_and_retightened() { let base = temp("session_dir_mode"); let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777; - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); + let cfg = vim_cfg(); let nested = base.join("parent").join("sessions"); save_in(&nested, "fresh", &cfg).unwrap(); @@ -1272,8 +1277,7 @@ mod tests { #[test] fn save_leaves_no_temp_file() { let dir = temp("session_notemp"); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); + let cfg = vim_cfg(); save_in(&dir, "clean", &cfg).unwrap(); let names: Vec = fs::read_dir(&*dir) @@ -1396,8 +1400,7 @@ mod tests { #[test] fn lists_stored_names_for_new_schema_files() { let dir = temp("session_list_names"); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); + let cfg = vim_cfg(); save_in(&dir, "a/b", &cfg).unwrap(); fs::write(dir.join("legacy.json"), r#"{"~/x": ["top"]}"#).unwrap(); @@ -1415,6 +1418,32 @@ mod tests { /// Out-of-range PID used for dead-writer fixtures. const DEAD_FIXTURE_PID: u32 = 9_999_999; + /// Write one dead-writer snapshot for each value in `minutes`. Use stem + /// `-0930NN-` and that day's 09:30 label. + fn fill_dead( + rec: &Path, + day: &str, + minutes: std::ops::RangeInclusive, + cfg: &SessionConfig, + ) { + let label = format!("autosaved {}-{}-{} 09:30", &day[..4], &day[4..6], &day[6..]); + for i in minutes { + let stem = format!("{day}-0930{i:02}-{DEAD_FIXTURE_PID}"); + save_recovery_in(rec, &stem, &label, cfg).unwrap(); + } + } + + /// Return the sorted file names in `dir`. + fn names_in(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .unwrap() + .flatten() + .map(|e| e.file_name().into_string().unwrap()) + .collect(); + names.sort(); + names + } + /// The stem is `-`; the label is the write minute. #[test] fn recovery_stem_and_label_render_utc() { @@ -1457,19 +1486,10 @@ mod tests { fn recovery_prune_keeps_the_newest_ten() { let base = temp("session_recovery_prune"); let rec = recovery_dir(&base); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); - for i in 1..=12u32 { - let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}"); - save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap(); - } + let cfg = vim_cfg(); + fill_dead(&rec, "20260714", 1..=12, &cfg); - let mut names: Vec = fs::read_dir(&rec) - .unwrap() - .flatten() - .map(|e| e.file_name().into_string().unwrap()) - .collect(); - names.sort(); + let names = names_in(&rec); let expected: Vec = (3..=12u32) .map(|i| format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}.json")) .collect(); @@ -1481,12 +1501,8 @@ mod tests { fn recovery_prune_exempts_the_active_stem() { let base = temp("session_recovery_prune_active"); let rec = recovery_dir(&base); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); - for i in 1..=10u32 { - let stem = format!("20260715-0930{i:02}-{DEAD_FIXTURE_PID}"); - save_recovery_in(&rec, &stem, "autosaved 2026-07-15 09:30", &cfg).unwrap(); - } + let cfg = vim_cfg(); + fill_dead(&rec, "20260715", 1..=10, &cfg); // This dead-PID stem sorts below every existing snapshot. let active_stem = format!("20260714-093000-{DEAD_FIXTURE_PID}"); @@ -1494,12 +1510,7 @@ mod tests { save_recovery_in(&rec, &active_stem, "autosaved 2026-07-15 09:30", &cfg).unwrap(); assert!(active.exists(), "the just-written snapshot must survive"); - let mut names: Vec = fs::read_dir(&rec) - .unwrap() - .flatten() - .map(|e| e.file_name().into_string().unwrap()) - .collect(); - names.sort(); + let names = names_in(&rec); let mut expected = vec![format!("{active_stem}.json")]; expected .extend((2..=10u32).map(|i| format!("20260715-0930{i:02}-{DEAD_FIXTURE_PID}.json"))); @@ -1514,12 +1525,8 @@ mod tests { fn recovery_prune_below_limit_removes_nothing() { let base = temp("session_recovery_prune_few"); let rec = recovery_dir(&base); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); - for i in 1..=5u32 { - let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}"); - save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap(); - } + let cfg = vim_cfg(); + fill_dead(&rec, "20260714", 1..=5, &cfg); assert_eq!( fs::read_dir(&rec).unwrap().flatten().count(), @@ -1533,22 +1540,13 @@ mod tests { fn recovery_prune_exempts_live_pid_stems() { let base = temp("session_recovery_prune_live"); let rec = recovery_dir(&base); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); + let cfg = vim_cfg(); // The oldest candidate names this live test process. let live_stem = format!("20260101-000000-{}", std::process::id()); save_recovery_in(&rec, &live_stem, "autosaved 2026-01-01 00:00", &cfg).unwrap(); - for i in 1..=11u32 { - let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}"); - save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap(); - } + fill_dead(&rec, "20260714", 1..=11, &cfg); - let mut names: Vec = fs::read_dir(&rec) - .unwrap() - .flatten() - .map(|e| e.file_name().into_string().unwrap()) - .collect(); - names.sort(); + let names = names_in(&rec); let mut expected = vec![format!("{live_stem}.json")]; expected .extend((2..=11u32).map(|i| format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}.json"))); @@ -1563,14 +1561,10 @@ mod tests { fn recovery_prune_removes_dead_pid_stems() { let base = temp("session_recovery_prune_dead"); let rec = recovery_dir(&base); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); + let cfg = vim_cfg(); let oldest = format!("20260101-000000-{}", dead_pid()); save_recovery_in(&rec, &oldest, "autosaved 2026-01-01 00:00", &cfg).unwrap(); - for i in 1..=10u32 { - let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}"); - save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap(); - } + fill_dead(&rec, "20260714", 1..=10, &cfg); assert!( !rec.join(format!("{oldest}.json")).exists(), @@ -1588,8 +1582,7 @@ mod tests { fn recovery_prune_ignores_malformed_pid_suffixes() { let base = temp("session_recovery_prune_malformed"); let rec = recovery_dir(&base); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); + let cfg = vim_cfg(); let malformed = [ "20260101-000000-x42", // non-numeric pid "20260101-000001-99999999999", // past i32::MAX @@ -1599,10 +1592,7 @@ mod tests { for stem in malformed { save_recovery_in(&rec, stem, "autosaved 2026-01-01 00:00", &cfg).unwrap(); } - for i in 1..=10u32 { - let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}"); - save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap(); - } + fill_dead(&rec, "20260714", 1..=10, &cfg); for stem in malformed { assert!( @@ -1621,8 +1611,7 @@ mod tests { #[test] fn list_ignores_the_recovery_subdirectory() { let dir = temp("session_list_recovery"); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); + let cfg = vim_cfg(); save_in(&dir, "real", &cfg).unwrap(); save_recovery_in( &recovery_dir(&dir), diff --git a/src/supervisor.rs b/src/supervisor.rs index 7cc3de9..80d8c92 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -83,6 +83,18 @@ struct LoadOutcome { } impl LoadOutcome { + /// Format nonzero skipped and failed counts as summary clauses. + fn clauses(&self) -> Vec { + let mut parts = Vec::new(); + if self.skipped > 0 { + parts.push(format!("{} skipped ({SKIP_REASONS})", self.skipped)); + } + if self.failed > 0 { + parts.push(format!("{} failed to spawn", self.failed)); + } + parts + } + /// Append each distinct note to `summary` once, with a count for repeated notes. For /// example, report an unchainable Codex notifier once for all affected entries. fn annotate(&self, mut summary: String) -> String { @@ -127,7 +139,7 @@ fn resolve_agent( pub const FLEETCOM_SCROLLBACK: &str = "FLEETCOM_SCROLLBACK"; /// Default history rows retained by each task's terminal grid. -pub const DEFAULT_SCROLLBACK: usize = 2000; +const DEFAULT_SCROLLBACK: usize = 2000; /// Maximum configured history rows per task. const MAX_SCROLLBACK: usize = 100_000; @@ -203,31 +215,6 @@ fn fnv1a_hex(bytes: &[u8]) -> String { format!("{h:016x}") } -/// Resolve a managed task's session ID in precedence order: capture file, live registry, -/// then spawn-time ID. Prefer the first two sources because a session may have been -/// selected after launch. Validate capture through the harness using the task's leader PID. -/// On rejection, try the next source. For literal tasks, skip all sources and return -/// `None`. -fn current_resume_id(task: &Task) -> Option { - if let (Some(h), Some(path)) = (task.harness, &task.capture_file) - && let Ok(payload) = std::fs::read_to_string(path) - && let Some(id) = h.parse_capture(&payload, task.pid()) - { - return Some(id); - } - if let (Some(h), Some(pid)) = (task.harness, task.pid()) - && let Some(id) = h.live_session_id( - pid, - &task.cwd, - task.spawned_at, - task.harness_home.as_deref(), - ) - { - return Some(id); - } - task.resume_id.clone() -} - /// Resolve harness configuration from the task's launch environment. fn harness_home(env: &[(OsString, OsString)], h: &dyn Harness) -> Option { h.resolve_home(&|key| env_get(env, key).map(PathBuf::from)) @@ -673,14 +660,14 @@ impl Supervisor { if cadence_due { self.recovery.last_cadence = now; } + // Clear the pending mutation before an early return or snapshot write. + self.recovery.last_mutation = None; // Do not replace an existing snapshot with an empty recipe. if self.tasks.is_empty() { - self.recovery.last_mutation = None; return; } // Skip this pass without a config root. let Some(root) = self.sessions_root() else { - self.recovery.last_mutation = None; return; }; let cfg = self.session_config(); @@ -695,7 +682,6 @@ impl Supervisor { .as_ref() .is_some_and(|(r, dest, h)| *r == root && *h == hash && std::fs::metadata(dest).is_ok()) { - self.recovery.last_mutation = None; return; } let label = session::recovery_label(std::time::SystemTime::now()); @@ -718,7 +704,6 @@ impl Supervisor { } } } - self.recovery.last_mutation = None; } /// Run recovery maintenance between clients without queuing task or screen @@ -800,7 +785,7 @@ impl Supervisor { /// and installation failure disables instrumentation for the spawn. fn ensure_capture_assets(&mut self) -> Option<&assets::CaptureAssets> { let root = self - .launch_env_path(path::FLEETCOM_RUNTIME_DIR) + .launch_env_path(crate::protocol::FLEETCOM_RUNTIME_DIR) .or_else(|| { assets::runtime_root().map(|base| { let key = self @@ -816,6 +801,9 @@ impl Supervisor { return self.capture.get(&key); } let installed = assets::CaptureAssets::install(&root, std::process::id()).ok()?; + // Call `install` first so `root` exists, then canonicalize it before caching. + // On macOS, use `/private/tmp` for `/tmp`; later lookups must use the same key + // or the daemon will reinstall the assets. let key = std::fs::canonicalize(&root).unwrap_or(root); Some(self.capture.entry(key).or_insert(installed)) } @@ -1009,7 +997,7 @@ impl Supervisor { let command; let relaunch = match self.tasks[i].harness { Some(agent) => { - let resume = current_resume_id(&self.tasks[i]); + let resume = self.tasks[i].current_resume_id(); match resolve_agent(agent.shape().0, &launch, resume) { Ok(m) => Launch::Managed(m), Err(why) => return self.status(format!("{why}, not spawning")), @@ -1029,8 +1017,8 @@ impl Supervisor { self.status(format!("task {id}: {notice}")); } fresh.tagged = self.tasks[i].tagged; - fresh.group = self.tasks[i].group.clone(); - fresh.name = self.tasks[i].name.clone(); + fresh.group.clone_from(&self.tasks[i].group); + fresh.name.clone_from(&self.tasks[i].name); // Read the resume ID before retiring the displaced run and removing its // capture file. let old = std::mem::replace(&mut self.tasks[i], fresh); @@ -1059,7 +1047,7 @@ impl Supervisor { let kind = match t.harness { Some(h) => EntryKind::Managed { agent: h.shape().0.to_string(), - resume: current_resume_id(t), + resume: t.current_resume_id(), }, None => EntryKind::Literal(t.command.clone()), }; @@ -1215,12 +1203,7 @@ impl Supervisor { if out.spawned > 0 || (out.skipped == 0 && out.failed == 0) { parts.push(format!("{} task(s)", out.spawned)); } - if out.skipped > 0 { - parts.push(format!("{} skipped ({SKIP_REASONS})", out.skipped)); - } - if out.failed > 0 { - parts.push(format!("{} failed to spawn", out.failed)); - } + parts.extend(out.clauses()); let msg = format!("loaded '{name}': {}", parts.join(", ")); self.status(out.annotate(msg)); } @@ -1237,11 +1220,8 @@ impl Supervisor { }; // Append optional clauses to the fixed message prefix. let mut msg = String::from("loaded recovery snapshot; save to name it"); - if out.skipped > 0 { - let _ = write!(msg, ", {} skipped ({SKIP_REASONS})", out.skipped); - } - if out.failed > 0 { - let _ = write!(msg, ", {} failed to spawn", out.failed); + for clause in out.clauses() { + let _ = write!(msg, ", {clause}"); } self.status(out.annotate(msg)); } diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index a73616a..f60298e 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -3,8 +3,8 @@ use crate::{ harness::fixtures::{CODEX_CHILD, CODEX_ROOT, CODEX_TITLE, ID as CAP_ID, OTHER as CAP_OTHER}, protocol::{Lifecycle, Preview, PreviewSource}, testutil::{ - codex_session_meta, dead_pid, install_codex_rollout, install_codex_root, - install_resident_shell, + codex_session_meta, dead_pid, hook_json, install_codex_rollout, install_codex_root, + install_resident_shell, with_shell, }, }; @@ -38,7 +38,7 @@ fn agent_ctx(bin: &Path, runtime: &Path, cwd: PathBuf) -> LaunchContext { ), ("SHELL".into(), "/bin/sh".into()), ( - "FLEETCOM_RUNTIME_DIR".into(), + FLEETCOM_RUNTIME_DIR.into(), runtime.as_os_str().to_os_string(), ), ], @@ -105,11 +105,6 @@ fn stamped(task: &Task, json: &str) -> String { ) } -/// The JSON Claude's `SessionStart` hook writes for session `id` started from `source`. -fn hook_json(id: &str, source: &str) -> String { - format!(r#"{{"session_id":"{id}","hook_event_name":"SessionStart","source":"{source}"}}"#) -} - /// Save a recipe and return its persisted JSON. fn save_and_read(s: &mut Supervisor, config: &Path, name: &str) -> String { s.apply(Command::SaveSession { name: name.into() }); @@ -367,7 +362,7 @@ fn typed_agent_word_is_literal_runs_verbatim_and_saves_as_text() { &dir, r#""status":"waiting","waitingFor":"permission prompt""#, ); - assert_eq!(current_resume_id(&s.tasks[0]), None); + assert_eq!(s.tasks[0].current_resume_id(), None); let entries = saved_entries(&mut s, &config, "typed"); assert_eq!(entries[0], SessionEntry::literal("claude")); assert!(matches!( @@ -403,9 +398,7 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { stamped(&s.tasks[0], &format!(r#"{{"session_id":"{CAP_ID}"}}"#)), ) .unwrap(); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); + wait_exited(&mut s, id); s.apply(Command::Restart { id }); let new_cap = s.tasks[0].capture_file.clone().expect("capture file set"); @@ -427,25 +420,6 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { ); } -/// Removing a task also removes its capture file. -#[test] -fn remove_deletes_the_capture_file() { - use crate::protocol::Lifecycle; - let dir = scratch("cap_remove"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "claude", &dir); - let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - s.spawn_agent("claude", dir.to_path_buf(), None); - let _ = wait_argv(&mut s, &dir.join("argv")); - let id = s.tasks[0].id; - wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); - let cap = s.tasks[0].capture_file.clone().unwrap(); - std::fs::write(&cap, "{}").unwrap(); - - s.apply(Command::Remove { id }); - assert!(!cap.exists(), "Remove must delete the task's capture file"); -} - /// Reconnecting with the active root reuses installed assets and preserves /// live capture files. #[test] @@ -507,7 +481,6 @@ fn returning_to_a_prior_root_preserves_its_live_captures() { /// not under whichever root the current client presents. #[test] fn remove_deletes_the_capture_file_under_the_spawn_root() { - use crate::protocol::Lifecycle; let dir = scratch("cap_remove_cross"); let (bin, root_a, root_b) = (dir.join("bin"), dir.join("run-a"), dir.join("run-b")); install_stub(&bin, "claude", &dir); @@ -943,7 +916,7 @@ until [ -e '{d}/done' ]; do sleep 0.05; done"#, std::fs::write(&cap, stamped(&s.tasks[0], &hook_json(CAP_OTHER, "clear"))).unwrap(); assert_eq!( - current_resume_id(&s.tasks[0]).as_deref(), + s.tasks[0].current_resume_id().as_deref(), Some(CAP_OTHER), "the task's own capture must beat the pin" ); @@ -969,13 +942,13 @@ until [ -e '{d}/done' ]; do sleep 0.05; done"#, // With no registry record yet, use the pinned ID after rejecting the capture. assert_eq!( - current_resume_id(&s.tasks[0]).as_deref(), + s.tasks[0].current_resume_id().as_deref(), Some(pinned.as_str()), "a foreign capture must fall through to the spawn-time id" ); install_status_record(&claude_home, pid, &dir, r#""status":"idle""#); assert_eq!( - current_resume_id(&s.tasks[0]).as_deref(), + s.tasks[0].current_resume_id().as_deref(), Some(CAP_ID), "a foreign capture must fall through to the registry" ); @@ -1036,7 +1009,7 @@ fn silent_codex_tasks_save_and_recover_without_a_resume_id() { .all(|t| t.finished.is_some()))); for task in &s.tasks { assert!( - current_resume_id(task).is_none(), + task.current_resume_id().is_none(), "no capture channel fired" ); let spawn_ms = task @@ -1226,24 +1199,25 @@ fn refused_codex_capture_keeps_the_launch_target() { /// joined with the tool's dot directory, then nothing. #[test] fn harness_home_prefers_the_tool_var_then_home() { - use crate::harness::{Claude, Codex, Grok, Omp}; + let [claude, codex, grok, omp] = + ["claude", "codex", "grok", "omp"].map(|p| crate::harness::registered(p).unwrap()); let env: Vec<(OsString, OsString)> = vec![ ("HOME".into(), "/h".into()), ("CODEX_HOME".into(), "/x".into()), ]; - assert_eq!(harness_home(&env, &Codex).as_deref(), Some(Path::new("/x"))); + assert_eq!(harness_home(&env, codex).as_deref(), Some(Path::new("/x"))); let env: Vec<(OsString, OsString)> = vec![("HOME".into(), "/h".into())]; assert_eq!( - harness_home(&env, &Codex).as_deref(), + harness_home(&env, codex).as_deref(), Some(Path::new("/h/.codex")) ); assert_eq!( - harness_home(&env, &Claude).as_deref(), + harness_home(&env, claude).as_deref(), Some(Path::new("/h/.claude")) ); - assert_eq!(harness_home(&env, &Grok), None); - assert_eq!(harness_home(&env, &Omp), None); - assert_eq!(harness_home(&[], &Codex), None); + assert_eq!(harness_home(&env, grok), None); + assert_eq!(harness_home(&env, omp), None); + assert_eq!(harness_home(&[], codex), None); } /// With only `HOME` in the launch environment, read notify routing from @@ -1286,9 +1260,8 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { ["-c", "features.daemon_auto_start=false"], "the guard must read /.codex/config.toml and inject only embedded mode" ); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); + let id = s.tasks[0].id; + wait_exited(&mut s, id); assert_eq!( saved_entries(&mut s, &config, "homeonly"), @@ -1335,9 +1308,8 @@ fn stale_inherited_notify_chain_is_never_executed() { )); let mut s = sup_ctx(ctx); s.spawn_agent("codex", dir.to_path_buf(), None); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); + let id = s.tasks[0].id; + wait_exited(&mut s, id); let cap = s.tasks[0].capture_file.clone().expect("capture file set"); assert_eq!( std::fs::read_to_string(&cap).unwrap(), @@ -1349,7 +1321,7 @@ fn stale_inherited_notify_chain_is_never_executed() { "the stale inherited chain must not execute" ); assert_eq!( - current_resume_id(&s.tasks[0]).as_deref(), + s.tasks[0].current_resume_id().as_deref(), Some(CAP_ID), "the notifier's payload for a root thread must pass the capture gate" ); @@ -1373,9 +1345,8 @@ fn managed_save_without_any_id_omits_resume() { ], )); s.spawn_agent("codex", dir.to_path_buf(), None); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some())); + let id = s.tasks[0].id; + wait_exited(&mut s, id); let text = save_and_read(&mut s, &config, "plainagent"); assert!( @@ -1457,7 +1428,7 @@ fn config_toml_notify_chains_through_the_injected_script() { "the validation step must precede the chain handoff" ); assert_eq!( - current_resume_id(&s.tasks[0]).as_deref(), + s.tasks[0].current_resume_id().as_deref(), Some(CAP_ID), "the notifier's payload for a root thread must pass the capture gate" ); @@ -1628,11 +1599,7 @@ fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { ), ); let mut ctx = agent_ctx(&bin, &runtime, dir.to_path_buf()); - ctx.env.retain(|(k, _)| k != "SHELL"); - ctx.env.push(( - "SHELL".into(), - install_resident_shell(&dir).into_os_string(), - )); + ctx.env = with_shell(ctx.env, install_resident_shell(&dir)); let mut s = sup_ctx(ctx); s.spawn_agent("claude", dir.to_path_buf(), None); assert!(acknowledged(&s.drain()), "a managed spawn is acknowledged"); @@ -1652,7 +1619,7 @@ fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { ["--session-id", &pinned, "--settings", &settings_beside(&s)] ); assert_eq!( - current_resume_id(&s.tasks[0]).as_deref(), + s.tasks[0].current_resume_id().as_deref(), Some(CAP_OTHER), "the stub's `$$` is the leader pid: no shell sat between" ); @@ -1663,7 +1630,7 @@ fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { let (_, json) = text.split_once('\n').unwrap(); std::fs::write(&cap, format!("{}\n{json}", dead_pid())).unwrap(); assert_eq!( - current_resume_id(&s.tasks[0]).as_deref(), + s.tasks[0].current_resume_id().as_deref(), Some(pinned.as_str()), "a foreign stamp must fall through to the pinned id" ); @@ -1864,7 +1831,7 @@ fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { "the reloaded claude entry must resume its captured ID: {claude_argv:?}" ); assert_eq!( - current_resume_id(&s.tasks[2]).as_deref(), + s.tasks[2].current_resume_id().as_deref(), Some(CAP_OTHER), "the reloaded run's own capture outranks the recipe's resume ID" ); @@ -1984,14 +1951,7 @@ fn tick_until_preview( ) -> Preview { let mut last = None; wait_until(budget, || { - s.tick(); - for e in s.drain() { - if let Event::Tasks(v) = e - && let Some(t) = v.into_iter().next() - { - last = Some(t.preview); - } - } + last = snapshot(s).into_iter().next().map(|t| t.preview); last.as_ref().is_some_and(&mut pred) }); last.expect("a Tasks snapshot must carry the task's preview") @@ -2056,12 +2016,8 @@ fn registry_waiting_status_reaches_the_dashboard_preview() { // After exit, a changed record must neither retain nor replace the cached // blocked preview. std::fs::write(&done, b"").unwrap(); - assert!( - reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] - .finished - .is_some()), - "the leader never exited" - ); + let id = s.tasks[0].id; + wait_exited(&mut s, id); install_status_record( &claude_home, pid, diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index a0172ac..b38c933 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -1,10 +1,15 @@ use std::path::Path; +use nix::{ + sys::signal::{Signal, kill}, + unistd::Pid, +}; + use super::*; use crate::{ - protocol::{ClipboardKind, Key, Mods}, + protocol::{ClipboardKind, FLEETCOM_RUNTIME_DIR, Key, Lifecycle, Mods}, testutil::{ - Scratch, here, install_fake_notifier, now_ms, read_pid, sh_env, wait_until, + Scratch, here, install_fake_notifier, now_ms, read_pid, screen, sh_env, wait_until, write_executable, }, }; @@ -55,8 +60,8 @@ fn scrollback_resolution_precedence_clamp_and_fallback() { assert_eq!(effective_scrollback(Some(0), Some("500")), 0); } -/// The recipe groups commands by dir and preserves spawn order within a dir. -/// `a`/`c` share the invocation dir; `b` is off in `/tmp`. +/// Check grouping by directory and preservation of spawn order: `a` and `c` +/// share the invocation directory; `b` runs in `/tmp`. #[test] fn session_config_groups_by_dir_in_spawn_order() { let mut s = sup(24, 80); @@ -72,9 +77,8 @@ fn session_config_groups_by_dir_in_spawn_order() { assert_eq!(cfg["/tmp"], vec![SessionEntry::literal("b")]); } -/// `tick` emits exactly a `Tasks` snapshot while nothing is watched, and -/// adds a `Screen` for the watched task once `Watch` is set: the contract -/// the client's render loop depends on. +/// Verify that `tick` queues only a `Tasks` snapshot when no task is watched, +/// then queues a `Screen` for the watched task after `Watch`. #[test] fn tick_emits_snapshot_and_watched_screen() { let mut s = sup(24, 80); @@ -107,8 +111,7 @@ fn tick_emits_snapshot_and_watched_screen() { ); } -/// A watched task whose screen hasn't changed must not re-emit a `Screen` -/// every tick: the send-on-change that kills idle attach churn. +/// Verify that the supervisor sends a watched task's screen only after it changes. #[test] fn watched_screen_not_resent_when_unchanged() { let mut s = sup(24, 80); @@ -117,37 +120,21 @@ fn watched_screen_not_resent_when_unchanged() { // stabilizes before we assert nothing changes. let mut id = 0; for _ in 0..5 { - s.tick(); - for e in s.drain() { - if let Event::Tasks(v) = e - && let Some(t) = v.first() - { - id = t.id; - } - } + id = first_id(&mut s); std::thread::sleep(Duration::from_millis(20)); } - assert!(id != 0, "task never appeared"); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - s.tick(); + watch(&mut s, id); assert!( - s.drain().iter().any(|e| matches!(e, Event::Screen(_))), + screen_sent(&mut s), "first watched tick sends a full screen" ); // The screen is now stable; further ticks must not re-send it. - s.tick(); - assert!( - !s.drain().iter().any(|e| matches!(e, Event::Screen(_))), - "unchanged screen must not be resent" - ); + assert!(!screen_sent(&mut s), "unchanged screen must not be resent"); } -/// A DECSET 1007 change emits a new `Screen` event even when the rendered -/// contents are unchanged. +/// Verify that toggling DECSET 1007 queues a `Screen` event even when the +/// rendered contents do not change. #[test] fn decset_1007_flip_resends_watched_screen() { let dir = scratch("flip_1007"); @@ -162,10 +149,7 @@ fn decset_1007_flip_resends_watched_screen() { f = flag.display() ); let id = spawn_ready(&mut s, cmd, here(), &ready); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); + watch(&mut s, id); // Wait for the initial alternate-scroll state. let open = wait_until(Duration::from_secs(5), || { @@ -186,8 +170,8 @@ fn decset_1007_flip_resends_watched_screen() { assert!(closed, "the ?1007l flip never re-sent the screen"); } -/// A periodic tick flushes an expired synchronized update from a child -/// that stops producing output. +/// Verify that a periodic tick flushes an expired synchronized update after the +/// child stops producing output. #[test] fn tick_flushes_a_stalled_sync_update() { let mut s = sup(24, 80); @@ -198,14 +182,7 @@ fn tick_flushes_a_stalled_sync_update() { ); let mut preview = String::new(); wait_until(Duration::from_secs(5), || { - s.tick(); - for e in s.drain() { - if let Event::Tasks(v) = e - && let Some(t) = v.first() - { - preview = t.preview.text.clone(); - } - } + preview = snapshot(&mut s)[0].preview.text.clone(); preview.contains("stalled") }); assert!( @@ -214,7 +191,7 @@ fn tick_flushes_a_stalled_sync_update() { ); } -/// Stores from the attached task are forwarded in arrival order. +/// Forward attached-task clipboard stores in arrival order. #[test] fn watched_task_clipboard_stores_are_forwarded() { let dir = scratch("clip_fwd"); @@ -228,10 +205,7 @@ fn watched_task_clipboard_stores_are_forwarded() { f = flag.display() ); let id = spawn_ready(&mut s, cmd, here(), &ready); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); + watch(&mut s, id); std::fs::write(&flag, b"").unwrap(); let mut copies = Vec::new(); @@ -265,33 +239,20 @@ fn watch_purges_stores_captured_before_the_watch() { "printf '\\033]52;c;c3RhbGU=\\007MARKER'; sleep 30", here(), ); - let parsed = wait_until(Duration::from_secs(5), || { - s.tasks.first().is_some_and(|t| { - let (formatted, _, _) = t.formatted(); - String::from_utf8_lossy(&formatted).contains("MARKER") - }) - }); - assert!(parsed, "the marker never reached the grid"); + assert!( + wait_until(Duration::from_secs(5), || grid_shows(&s, "MARKER")), + "the marker never reached the grid" + ); let id = s.tasks[0].id; - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - let mut saw_screen = false; - for _ in 0..3 { - s.tick(); - for e in s.drain() { - match e { - Event::ClipboardCopy { .. } => { - panic!("a store captured before the watch must not fire after it") - } - Event::Screen(_) => saw_screen = true, - _ => {} - } - } - } - assert!(saw_screen, "watching the task should stream its screen"); + watch(&mut s, id); + assert!( + ticks_without_copies( + &mut s, + "a store captured before the watch must not fire after it" + ), + "watching the task should stream its screen" + ); } /// Stores from an unwatched task are discarded instead of deferred. @@ -329,24 +290,14 @@ fn backgrounded_clipboard_store_is_discarded_not_deferred() { s.tick(); let _ = s.drain(); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - let mut saw_screen = false; - for _ in 0..3 { - s.tick(); - for e in s.drain() { - match e { - Event::ClipboardCopy { .. } => { - panic!("a store buffered while backgrounded must never fire on watch") - } - Event::Screen(_) => saw_screen = true, - _ => {} - } - } - } - assert!(saw_screen, "watching the task should stream its screen"); + watch(&mut s, id); + assert!( + ticks_without_copies( + &mut s, + "a store buffered while backgrounded must never fire on watch" + ), + "watching the task should stream its screen" + ); } /// Peeked stores are discarded, while stores captured after attachment forward. @@ -379,52 +330,26 @@ fn peeked_stores_never_forward_and_die_at_the_attach_transition() { // A store drained during peek is not forwarded. std::fs::write(&flag1, b"").unwrap(); - let parsed = wait_until(Duration::from_secs(5), || { - s.tasks.first().is_some_and(|t| { - let (formatted, _, _) = t.formatted(); - String::from_utf8_lossy(&formatted).contains("M1") - }) - }); - assert!(parsed, "the first marker never reached the grid"); - let mut saw_screen = false; - for _ in 0..3 { - s.tick(); - for e in s.drain() { - match e { - Event::ClipboardCopy { .. } => { - panic!("a peeked task's store must never forward") - } - Event::Screen(_) => saw_screen = true, - _ => {} - } - } - } assert!( - saw_screen, + wait_until(Duration::from_secs(5), || grid_shows(&s, "M1")), + "the first marker never reached the grid" + ); + assert!( + ticks_without_copies(&mut s, "a peeked task's store must never forward"), "peeking the task should still stream its screen" ); // A buffered peek store is discarded when the same task becomes attached. std::fs::write(&flag2, b"").unwrap(); - let parsed = wait_until(Duration::from_secs(5), || { - s.tasks.first().is_some_and(|t| { - let (formatted, _, _) = t.formatted(); - String::from_utf8_lossy(&formatted).contains("M2") - }) - }); - assert!(parsed, "the second marker never reached the grid"); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - for _ in 0..3 { - s.tick(); - for e in s.drain() { - if let Event::ClipboardCopy { .. } = e { - panic!("a store captured during peek must not fire after attach") - } - } - } + assert!( + wait_until(Duration::from_secs(5), || grid_shows(&s, "M2")), + "the second marker never reached the grid" + ); + watch(&mut s, id); + ticks_without_copies( + &mut s, + "a store captured during peek must not fire after attach", + ); // A store captured after attachment is forwarded. std::fs::write(&flag3, b"").unwrap(); @@ -461,10 +386,7 @@ fn oversized_watched_store_yields_notice_and_no_copy() { f = flag.display() ); let id = spawn_ready(&mut s, cmd, here(), &ready); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); + watch(&mut s, id); std::fs::write(&flag, b"").unwrap(); let mut notice = None; @@ -488,14 +410,14 @@ fn oversized_watched_store_yields_notice_and_no_copy() { ); } -/// Scratch dir for tests that sync through marker files. +/// Create a `sup_`-prefixed scratch directory for the test's config root, +/// stub binaries, and marker files. fn scratch(tag: &str) -> Scratch { crate::testutil::temp(&format!("sup_{tag}")) } -/// Spawn `command` and block until it has written `ready`: the sync that -/// keeps kill-path tests deterministic (no signalling a shell that hasn't -/// installed its trap yet). +/// Spawn `command` and wait for its `ready` marker before sending signals or +/// changing clipboard and DECSET gates. fn spawn_ready(s: &mut Supervisor, command: String, cwd: PathBuf, ready: &Path) -> u64 { spawn(s, command, cwd); assert!( @@ -505,29 +427,90 @@ fn spawn_ready(s: &mut Supervisor, command: String, cwd: PathBuf, ready: &Path) first_id(s) } -/// Tick once and return the first task id from `Tasks`, ignoring other events. -fn first_id(s: &mut Supervisor) -> u64 { +/// Tick once and return the `Tasks` snapshot it queues, discarding every +/// other event. +fn snapshot(s: &mut Supervisor) -> Vec { s.tick(); s.drain() - .iter() + .into_iter() .find_map(|e| match e { - Event::Tasks(v) => Some(v[0].id), + Event::Tasks(v) => Some(v), _ => None, }) .expect("expected a Tasks snapshot") } -/// Tick once and return task `id` from the emitted snapshot. +/// Tick once and return the first task id from the snapshot. +fn first_id(s: &mut Supervisor) -> u64 { + snapshot(s)[0].id +} + +/// Tick once and return task `id` from the snapshot. fn view_of(s: &mut Supervisor, id: u64) -> TaskView { + snapshot(s) + .into_iter() + .find(|t| t.id == id) + .unwrap_or_else(|| panic!("task {id} missing from the snapshot")) +} + +/// Tick once and report whether a `Screen` event was queued. +fn screen_sent(s: &mut Supervisor) -> bool { s.tick(); - for e in s.drain() { - if let Event::Tasks(v) = e - && let Some(t) = v.iter().find(|t| t.id == id) - { - return t.clone(); + s.drain().iter().any(|e| matches!(e, Event::Screen(_))) +} + +/// Apply an attached `Watch` of `id`. +fn watch(s: &mut Supervisor, id: u64) { + s.apply(Command::Watch { + id: Some(id), + attached: true, + }); +} + +/// Tick three times, panicking with `why` on any `ClipboardCopy`; report +/// whether a `Screen` arrived meanwhile. +fn ticks_without_copies(s: &mut Supervisor, why: &str) -> bool { + let mut saw_screen = false; + for _ in 0..3 { + s.tick(); + for e in s.drain() { + match e { + Event::ClipboardCopy { .. } => panic!("{why}"), + Event::Screen(_) => saw_screen = true, + _ => {} + } } } - panic!("task {id} missing from the snapshot"); + saw_screen +} + +/// Search the first task's formatted grid for `marker`. +fn grid_shows(s: &Supervisor, marker: &str) -> bool { + s.tasks.first().is_some_and(|t| { + let (formatted, _, _) = t.formatted(); + String::from_utf8_lossy(&formatted).contains(marker) + }) +} + +/// Build a short-grid supervisor with one watched task in retained history. +/// `seq 1 200` overflows six rows at once. +fn scrolled_task() -> (Supervisor, u64) { + let mut s = sup(6, 80); + spawn(&mut s, "seq 1 200; sleep 30", here()); + let id = first_id(&mut s); + watch(&mut s, id); + // Retry until output has produced retained history. + let scrolled = wait_until(Duration::from_secs(5), || { + s.tick(); + let _ = s.drain(); + s.apply(Command::Scrollback { + id, + action: ScrollAction::Up(3), + }); + s.tasks[0].scroll_offset() > 0 + }); + assert!(scrolled, "the task never accrued scrollback"); + (s, id) } /// Launch context with `FLEETCOM_CONFIG_DIR` and optional environment entries. @@ -543,11 +526,7 @@ fn config_ctx(config: &Path, cwd: PathBuf, extra: &[(&str, &str)]) -> LaunchCont } /// Poll ticks until the task's lifecycle satisfies `pred`, or fail. -fn wait_for_lifecycle( - s: &mut Supervisor, - id: u64, - pred: impl Fn(crate::protocol::Lifecycle) -> bool, -) { +fn wait_for_lifecycle(s: &mut Supervisor, id: u64, pred: impl Fn(Lifecycle) -> bool) { let ok = wait_until(Duration::from_secs(5), || { s.tick(); s.drain().iter().any(|e| { @@ -563,7 +542,6 @@ fn wait_for_lifecycle( /// plus the `Ok` lifecycle is proof of TERM-before-KILL. #[test] fn kill_delivers_term_before_kill() { - use crate::protocol::Lifecycle; let dir = scratch("term_first"); let (ready, trapped) = (dir.join("ready"), dir.join("trapped")); let mut s = sup(24, 80); @@ -586,7 +564,6 @@ fn kill_delivers_term_before_kill() { /// reap-driven escalation. `Kill` must never leave an immortal task. #[test] fn term_ignoring_task_escalates_to_kill() { - use crate::protocol::Lifecycle; let dir = scratch("escalate"); let ready = dir.join("ready"); let mut s = sup(24, 80); @@ -616,12 +593,7 @@ fn shutdown_waits_the_grace_when_tasks_respect_term() { t0.elapsed() >= Duration::from_millis(200), "shutdown skipped the grace for a TERM-respecting task" ); - s.tick(); - assert!( - s.drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.is_empty())) - ); + assert!(snapshot(&mut s).is_empty()); } /// A blocked PTY write runs off the core thread, so shutdown remains bounded @@ -644,12 +616,7 @@ fn shutdown_survives_a_child_that_never_reads_stdin() { t0.elapsed() < Duration::from_secs(2), "shutdown blocked behind a PTY write to a non-reading child" ); - s.tick(); - assert!( - s.drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.is_empty())) - ); + assert!(snapshot(&mut s).is_empty()); } /// A message that would exceed the writer-queue limit is refused whole, @@ -709,12 +676,7 @@ fn shutdown_is_bounded_by_grace() { elapsed >= Duration::from_millis(200) && elapsed < Duration::from_secs(1), "shutdown took {elapsed:?}: eight tasks must share the 200 ms grace" ); - s.tick(); - assert!( - s.drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.is_empty())) - ); + assert!(snapshot(&mut s).is_empty()); } /// `clear_watch` (the client-disconnect path) must stop the `Screen` stream @@ -726,33 +688,21 @@ fn clear_watch_stops_screen_stream_and_resets_dedup() { spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - s.tick(); - assert!( - s.drain().iter().any(|e| matches!(e, Event::Screen(_))), - "watching should stream a Screen" - ); + watch(&mut s, id); + assert!(screen_sent(&mut s), "watching should stream a Screen"); // Disconnect: no client is watching anymore. s.clear_watch(); - s.tick(); assert!( - !s.drain().iter().any(|e| matches!(e, Event::Screen(_))), + !screen_sent(&mut s), "a disconnected client's watch must not keep streaming" ); // A new client watching the same task gets a full screen at once, even // though the screen bytes haven't changed since the last send. - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - s.tick(); + watch(&mut s, id); assert!( - s.drain().iter().any(|e| matches!(e, Event::Screen(_))), + screen_sent(&mut s), "re-watch after clear_watch must resend the full screen" ); } @@ -760,27 +710,7 @@ fn clear_watch_stops_screen_stream_and_resets_dedup() { /// `clear_watch` restores the watched task's live viewport. #[test] fn clear_watch_snaps_the_watched_task_live() { - // Use a short grid to build scrollback quickly. - let mut s = sup(6, 80); - spawn(&mut s, "seq 1 200; sleep 30", here()); - let id = first_id(&mut s); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - - // Retry until output has produced retained history. - let scrolled = wait_until(Duration::from_secs(5), || { - s.tick(); - let _ = s.drain(); - s.apply(Command::Scrollback { - id, - action: ScrollAction::Up(3), - }); - s.tasks[0].scroll_offset() > 0 - }); - assert!(scrolled, "the task never accrued scrollback"); - + let (mut s, _) = scrolled_task(); s.clear_watch(); assert_eq!( s.tasks[0].scroll_offset(), @@ -794,7 +724,6 @@ fn clear_watch_snaps_the_watched_task_live() { /// gains one line per run). #[test] fn rerun_replaces_finished_task_in_place() { - use crate::protocol::Lifecycle; let dir = scratch("rerun"); let marker = dir.join("marker"); let mut s = sup(24, 80); @@ -811,31 +740,16 @@ fn rerun_replaces_finished_task_in_place() { wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); let runs = std::fs::read_to_string(&marker).unwrap().lines().count(); assert_eq!(runs, 2, "rerun must re-execute the command"); - - s.tick(); - let tagged = s - .drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.iter().any(|t| t.id == id && t.tagged))); - assert!(tagged, "rerun must carry the tag over"); + assert!(view_of(&mut s, id).tagged, "rerun must carry the tag over"); } -/// Group normalization strips controls, trims whitespace, caps by character, -/// reserves `Unassigned`, and preserves case. +/// Check whitespace trimming, exact-case `Unassigned` reservation, and case +/// preservation for group labels. The label tests cover the remaining rules. #[test] fn group_names_normalize_at_the_boundary() { let n = |s: &str| normalize_group(Some(s.to_string())); assert_eq!(normalize_group(None), None); - // Controls are removed while printable text remains. - assert_eq!(n("\x1b[31mapi\x07"), Some("[31mapi".into())); assert_eq!(n(" backend "), Some("backend".into())); - // Control-only names become unassigned. - assert_eq!(n(" \t \x1b \x7f \u{9b} "), None); - assert_eq!(n(""), None); - // The cap counts Unicode scalar values, not UTF-8 bytes. - assert_eq!(n(&"\u{e9}".repeat(80)), Some("\u{e9}".repeat(64))); - // The cap applies after the trim, so padding spends none of it. - assert_eq!(n(&format!(" {} ", "x".repeat(64))), Some("x".repeat(64))); // The reserved section label maps to unassigned. assert_eq!(n("Unassigned"), None); assert_eq!(n(" Unassigned "), None); @@ -958,26 +872,7 @@ fn tag_with_an_unknown_id_leaves_the_live_task_alone() { /// Scrolling an unknown id does not change a live task's viewport. #[test] fn scrollback_with_an_unknown_id_leaves_the_live_task_alone() { - // Short grid: history accrues within a few rows of output. - let mut s = sup(6, 80); - spawn(&mut s, "seq 1 200; sleep 30", here()); - let id = first_id(&mut s); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - - // Retry until output has produced retained history. - let scrolled = wait_until(Duration::from_secs(5), || { - s.tick(); - let _ = s.drain(); - s.apply(Command::Scrollback { - id, - action: ScrollAction::Up(3), - }); - s.tasks[0].scroll_offset() > 0 - }); - assert!(scrolled, "the task never accrued scrollback"); + let (mut s, _) = scrolled_task(); let offset = s.tasks[0].scroll_offset(); s.apply(Command::Scrollback { @@ -1000,17 +895,12 @@ fn scrollback_with_an_unknown_id_leaves_the_live_task_alone() { fn spawn_carries_a_normalized_group_from_birth() { let mut s = sup(24, 80); spawn_grouped(&mut s, "sleep 30", here(), " ui\x1b[2J "); - s.tick(); - match s.drain().iter().find(|e| matches!(e, Event::Tasks(_))) { - Some(Event::Tasks(v)) => assert_eq!(v[0].group.as_deref(), Some("ui[2J")), - _ => panic!("expected a Tasks snapshot"), - } + assert_eq!(snapshot(&mut s)[0].group.as_deref(), Some("ui[2J")); } /// Rerun preserves the task's group and tag. #[test] fn rerun_carries_the_group_over() { - use crate::protocol::Lifecycle; let mut s = sup(24, 80); spawn_grouped(&mut s, "true", here(), "infra"); let id = first_id(&mut s); @@ -1018,18 +908,16 @@ fn rerun_carries_the_group_over() { s.apply(Command::Restart { id }); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); - s.tick(); - let carried = s.drain().iter().any(|e| { - matches!(e, Event::Tasks(v) - if v.iter().any(|t| t.id == id && t.group.as_deref() == Some("infra"))) - }); - assert!(carried, "rerun must carry the group over"); + assert_eq!( + view_of(&mut s, id).group.as_deref(), + Some("infra"), + "rerun must carry the group over" + ); } /// Rerun preserves the task's name. #[test] fn rerun_carries_the_name_over() { - use crate::protocol::Lifecycle; let mut s = sup(24, 80); spawn(&mut s, "true", here()); let id = first_id(&mut s); @@ -1041,19 +929,17 @@ fn rerun_carries_the_name_over() { s.apply(Command::Restart { id }); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); - s.tick(); - let carried = s.drain().iter().any(|e| { - matches!(e, Event::Tasks(v) - if v.iter().any(|t| t.id == id && t.name.as_deref() == Some("smoke"))) - }); - assert!(carried, "rerun must carry the name over"); + assert_eq!( + view_of(&mut s, id).name.as_deref(), + Some("smoke"), + "rerun must carry the name over" + ); } /// `Restart` never kills: a running task is refused with a status notice /// and keeps running. An unknown id gets a notice too, not a panic. #[test] fn rerun_refuses_running_task_and_unknown_id() { - use crate::protocol::Lifecycle; let mut s = sup(24, 80); spawn(&mut s, "sleep 30", here()); let id = first_id(&mut s); @@ -1065,13 +951,13 @@ fn rerun_refuses_running_task_and_unknown_id() { .any(|e| matches!(e, Event::Status(m) if m.contains("still running"))), "a running task must be refused" ); - s.tick(); - let alive = s.drain().iter().any(|e| { - matches!(e, Event::Tasks(v) if v.iter().any( - |t| t.id == id && matches!(t.lifecycle, Lifecycle::Active | Lifecycle::Idle) - )) - }); - assert!(alive, "the refused task must keep running"); + assert!( + matches!( + view_of(&mut s, id).lifecycle, + Lifecycle::Active | Lifecycle::Idle + ), + "the refused task must keep running" + ); s.apply(Command::Restart { id: 999 }); assert!( @@ -1087,26 +973,20 @@ fn rerun_refuses_running_task_and_unknown_id() { /// fresh screen would be skipped as "unchanged". #[test] fn rerun_watched_task_resends_screen() { - use crate::protocol::Lifecycle; let mut s = sup(24, 80); spawn(&mut s, "true", here()); let id = first_id(&mut s); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); - s.apply(Command::Watch { - id: Some(id), - attached: true, - }); - s.tick(); + watch(&mut s, id); assert!( - s.drain().iter().any(|e| matches!(e, Event::Screen(_))), + screen_sent(&mut s), "first watched tick sends a full screen" ); s.apply(Command::Restart { id }); - s.tick(); assert!( - s.drain().iter().any(|e| matches!(e, Event::Screen(_))), + screen_sent(&mut s), "rerun of the watched task must resend the screen" ); } @@ -1208,15 +1088,11 @@ fn worst_case_screen_frame_fits_max_frame() { let (formatted, cursor, hide) = ansi::formatted(&term); let lines: Vec = ansi::contents(&term).lines().map(str::to_string).collect(); let (kind, payload) = encode_event(&Event::Screen(ScreenView { - id: 1, lines, formatted, cursor, hide_cursor: hide, - wants_mouse: false, - alt_screen: false, - alt_scroll: false, - scrollback: 0, + ..screen(1) })); assert_eq!(kind, KIND_SCREEN); @@ -1251,26 +1127,34 @@ fn reap_until( }) } -/// Use `/bin/sh` so background-process tests have consistent semantics. -fn hello_with_sh(s: &mut Supervisor, cwd: PathBuf) { - s.set_launch_context(LaunchContext { env: sh_env(), cwd }); +/// Poll `reap` until the supervisor records task `id` as finished, or fail. +fn wait_exited(s: &mut Supervisor, id: u64) { + assert!( + reap_until(s, Duration::from_secs(5), |s| { + s.tasks.iter().all(|t| t.id != id || t.finished.is_some()) + }), + "task {id} never exited" + ); } -/// `Remove` must sweep group members the exited leader left behind (a -/// non-interactive shell's `&` child never leaves the group): TERM at -/// removal, delivered through the graveyard. This is the leak the old -/// `finished.is_none()` gate guaranteed. -#[test] -fn remove_sweeps_stragglers_of_an_exited_leader() { - use nix::sys::signal::kill; - let dir = scratch("remove_sweep"); +/// Use `/bin/sh` in `dir` so the straggler fixtures use consistent +/// background-job and trap semantics. +fn sh_sup(dir: &Path) -> Supervisor { + sup_ctx(LaunchContext { + env: sh_env(), + cwd: dir.to_path_buf(), + }) +} + +/// Run `script` as a background job and wait for the task leader to exit. +/// Return the task ID and the job PID read from `$!`. Start `script` with a +/// `trap` that ignores the signal the child must inherit to outlive its leader. +fn exited_leader_with_straggler(s: &mut Supervisor, dir: &Path, script: &str) -> (u64, Pid) { let (spid, ready) = (dir.join("spid"), dir.join("ready")); - let mut s = sup(24, 80); - hello_with_sh(&mut s, dir.to_path_buf()); let id = spawn_ready( - &mut s, + s, format!( - "trap '' HUP; sleep 300 & echo $! > {sp}; echo r > {r}", + "{script} & echo $! > {sp}; echo r > {r}", sp = spid.display(), r = ready.display() ), @@ -1278,10 +1162,19 @@ fn remove_sweeps_stragglers_of_an_exited_leader() { &ready, ); let straggler = read_pid(&spid); + wait_exited(s, id); + (id, straggler) +} + +/// Verify that `Remove` sends TERM through the graveyard to every group member +/// the exited leader left behind. The `&` child stays in the shell's process group; +/// the old `finished.is_none()` guard leaked it. +#[test] +fn remove_sweeps_stragglers_of_an_exited_leader() { + let dir = scratch("remove_sweep"); + let mut s = sh_sup(&dir); // The leader exits on its own; the straggler stays. - assert!(reap_until(&mut s, Duration::from_secs(5), |s| { - s.tasks.iter().all(|t| t.id != id || t.finished.is_some()) - })); + let (id, straggler) = exited_leader_with_straggler(&mut s, &dir, "trap '' HUP; sleep 300"); assert!(kill(straggler, None).is_ok(), "straggler should be alive"); s.apply(Command::Remove { id }); @@ -1302,25 +1195,9 @@ fn remove_sweeps_stragglers_of_an_exited_leader() { /// (same id) is already up. #[test] fn rerun_sweeps_stragglers_of_the_old_run() { - use nix::sys::signal::kill; let dir = scratch("rerun_sweep"); - let (spid, ready) = (dir.join("spid"), dir.join("ready")); - let mut s = sup(24, 80); - hello_with_sh(&mut s, dir.to_path_buf()); - let id = spawn_ready( - &mut s, - format!( - "trap '' HUP; sleep 300 & echo $! > {sp}; echo r > {r}", - sp = spid.display(), - r = ready.display() - ), - dir.to_path_buf(), - &ready, - ); - let old_straggler = read_pid(&spid); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| { - s.tasks.iter().all(|t| t.id != id || t.finished.is_some()) - })); + let mut s = sh_sup(&dir); + let (id, old_straggler) = exited_leader_with_straggler(&mut s, &dir, "trap '' HUP; sleep 300"); assert!(kill(old_straggler, None).is_ok()); // The rerun overwrites the pid file with the *new* run's straggler. @@ -1343,29 +1220,14 @@ fn rerun_sweeps_stragglers_of_the_old_run() { /// exact case a `finished.is_none()` gate silently no-ops. #[test] fn kill_escalation_reaches_term_ignoring_straggler_after_leader_exit() { - use nix::sys::signal::kill; let dir = scratch("kill_escalate_straggler"); - let (spid, ready) = (dir.join("spid"), dir.join("ready")); - let mut s = sup(24, 80); + let mut s = sh_sup(&dir); s.set_kill_grace(Duration::from_millis(150)); - hello_with_sh(&mut s, dir.to_path_buf()); // The leader ignores HUP (inherited by the `&` child, so it survives // the leader's exit); the subshell ignores TERM, then execs sleep, // which inherits both. Only the KILL can end it. - let id = spawn_ready( - &mut s, - format!( - "trap '' HUP; (trap '' TERM; exec sleep 300) & echo $! > {sp}; echo r > {r}", - sp = spid.display(), - r = ready.display() - ), - dir.to_path_buf(), - &ready, - ); - let straggler = read_pid(&spid); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| { - s.tasks.iter().all(|t| t.id != id || t.finished.is_some()) - })); + let (id, straggler) = + exited_leader_with_straggler(&mut s, &dir, "trap '' HUP; (trap '' TERM; exec sleep 300)"); s.apply(Command::Kill { id }); // TERM: ignored by the straggler assert!( @@ -1378,27 +1240,11 @@ fn kill_escalation_reaches_term_ignoring_straggler_after_leader_exit() { /// Shutdown after removal preserves the removed task's TERM grace. #[test] fn shutdown_waits_for_graveyard_grace() { - use nix::sys::signal::{Signal, kill}; let dir = scratch("shutdown_graveyard"); - let (spid, ready) = (dir.join("spid"), dir.join("ready")); - let mut s = sup(24, 80); + let mut s = sh_sup(&dir); s.set_kill_grace(Duration::from_millis(400)); - hello_with_sh(&mut s, dir.to_path_buf()); // The background process ignores HUP and TERM. - let id = spawn_ready( - &mut s, - format!( - "trap '' HUP TERM; sleep 300 & echo $! > {sp}; echo r > {r}", - sp = spid.display(), - r = ready.display() - ), - dir.to_path_buf(), - &ready, - ); - let straggler = read_pid(&spid); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| { - s.tasks.iter().all(|t| t.id != id || t.finished.is_some()) - })); + let (id, straggler) = exited_leader_with_straggler(&mut s, &dir, "trap '' HUP TERM; sleep 300"); s.apply(Command::Remove { id }); // graveyard: TERM sent, grace running // Check that the background process remains alive during the grace. @@ -1424,26 +1270,10 @@ fn shutdown_waits_for_graveyard_grace() { /// process-group ID before escalation. #[test] fn shutdown_holds_the_grace_for_members_of_an_exited_leader() { - use nix::sys::signal::{Signal, kill}; let dir = scratch("shutdown_leaderless"); - let (spid, ready) = (dir.join("spid"), dir.join("ready")); - let mut s = sup(24, 80); + let mut s = sh_sup(&dir); s.set_kill_grace(Duration::from_millis(400)); - hello_with_sh(&mut s, dir.to_path_buf()); - let id = spawn_ready( - &mut s, - format!( - "trap '' HUP TERM; sleep 300 & echo $! > {sp}; echo r > {r}", - sp = spid.display(), - r = ready.display() - ), - dir.to_path_buf(), - &ready, - ); - let straggler = read_pid(&spid); - assert!(reap_until(&mut s, Duration::from_secs(5), |s| { - s.tasks.iter().all(|t| t.id != id || t.finished.is_some()) - })); + let (_, straggler) = exited_leader_with_straggler(&mut s, &dir, "trap '' HUP TERM; sleep 300"); assert!(kill(straggler, None).is_ok(), "straggler should be alive"); let alive_mid_grace = std::thread::spawn(move || { @@ -1567,15 +1397,7 @@ fn load_session_restores_saved_groups_and_names() { fresh.apply(Command::LoadSession { name: "fleet".into(), }); - fresh.tick(); - let evs = fresh.drain(); - let tasks = evs - .iter() - .find_map(|e| match e { - Event::Tasks(v) => Some(v), - _ => None, - }) - .expect("a Tasks snapshot after load"); + let tasks = snapshot(&mut fresh); let by_cmd = |cmd: &str| { let t = tasks .iter() @@ -1613,16 +1435,12 @@ fn load_session_renormalizes_hand_edited_groups() { s.apply(Command::LoadSession { name: "edited".into(), }); - s.tick(); - let evs = s.drain(); - let restored = evs.iter().any(|e| { - matches!(e, Event::Tasks(v) - if v.iter().any(|t| t.group.as_deref() == Some("x") - && t.name.as_deref() == Some("y"))) - }); + let tasks = snapshot(&mut s); assert!( - restored, - "loaded group and name must come back normalized; got {evs:?}" + tasks + .iter() + .any(|t| t.group.as_deref() == Some("x") && t.name.as_deref() == Some("y")), + "loaded group and name must come back normalized; got {tasks:?}" ); } @@ -1743,11 +1561,8 @@ fn load_reports_admit_failures_not_clean_success() { if m.contains("2 failed to spawn") && !m.contains("task(s)"))), "failed spawns must be reported, never folded into success; got {evs:?}" ); - s.tick(); assert!( - s.drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.is_empty())), + snapshot(&mut s).is_empty(), "no task may exist when every spawn failed" ); } @@ -1776,18 +1591,6 @@ fn spawn_acks_with_spawned_before_the_snapshot() { assert_eq!(*id, v[0].id, "the ack must name the admitted task"); } -/// A command-length refusal emits no `Spawned` event. -#[test] -fn refused_spawn_emits_no_spawned() { - let mut s = sup(24, 80); - spawn(&mut s, "x".repeat(MAX_COMMAND_LEN + 1), here()); - let evs = s.drain(); - assert!( - !evs.iter().any(|e| matches!(e, Event::Spawned { .. })), - "a refused spawn must not ack; got {evs:?}" - ); -} - /// Session loads admit tasks without emitting `Spawned` events. #[test] fn session_load_emits_no_spawned() { @@ -1816,22 +1619,24 @@ fn session_load_emits_no_spawned() { ); } -/// Direct spawns reject commands above `MAX_COMMAND_LEN` without creating a task. +/// Verify that direct spawns refuse commands over `MAX_COMMAND_LEN` with a +/// notice, no `Spawned` acknowledgment, and no task. #[test] fn spawn_refuses_over_length_command() { let mut s = sup(24, 80); spawn(&mut s, "x".repeat(MAX_COMMAND_LEN + 1), here()); + let evs = s.drain(); assert!( - s.drain() - .iter() + evs.iter() .any(|e| matches!(e, Event::Status(m) if m.contains("command too long"))), "an over-length spawn must be refused with a notice" ); - s.tick(); assert!( - s.drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.is_empty())), + !evs.iter().any(|e| matches!(e, Event::Spawned { .. })), + "a refused spawn must not ack; got {evs:?}" + ); + assert!( + snapshot(&mut s).is_empty(), "no task may exist after a refused spawn" ); } @@ -1901,11 +1706,8 @@ fn launch_without_context_is_refused() { .any(|e| matches!(e, Event::Status(m) if m.contains("no launch context"))), "context-less spawn must be refused with a status notice" ); - s.tick(); assert!( - s.drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.is_empty())), + snapshot(&mut s).is_empty(), "no task may exist after a refused spawn" ); @@ -1923,8 +1725,7 @@ fn launch_without_context_is_refused() { fn key_command_encodes_against_live_cursor_mode() { let dir = scratch("key_live_mode"); let (ready, out) = (dir.join("ready"), dir.join("out")); - let mut s = sup(24, 80); - hello_with_sh(&mut s, dir.to_path_buf()); + let mut s = sh_sup(&dir); // Raw mode lets `cat` receive ESC-prefixed keys without a newline. The // child enables DECCKM before alternate-screen mode, so observing the @@ -2142,18 +1943,13 @@ fn launch_context_install_discovers_agents_in_registry_order() { fn spawn_agent_admits_a_managed_task() { let dir = scratch("spawn_agent"); let bin = dir.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let rec = dir.join("argv"); - write_executable( - &bin.join("claude"), - &format!("printf '%s\\n' \"$@\" > '{}'", rec.display()), - ); + install_fake_notifier(&bin.join("claude"), &dir.join("argv")); // Keep capture assets and the registry read inside the scratch tree. let mut s = sup_ctx(LaunchContext { env: vec![ ("PATH".into(), bin.as_os_str().to_os_string()), ( - path::FLEETCOM_RUNTIME_DIR.into(), + FLEETCOM_RUNTIME_DIR.into(), dir.join("run").into_os_string(), ), ( @@ -2175,15 +1971,6 @@ fn spawn_agent_admits_a_managed_task() { assert!(v.managed, "a SpawnAgent task must report managed"); assert_eq!(v.command, "claude", "the row shows the program word"); assert_eq!(v.group.as_deref(), Some("agents")); - assert!( - wait_until(Duration::from_secs(5), || rec.exists()), - "the stub never ran" - ); - let argv = std::fs::read_to_string(&rec).unwrap(); - assert!( - argv.lines().any(|l| l == "--session-id"), - "the launch must carry the harness argv: {argv:?}" - ); spawn(&mut s, "claude", dir.to_path_buf()); let literal = spawned_id(&mut s); @@ -2195,14 +1982,11 @@ fn spawn_agent_admits_a_managed_task() { /// Tick once and return the flagship ids from the snapshot. fn flagship_ids(s: &mut Supervisor) -> Vec { - s.tick(); - s.drain() + snapshot(s) .iter() - .find_map(|e| match e { - Event::Tasks(v) => Some(v.iter().filter(|t| t.flagship).map(|t| t.id).collect()), - _ => None, - }) - .expect("expected a Tasks snapshot") + .filter(|t| t.flagship) + .map(|t| t.id) + .collect() } /// Take the id from the `Spawned` acknowledgement without calling `tick` or `reap`. @@ -2275,7 +2059,7 @@ fn flagship_on_finished_task_never_surfaces() { let mut s = sup(24, 80); spawn(&mut s, "true", here()); let id = spawned_id(&mut s); - wait_for_lifecycle(&mut s, id, |l| l == crate::protocol::Lifecycle::Ok); + wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); s.apply(Command::Flagship { id: Some(id) }); assert!(!view_of(&mut s, id).flagship); @@ -2288,7 +2072,6 @@ fn flagship_on_finished_task_never_surfaces() { /// for the marker file. #[test] fn rerun_clears_flagship() { - use crate::protocol::Lifecycle; let dir = scratch("rerun_flagship"); let marker = dir.join("ran"); let mut s = sup(24, 80); @@ -2578,12 +2361,9 @@ fn recovery_write_failure_notices_once_and_keeps_supervising() { std::thread::sleep(Duration::from_millis(10)); } assert_eq!(notices, 1, "persistent failure must notice exactly once"); - - s.tick(); - assert!( - s.drain() - .iter() - .any(|e| matches!(e, Event::Tasks(v) if v.len() == 1)), + assert_eq!( + snapshot(&mut s).len(), + 1, "a failing writer must never disturb supervision" ); } diff --git a/src/task.rs b/src/task.rs index 80ec2f4..527fe4b 100644 --- a/src/task.rs +++ b/src/task.rs @@ -47,21 +47,18 @@ pub struct WriteRefused { pub len: usize, } -/// Map a dependency error (`portable-pty` returns `anyhow`) into `io::Error` so -/// the crate uses `io::Result` without a direct `anyhow` dependency. -fn io_err(e: impl std::fmt::Display) -> io::Error { - io::Error::other(e.to_string()) -} - /// Return true only when signal 0 reports `ESRCH`. `EPERM` remains potentially /// live so callers do not delete another owner's files. -pub(crate) fn pid_is_dead(pid: i32) -> bool { +pub fn pid_is_dead(pid: i32) -> bool { matches!(kill(Pid::from_raw(pid), None), Err(Errno::ESRCH)) } -/// Parse an untrimmed, strictly positive decimal PID. Rejecting zero and -/// negatives avoids `kill` process-group semantics. -pub(crate) fn positive_pid(field: &str) -> Option { +/// Parse a positive decimal PID containing only ASCII digits. Reject signs, +/// whitespace, and zero; `kill(0, ...)` targets a process group. +pub fn positive_pid(field: &str) -> Option { + if !field.bytes().all(|b| b.is_ascii_digit()) { + return None; + } field.parse::().ok().filter(|p| *p > 0) } @@ -71,7 +68,8 @@ pub struct Task { /// Working directory the command was launched in: the grouping key for /// "by dir" mode and the label shown when it differs from the default. pub cwd: PathBuf, - /// Kept for resize (`TIOCSWINSZ`); `try_clone_reader`/`take_writer` borrow it. + /// Keep the PTY master open for `resize` (`TIOCSWINSZ`). Split the reader and + /// writer handles from it at spawn. master: Box, /// Sender for the detached PTY writer worker. `None` after `force_kill`. Queue /// writes to avoid blocking the core thread on PTY I/O. @@ -85,9 +83,10 @@ pub struct Task { pending_write: Arc, /// Session-leader PID, also used as the process-group ID. pid: Option, - /// Shared with the reader thread: it writes (process bytes), the UI reads - /// (render/preview). Fair locking prevents repeated parser writes from - /// starving the supervisor's snapshot reads. + /// Share the emulator between the PTY reader, which writes process bytes, + /// and the UI, which renders previews. Use fair locking so repeated parser + /// writes do not starve snapshot reads. `FairMutex` does not poison, so + /// later accesses can inspect the state a panicking operation left behind. parser: Arc>, last_activity: Arc>, handle: Option>, @@ -148,12 +147,6 @@ fn signal(waker: &Waker) { } } -/// Lock the shared emulator grid. `FairMutex` does not poison, so a later -/// access can read the state left by a panicking operation. -fn grid(parser: &FairMutex) -> impl std::ops::DerefMut + '_ { - parser.lock() -} - /// Admit one whole message to a writer queue bounded by `MAX_PENDING_WRITE`, /// or refuse it whole. The cap check and the `fetch_add` are separate /// operations, so racing admitters can overshoot the cap by one message (see @@ -246,7 +239,7 @@ impl Task { pixel_width: 0, pixel_height: 0, }) - .map_err(io_err)?; + .map_err(io::Error::other)?; let mut cmd = match exec { Exec::Literal => { @@ -283,13 +276,13 @@ impl Task { cmd.env("PWD", cwd.as_os_str()); cmd.cwd(cwd); - let child = pair.slave.spawn_command(cmd).map_err(io_err)?; + let child = pair.slave.spawn_command(cmd).map_err(io::Error::other)?; // Drop our slave handle: once the child's own fds close, the master // read hits EOF and the reader thread can exit. drop(pair.slave); - let mut reader = pair.master.try_clone_reader().map_err(io_err)?; - let writer = pair.master.take_writer().map_err(io_err)?; + let mut reader = pair.master.try_clone_reader().map_err(io::Error::other)?; + let writer = pair.master.take_writer().map_err(io::Error::other)?; let parser = Arc::new(FairMutex::new(Emulator::new(rows, cols, scrollback))); let last_activity = Arc::new(Mutex::new(Instant::now())); @@ -317,7 +310,7 @@ impl Task { break; } Ok(n) => { - let replies = grid(&parser).process(&buf[..n]); + let replies = parser.lock().process(&buf[..n]); if !replies.is_empty() { // Probe replies answer the child through the // same writer worker as client input, keeping @@ -384,6 +377,30 @@ impl Task { self.pid } + /// Check the capture file, then the live registry, then the spawn-time ID. A + /// A managed CLI can select a session after launch, so validate captured IDs + /// through the harness with the task leader's PID. If a source has no valid ID, + /// check the next source. For literal tasks, return `None`. + pub fn current_resume_id(&self) -> Option { + if let (Some(h), Some(path)) = (self.harness, &self.capture_file) + && let Ok(payload) = std::fs::read_to_string(path) + && let Some(id) = h.parse_capture(&payload, self.pid()) + { + return Some(id); + } + if let (Some(h), Some(pid)) = (self.harness, self.pid()) + && let Some(id) = h.live_session_id( + pid, + &self.cwd, + self.spawned_at, + self.harness_home.as_deref(), + ) + { + return Some(id); + } + self.resume_id.clone() + } + /// Latch the exit code and finish time if the leader has exited, without /// reaping it. `WNOWAIT` leaves the zombie in place, which is what keeps /// the pid (and therefore the pgid) reserved so the group stays signalable @@ -415,7 +432,7 @@ impl Task { } /// Report whether the reader reached EOF without driving the reap loop. - pub(crate) fn reader_done(&self) -> bool { + pub fn reader_done(&self) -> bool { self.handle.as_ref().is_none_or(|h| h.is_finished()) } @@ -458,6 +475,9 @@ impl Task { self.reaped } + /// Return the dashboard lifecycle: `Ok` or `Failed` after `poll_exit` latches + /// the leader's exit, `Idle` after `idle_after` without PTY output, or `Active` + /// otherwise. Compute it for each snapshot; do not store it. pub fn lifecycle(&self, now: Instant, idle_after: Duration) -> Lifecycle { if self.finished.is_some() { return if self.exit_code == Some(0) { @@ -489,7 +509,7 @@ impl Task { /// ones. Called from the supervisor's tick (the loop's only periodic /// path) because vte re-checks its sync timeout only when bytes arrive. pub fn flush_expired_sync(&self) { - let replies = grid(&self.parser).flush_expired_sync(); + let replies = self.parser.lock().flush_expired_sync(); if !replies.is_empty() && let Some(tx) = &self.input_tx { @@ -499,7 +519,7 @@ impl Task { /// Drain OSC 52 clipboard stores captured by this task's emulator. pub fn drain_clipboard(&self) -> ClipboardStores { - grid(&self.parser).drain_clipboard() + self.parser.lock().drain_clipboard() } /// The dashboard preview, resolved through the provenance cascade under @@ -507,7 +527,7 @@ impl Task { /// instant so every task in one snapshot resolves against the same clock. pub fn resolve_preview(&mut self, now: Instant) -> Preview { self.refresh_blocked(now); - let emu = grid(&self.parser); + let emu = self.parser.lock(); let blocked = self.blocked.as_ref().map(|(text, rule)| (&**text, *rule)); self.preview .resolve(now, &*emu, self.summary_adapter, blocked) @@ -543,7 +563,7 @@ impl Task { if self.preview.finalized() || !self.output_complete() { return; } - let mut emu = grid(&self.parser); + let mut emu = self.parser.lock(); let _ = emu.finish_output(); self.preview.finalize(&*emu, self.summary_adapter); } @@ -551,7 +571,7 @@ impl Task { /// Full screen as ANSI bytes for attached mode, plus cursor state so we can /// place the real cursor where the child put it. pub fn formatted(&self) -> (Vec, (u16, u16), bool) { - grid(&self.parser).formatted() + self.parser.lock().formatted() } /// Return one plain-text string per visible grid row for peek and drag @@ -559,14 +579,15 @@ impl Task { pub fn screen_lines(&self) -> Vec { // `contents()` separates grid rows with `\n`; `split` preserves the // trailing empty field that represents a blank final row. - grid(&self.parser) + self.parser + .lock() .contents() .split('\n') .map(str::to_string) .collect() } - pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> { + pub fn resize(&self, rows: u16, cols: u16) -> io::Result<()> { self.master .resize(PtySize { rows, @@ -574,15 +595,15 @@ impl Task { pixel_width: 0, pixel_height: 0, }) - .map_err(io_err)?; - grid(&self.parser).resize(rows, cols); + .map_err(io::Error::other)?; + self.parser.lock().resize(rows, cols); Ok(()) } /// Return the viewport to live, then queue `bytes` for the PTY as one /// message without blocking the caller. Refuse it whole if admission would /// exceed `MAX_PENDING_WRITE`. - pub fn send_input(&mut self, bytes: Vec) -> Result<(), WriteRefused> { + pub fn send_input(&self, bytes: Vec) -> Result<(), WriteRefused> { self.scroll_view(ScrollAction::Live); self.queue_write(bytes) } @@ -597,27 +618,27 @@ impl Task { } /// Move the scrollback viewport, clamped to retained history. - pub fn scroll_view(&mut self, action: ScrollAction) { - grid(&self.parser).scroll(action); + pub fn scroll_view(&self, action: ScrollAction) { + self.parser.lock().scroll(action); } /// Rows the viewport is scrolled back from live output. pub fn scroll_offset(&self) -> usize { - grid(&self.parser).scrollback() + self.parser.lock().scrollback() } /// Encode a paste using the child's bracketed-paste mode, read under the /// grid lock, then queue it as one PTY write. - pub fn send_paste(&mut self, content: &[u8]) -> Result<(), WriteRefused> { - let bracketed = grid(&self.parser).bracketed_paste(); + pub fn send_paste(&self, content: &[u8]) -> Result<(), WriteRefused> { + let bracketed = self.parser.lock().bracketed_paste(); self.send_input(input::paste_bytes(bracketed, content)) } /// Encode and queue one mouse action using the child's screen modes. /// Unsupported actions send nothing. - pub fn send_mouse(&mut self, kind: MouseKind, col: u16, row: u16) -> Result<(), WriteRefused> { + pub fn send_mouse(&self, kind: MouseKind, col: u16, row: u16) -> Result<(), WriteRefused> { let bytes = { - let p = grid(&self.parser); + let p = self.parser.lock(); input::mouse_bytes(&p, kind, col, row) }; bytes.map_or(Ok(()), |b| self.send_input(b)) @@ -625,9 +646,9 @@ impl Task { /// Encode and queue one key using the child's cursor-key mode, read under /// the grid lock. Unsupported combinations send nothing. - pub fn send_key(&mut self, code: Key, mods: Mods) -> Result<(), WriteRefused> { + pub fn send_key(&self, code: Key, mods: Mods) -> Result<(), WriteRefused> { let bytes = { - let p = grid(&self.parser); + let p = self.parser.lock(); input::key_bytes(p.application_cursor(), code, mods) }; bytes.map_or(Ok(()), |b| self.send_input(b)) @@ -636,7 +657,7 @@ impl Task { /// Return the child's mouse, alternate-screen, and alternate-scroll modes /// for `ScreenView`. pub fn input_hints(&self) -> (bool, bool, bool) { - let p = grid(&self.parser); + let p = self.parser.lock(); ( p.mouse_protocol_mode() != crate::emulator::MouseProtocolMode::None, p.alternate_screen(), diff --git a/src/task_tests.rs b/src/task_tests.rs index 1320ccf..1554c95 100644 --- a/src/task_tests.rs +++ b/src/task_tests.rs @@ -1,28 +1,36 @@ use super::*; -use crate::testutil::{ - env_here, here, install_resident_shell, read_pid, sh_env, temp, wait_until, write_executable, +use crate::{ + preview::ScreenFacts, + protocol::PreviewSource, + testutil::{ + env_here, here, install_resident_shell, read_pid, sh_env, temp, wait_until, with_shell, + write_executable, + }, }; -/// Tests drive the reader directly, so there is no core loop to wake. +/// Run reader tests without a core-loop waker. fn no_waker() -> Waker { Arc::new(Mutex::new(None)) } +/// Spawn a task with a 24x80 grid, 2000 rows of scrollback, and this process's +/// working directory. +fn spawn_with(id: u64, command: &str, exec: Exec, env: &[(OsString, OsString)]) -> Task { + Task::spawn(id, command, exec, &here(), 24, 80, 2000, env, no_waker()).unwrap() +} + +/// Spawn a literal task with this process's environment. fn spawn(id: u64, command: &str) -> Task { - Task::spawn( - id, - command, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &env_here(), - no_waker(), - ) - .unwrap() + spawn_with(id, command, Exec::Literal, &env_here()) +} + +/// Spawn a literal task with `SHELL=/bin/sh` so background jobs and `trap`s +/// use consistent shell behavior in process-group tests. +fn spawn_sh(id: u64, command: &str) -> Task { + spawn_with(id, command, Exec::Literal, &sh_env()) } +/// Poll until the child has exited; the reader may still be running. fn wait_finished(t: &mut Task) { assert!( wait_until(Duration::from_secs(5), || { @@ -33,6 +41,17 @@ fn wait_finished(t: &mut Task) { ); } +/// Wait for child exit and reader EOF before checking the complete grid output. +fn wait_complete(t: &mut Task) { + assert!( + wait_until(Duration::from_secs(60), || { + t.poll_exit().unwrap(); + t.output_complete() + }), + "child never completed" + ); +} + /// End-to-end plumbing: spawn under a PTY, the reader thread feeds the /// emulator, the screen reflects the output, and the exit code is latched. #[test] @@ -122,7 +141,6 @@ fn resize_is_reflected_in_the_grid() { /// signals); `Drop` collects it and only then does the pid free up. #[test] fn exited_leader_stays_a_zombie_until_drop() { - use nix::sys::signal::kill; let mut t = spawn(4, "exit 7"); wait_finished(&mut t); assert_eq!(t.exit_code, Some(7)); @@ -153,15 +171,13 @@ fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { &agent, &format!("printf '%s' \"$$\" > '{}'", pid_file.display()), ); - let mut env = env_here(); - env.retain(|(k, _)| k != "SHELL"); - env.push(("SHELL".into(), shell.into_os_string())); + let env = with_shell(env_here(), shell); let managed = Exec::Managed { binary: agent.clone(), args: Vec::new(), }; - let mut t = Task::spawn(1, "agent", managed, &here(), 24, 80, 2000, &env, no_waker()).unwrap(); + let mut t = spawn_with(1, "agent", managed, &env); wait_finished(&mut t); assert_eq!( read_pid(&pid_file).as_raw() as u32, @@ -171,18 +187,7 @@ fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { std::fs::remove_file(&pid_file).unwrap(); let typed = format!("'{}'", agent.display()); - let mut t = Task::spawn( - 2, - &typed, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &env, - no_waker(), - ) - .unwrap(); + let mut t = spawn_with(2, &typed, Exec::Literal, &env); wait_finished(&mut t); assert_ne!( read_pid(&pid_file).as_raw() as u32, @@ -194,25 +199,13 @@ fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { /// `terminate` reaches live group members after the leader exits. #[test] fn terminate_reaches_stragglers_after_leader_exit() { - use nix::sys::signal::kill; let dir = temp("task_straggler"); let spid = dir.join("spid"); // `trap '' HUP` first: the ignore is inherited by the `&` child, which // must survive its session leader's exit (leader death HUPs the // foreground group) to *be* a straggler. let cmd = format!("trap '' HUP; sleep 300 & echo $! > {}", spid.display()); - let mut t = Task::spawn( - 5, - &cmd, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &sh_env(), - no_waker(), - ) - .unwrap(); + let mut t = spawn_sh(5, &cmd); wait_finished(&mut t); // leader exits as soon as the background job is up let straggler = read_pid(&spid); assert!(kill(straggler, None).is_ok(), "straggler should be alive"); @@ -263,7 +256,7 @@ fn viewport_scrolls_and_snaps_live_on_input() { let mut t = spawn(9, "cat"); // Feed enough rows to create scrollback. for i in 0..50 { - grid(&t.parser).process(format!("line{i}\r\n").as_bytes()); + t.parser.lock().process(format!("line{i}\r\n").as_bytes()); } assert_eq!(t.scroll_offset(), 0); t.scroll_view(ScrollAction::Up(10)); @@ -292,7 +285,7 @@ fn viewport_scrolls_and_snaps_live_on_input() { #[test] fn screen_lines_yields_one_entry_per_grid_row() { let mut t = spawn(60, "sleep 300"); - grid(&t.parser).process(b"top"); + t.parser.lock().process(b"top"); let lines = t.screen_lines(); // The spawn helper's grid is 24x80. assert_eq!(lines.len(), 24, "one entry per grid row"); @@ -309,7 +302,7 @@ fn queued_writes_reach_the_child_in_order() { t.send_input(b"zqsecondqz\n".to_vec()).unwrap(); let mut contents = String::new(); wait_until(Duration::from_secs(5), || { - contents = grid(&t.parser).contents(); + contents = t.parser.lock().contents(); contents.contains("zqsecondqz") }); let first = contents @@ -380,11 +373,11 @@ fn write_error_keeps_draining_the_pending_counter() { fn input_hints_track_child_modes() { let mut t = spawn(8, "sleep 5"); assert_eq!(t.input_hints(), (false, false, false)); - grid(&t.parser).process(b"\x1b[?1000h"); + t.parser.lock().process(b"\x1b[?1000h"); assert_eq!(t.input_hints(), (true, false, false)); - grid(&t.parser).process(b"\x1b[?1000l\x1b[?1049h"); + t.parser.lock().process(b"\x1b[?1000l\x1b[?1049h"); assert_eq!(t.input_hints(), (false, true, true)); - grid(&t.parser).process(b"\x1b[?1007l"); + t.parser.lock().process(b"\x1b[?1007l"); assert_eq!(t.input_hints(), (false, true, false)); t.terminate(); } @@ -393,26 +386,8 @@ fn input_hints_track_child_modes() { /// finalization waits for reader EOF even after the child exits. #[test] fn finalize_preview_waits_for_reader_eof() { - let cmd = "printf 'test result: ok\\n'"; - let mut t = Task::spawn( - 20, - cmd, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &sh_env(), - no_waker(), - ) - .unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never exited" - ); + let mut t = spawn_sh(20, "printf 'test result: ok\\n'"); + wait_complete(&mut t); // Reopen the reader gate: a thread outliving the child's exit stands in // for a reader still working through bytes the grid has not seen. @@ -425,7 +400,7 @@ fn finalize_preview_waits_for_reader_eof() { !t.resolve_preview(Instant::now()).frozen, "finalization must wait for reader EOF" ); - grid(&t.parser).process(b"late output\r\n"); + t.parser.lock().process(b"late output\r\n"); // Dropping the sender ends the stand-in: the reader reached EOF. drop(release); @@ -442,32 +417,14 @@ fn finalize_preview_waits_for_reader_eof() { /// no ESU can arrive, so finalization must land the frame before resolving. #[test] fn finalize_preview_lands_an_open_sync_frame() { - let cmd = "printf '\\033[?2026htest result: ok\\n'"; - let mut t = Task::spawn( - 21, - cmd, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &sh_env(), - no_waker(), - ) - .unwrap(); + let mut t = spawn_sh(21, "printf '\\033[?2026htest result: ok\\n'"); + wait_complete(&mut t); assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.finished.is_some() && t.reader_done() - }), - "child never exited" - ); - assert!( - !grid(&t.parser).contents().contains("test result: ok"), + !t.parser.lock().contents().contains("test result: ok"), "premise: the unclosed frame still buffers the final output" ); t.finalize_preview(); - assert!(grid(&t.parser).contents().contains("test result: ok")); + assert!(t.parser.lock().contents().contains("test result: ok")); let p = t.resolve_preview(Instant::now()); assert_eq!((p.text.as_str(), p.frozen), ("test result: ok", true)); } @@ -477,36 +434,18 @@ fn finalize_preview_lands_an_open_sync_frame() { /// floor. #[test] fn finalize_preview_freezes_the_final_primary_line() { - use crate::protocol::PreviewSource; let dir = temp("task_final_primary"); let flag = dir.join("flag"); let cmd = format!( "until [ -e '{}' ]; do sleep 0.05; done; printf 'test result: ok\\n'", flag.display() ); - let mut t = Task::spawn( - 40, - &cmd, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &sh_env(), - no_waker(), - ) - .unwrap(); + let mut t = spawn_sh(40, &cmd); // The last live resolution predates every byte of output. let early = t.resolve_preview(Instant::now()); assert!(!early.frozen); std::fs::write(&flag, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never completed" - ); + wait_complete(&mut t); t.finalize_preview(); let p = t.resolve_preview(Instant::now()); assert_eq!( @@ -519,7 +458,6 @@ fn finalize_preview_freezes_the_final_primary_line() { /// alternate-screen title when the restored primary floor is unchanged. #[test] fn finalize_preview_keeps_the_last_render_across_alt_teardown() { - use crate::protocol::PreviewSource; let dir = temp("task_final_alt"); let teardown = dir.join("teardown"); let exit = dir.join("exit"); @@ -531,18 +469,7 @@ fn finalize_preview_keeps_the_last_render_across_alt_teardown() { td = teardown.display(), ex = exit.display() ); - let mut t = Task::spawn( - 41, - &cmd, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &sh_env(), - no_waker(), - ) - .unwrap(); + let mut t = spawn_sh(41, &cmd); assert!( wait_until(Duration::from_secs(5), || { t.resolve_preview(Instant::now()).source == PreviewSource::Title @@ -552,7 +479,7 @@ fn finalize_preview_keeps_the_last_render_across_alt_teardown() { std::fs::write(&teardown, b"").unwrap(); assert!( wait_until(Duration::from_secs(60), || { - !grid(&t.parser).alternate_screen() + !t.parser.lock().alternate_screen() }), "teardown never reached the grid" ); @@ -564,16 +491,10 @@ fn finalize_preview_keeps_the_last_render_across_alt_teardown() { "premise: the demotion hold keeps the title rendered" ); std::fs::write(&exit, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never completed" - ); + wait_complete(&mut t); t.finalize_preview(); assert_eq!( - grid(&t.parser).live_floor(), + t.parser.lock().live_floor(), "prelaunch junk", "premise: 1049l restored the pre-launch primary screen" ); @@ -589,7 +510,6 @@ fn finalize_preview_keeps_the_last_render_across_alt_teardown() { /// hold. #[test] fn finalize_preview_freezes_primary_output_after_alt_teardown() { - use crate::protocol::PreviewSource; let dir = temp("task_final_alt_output"); let flag = dir.join("flag"); let cmd = format!( @@ -599,18 +519,7 @@ fn finalize_preview_freezes_primary_output_after_alt_teardown() { printf '\\033[?1049ldone\\n'", flag.display() ); - let mut t = Task::spawn( - 43, - &cmd, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &sh_env(), - no_waker(), - ) - .unwrap(); + let mut t = spawn_sh(43, &cmd); assert!( wait_until(Duration::from_secs(5), || { t.resolve_preview(Instant::now()).source == PreviewSource::Title @@ -618,13 +527,7 @@ fn finalize_preview_freezes_primary_output_after_alt_teardown() { "title never rendered" ); std::fs::write(&flag, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never completed" - ); + wait_complete(&mut t); t.finalize_preview(); let p = t.resolve_preview(Instant::now()); assert_eq!( @@ -639,7 +542,6 @@ fn finalize_preview_freezes_primary_output_after_alt_teardown() { /// child command is `printf`. #[test] fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { - use crate::protocol::PreviewSource; let dir = temp("task_anchor_e2e"); let flag = dir.join("flag"); let cmd = format!( @@ -648,18 +550,7 @@ fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { printf '\\033[H\\033[2J• Ran echo ok\\n\\n› \\n synth-model high · 2 in · 3 out'", f = flag.display() ); - let mut t = Task::spawn( - 42, - &cmd, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &sh_env(), - no_waker(), - ) - .unwrap(); + let mut t = spawn_sh(42, &cmd); assert!(t.summary_adapter.is_none(), "printf selects nothing"); t.summary_adapter = crate::harness::select("codex"); assert!(t.summary_adapter.is_some()); @@ -678,13 +569,7 @@ fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { ); std::fs::write(&flag, b"").unwrap(); - assert!( - wait_until(Duration::from_secs(60), || { - t.poll_exit().unwrap(); - t.output_complete() - }), - "child never completed" - ); + wait_complete(&mut t); t.finalize_preview(); let p = t.resolve_preview(Instant::now()); assert_eq!( @@ -715,18 +600,7 @@ fn probe_replies_reach_the_child_through_the_allowlist() { head -c 11 > {}", out.display() ); - let mut t = Task::spawn( - 11, - &cmd, - Exec::Literal, - &here(), - 24, - 80, - 2000, - &sh_env(), - no_waker(), - ) - .unwrap(); + let mut t = spawn_sh(11, &cmd); let mut got = Vec::new(); wait_until(Duration::from_secs(5), || { got = std::fs::read(&out).unwrap_or_default(); diff --git a/src/terminal/ansi.rs b/src/terminal/ansi.rs index 896d220..23f226d 100644 --- a/src/terminal/ansi.rs +++ b/src/terminal/ansi.rs @@ -219,7 +219,7 @@ pub fn formatted(term: &Term) -> (Vec, (u16, u16), bool) { /// marks are included with their base character; `'\t'` cells, concealed (SGR 8) cells, /// and orphaned wide halves read as the blank the replayed screen shows; trailing /// spaces are trimmed per row. This display policy differs from -/// [`crate::emulator::Emulator::live_rows`], which preserves the stored glyphs for +/// [`crate::preview::ScreenFacts::live_rows`], which preserves the stored glyphs for /// structural matching. pub fn contents(term: &Term) -> String { let grid = term.grid(); diff --git a/src/terminal/codex_tests.rs b/src/terminal/codex_tests.rs deleted file mode 100644 index 9006e37..0000000 --- a/src/terminal/codex_tests.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Replay release-binary output; the model responses were scripted locally. - -use crate::{emulator::Emulator, protocol::ClipboardKind}; - -const REPLY: &str = "PHASE0 COMPLETE. Deterministic local response."; -const PROMPT: &str = "Phase zero fixture: reply with the completion marker."; -const MANIFEST: &str = include_str!("../../tests/corpus/codex_0158_terminal/manifest.json"); -const STREAMS: [&[u8]; 4] = [ - include_bytes!("../../tests/corpus/codex_0158_terminal/0.157.1-fullscreen-transitions.bin"), - include_bytes!("../../tests/corpus/codex_0158_terminal/0.157.1-fullscreen-fleetcom-load.bin"), - include_bytes!("../../tests/corpus/codex_0158_terminal/0.158.0-fullscreen-transitions.bin"), - include_bytes!("../../tests/corpus/codex_0158_terminal/0.158.0-fullscreen-fleetcom-load.bin"), -]; - -#[test] -fn codex_release_terminal_transitions() { - let manifest = jzon::parse(MANIFEST).unwrap(); - assert_eq!(manifest["fixtures"].len(), STREAMS.len()); - for (fixture, bytes) in manifest["fixtures"].members().zip(STREAMS) { - let name = fixture["name"].as_str().unwrap(); - let outer = fixture["surface"] == "outer fleetcom client"; - assert_eq!(fixture["bytes"].as_usize().unwrap(), bytes.len(), "{name}"); - // Small chunks also split UTF-8 and escape sequences across parser calls. - for chunk_size in [usize::MAX, 7] { - let mut emu = Emulator::new(40, 120, 10_000); - assert!(!emu.alternate_screen()); - let mut at = 0; - let mut resizes = fixture["resizes"].members().peekable(); - for mark in fixture["marks"].members() { - let checkpoint = mark["name"].as_str().unwrap(); - let end = mark["offset"].as_usize().unwrap(); - // A resize at a checkpoint's end belongs to the following state. - while let Some(resize) = resizes.peek() { - let offset = resize["offset"].as_usize().unwrap(); - if offset >= end { - break; - } - for chunk in bytes[at..offset].chunks(chunk_size) { - emu.process(chunk); - } - emu.resize( - resize["resize"][0].as_u16().unwrap(), - resize["resize"][1].as_u16().unwrap(), - ); - at = offset; - resizes.next(); - } - for chunk in bytes[at..end].chunks(chunk_size) { - emu.process(chunk); - } - at = end; - let context = format!("{name}/{checkpoint}, chunk size {chunk_size}"); - assert_eq!( - emu.size(), - ( - mark["rows"].as_u16().unwrap(), - mark["cols"].as_u16().unwrap() - ), - "{context}" - ); - assert_eq!(emu.alternate_screen(), checkpoint != "exit", "{context}"); - let text = emu.live_rows().join("\n"); - match checkpoint { - "attached" | "completed" | "copied" | "narrow" | "restored" => { - assert!(text.contains(PROMPT), "{context}: missing prompt: {text}"); - assert!(text.contains(REPLY), "{context}: missing reply: {text}"); - if outer { - assert!(text.contains("[attached] codex resume '"), "{context}"); - let replies = if checkpoint == "attached" { 1 } else { 2 }; - assert_eq!(text.matches(REPLY).count(), replies, "{context}"); - } - } - "new-thread" | "exit" => { - assert!(!text.contains(PROMPT), "{context}: stale prompt"); - assert!(!text.contains(REPLY), "{context}: stale reply"); - } - "startup" | "copy-menu" => {} - other => panic!("{context}: unknown checkpoint {other}"), - } - if checkpoint == "copied" { - assert!( - text.contains("Copied Whole response to clipboard"), - "{context}" - ); - if name.starts_with("0.158.0") { - assert!(text.contains("Copying Whole response"), "{context}"); - } - } - let clipboard = emu.drain_clipboard(); - assert_eq!(clipboard.oversized_len, None, "{context}"); - let expected = if checkpoint == "copied" { - vec![(ClipboardKind::Clipboard, REPLY.to_owned())] - } else { - vec![] - }; - assert_eq!(clipboard.stores, expected, "{context}"); - assert!( - emu.drain_clipboard().stores.is_empty(), - "{context}: drained twice" - ); - } - assert_eq!(at, bytes.len(), "{name}: untested trailing output"); - } - } -} diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index b36431e..7a52420 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -14,6 +14,7 @@ use alacritty_terminal::{ term::{ Config, TermMode, cell::{Cell, Flags}, + test::TermSize, }, vte::ansi::{self as vt, Handler, Processor}, }; @@ -21,6 +22,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use crate::{ format::prefix_bytes, + preview::ScreenFacts, protocol::{ClipboardKind, ScrollAction}, }; @@ -42,7 +44,7 @@ pub enum MouseProtocolEncoding { } /// Maximum decoded size of one buffered OSC 52 clipboard payload. -pub(crate) const CLIPBOARD_STORE_MAX_BYTES: usize = 1024 * 1024; +pub const CLIPBOARD_STORE_MAX_BYTES: usize = 1024 * 1024; // A maximum-size store leaves the daemon base64-encoded in one `Event::ClipboardCopy` frame. const _: () = assert!( @@ -61,7 +63,7 @@ pub struct ClipboardStores { /// Buffers backend-generated PTY responses while the parser advances. Other /// events are discarded; [`ObservedTerm`] captures titles and clipboard stores. -pub struct ProbeSink { +struct ProbeSink { responses: Arc>>, } @@ -77,26 +79,6 @@ impl EventListener for ProbeSink { } } -/// Terminal dimensions supplied to `Term::new` and `Term::resize`. -struct GridSize { - lines: usize, - columns: usize, -} - -impl Dimensions for GridSize { - fn total_lines(&self) -> usize { - self.lines - } - - fn screen_lines(&self) -> usize { - self.lines - } - - fn columns(&self) -> usize { - self.columns - } -} - /// Default-deny allowlist for backend-generated probe responses. `fleetcom` /// forwards only CPR (`ESC[;R`), DSR-5 (`ESC[0n`), and primary DA /// (`ESC[?c`) responses. @@ -199,9 +181,8 @@ pub struct Emulator { responses: Arc>>, /// OSC 52 stores captured since the last drain. clipboard: ClipboardStores, - /// Alt-screen and title facts, advanced at parser-event granularity by - /// [`ObservedTerm`] during the parse itself. - alt: AltScreen, + /// Have [`ObservedTerm`] update title state for each parser event. + alt_title: AltTitleState, /// Bumped once per grid advance; cheap change detection for consumers /// that poll the grid. revision: u64, @@ -213,7 +194,7 @@ impl Emulator { /// Drain buffered `PtyWrite` responses through the allowlist, preserving /// generation order. Runs after `advance`/`stop_sync` returns, outside /// the listener callback. - fn drain_allowed(&mut self) -> Vec { + fn drain_allowed(&self) -> Vec { let mut buf = self .responses .lock() @@ -277,10 +258,7 @@ impl Emulator { }; let term = Term::new( config, - &GridSize { - lines: rows as usize, - columns: cols as usize, - }, + &TermSize::new(cols as usize, rows as usize), ProbeSink { responses: Arc::clone(&responses), }, @@ -290,7 +268,7 @@ impl Emulator { parser: Processor::new(), responses, clipboard: ClipboardStores::default(), - alt: AltScreen::default(), + alt_title: AltTitleState::default(), revision: 0, bytes_since_sweep: 0, } @@ -302,7 +280,7 @@ impl Emulator { pub fn process(&mut self, bytes: &[u8]) -> Vec { let mut observed = ObservedTerm { term: &mut self.term, - alt: &mut self.alt, + alt_title: &mut self.alt_title, clipboard: &mut self.clipboard, }; self.parser.advance(&mut observed, bytes); @@ -319,7 +297,7 @@ impl Emulator { fn land_sync_frame(&mut self) -> Vec { let mut observed = ObservedTerm { term: &mut self.term, - alt: &mut self.alt, + alt_title: &mut self.alt_title, clipboard: &mut self.clipboard, }; self.parser.stop_sync(&mut observed); @@ -443,16 +421,14 @@ impl Emulator { // Re-snapshot an unchanged restored floor after reflow. A floor that // already differs records primary output after the alt-screen exit. let untouched = self - .alt + .alt_title .leave_floor .as_deref() .is_some_and(|snapshot| live_floor_of(&self.term) == snapshot); - self.term.resize(GridSize { - lines: rows as usize, - columns: cols as usize, - }); + self.term + .resize(TermSize::new(cols as usize, rows as usize)); if untouched { - self.alt.leave_floor = Some(live_floor_of(&self.term)); + self.alt_title.leave_floor = Some(live_floor_of(&self.term)); } // Reflow changes grid contents without parser input, so cached screen // facts must be invalidated. @@ -467,75 +443,69 @@ impl Emulator { self.term.grid().columns() as u16, ) } -} - -/// Capture accessors for the dashboard-preview resolution layer -/// (`crate::preview`). -impl Emulator { - /// Monotonic count of grid advances: incremented on every `process` call, - /// each sync-frame landing, and `resize`. If unchanged between polls, - /// skip re-reading the grid. - pub fn revision(&self) -> u64 { - self.revision - } /// Count of alt-screen entries observed so far. Production consults the /// epoch only inside this module (title stamping and `title`'s currency /// filter); the accessor exists for tests. #[cfg(test)] pub fn alt_epoch(&self) -> u64 { - self.alt.epoch + self.alt_title.epoch + } +} + +impl ScreenFacts for Emulator { + /// Read the revision counter. `process`, sync-frame landings, and `resize` + /// each increment it. + fn revision(&self) -> u64 { + self.revision } - /// The live floor captured at the most recent alt-screen exit, or `None` - /// before the first exit. - pub fn alt_leave_floor(&self) -> Option<&str> { - self.alt.leave_floor.as_deref() + fn alternate_screen(&self) -> bool { + Self::alternate_screen(self) } - /// The window title. On the alternate screen: the captured title, honored only - /// while its alt-screen epoch is current; a title from a previous alt session reads - /// as `None`. On the primary screen: a live staged announce (a title still staged - /// because no printable output has been parsed) surfaces first, then a - /// still-current captured title. - pub fn title(&self) -> Option<&str> { + /// On the alternate screen, return a captured title only while its epoch is + /// current. Return `None` for titles from earlier alt sessions. On the + /// primary screen, return a staged title until the parser handles printable + /// output; otherwise return the current captured title. + fn title(&self) -> Option<&str> { // Surface a staged primary-screen title until printable output is parsed or the // alternate screen is entered. if !self.alternate_screen() - && let Some(staged) = self.alt.staged_title.as_deref() + && let Some(staged) = self.alt_title.staged_title.as_deref() { return Some(staged); } - self.alt + self.alt_title .title .as_ref() - .filter(|t| t.alt_epoch == self.alt.epoch) + .filter(|t| t.alt_epoch == self.alt_title.epoch) .map(|t| t.text.as_str()) } - /// Last sanitized primary-screen title. Printable output and - /// alternate-screen transitions do not clear it; an empty title or RIS - /// does. - pub fn primary_title(&self) -> Option<&str> { - self.alt.primary_title.as_deref() + fn primary_title(&self) -> Option<&str> { + self.alt_title.primary_title.as_deref() } /// The last non-blank row of the live screen, trailing padding trimmed; /// empty when the screen is blank. Ignores the scrollback view offset: /// `contents` follows `display_offset`, which would make a scrolled-back /// task preview historical rows instead of live output. - pub fn live_floor(&self) -> String { + fn live_floor(&self) -> String { live_floor_of(&self.term) } - /// Every live-viewport row, top to bottom, trailing padding trimmed: the - /// summary adapters' structural scan input. Ignores the scrollback view - /// offset for the same reason as [`Emulator::live_floor`]. - pub fn live_rows(&self) -> Vec { + /// Return each live-viewport row from top to bottom with trailing padding + /// removed. Ignore the scrollback offset so summary adapters scan live output. + fn live_rows(&self) -> Vec { (0..self.term.grid().screen_lines() as i32) .map(|row| live_row_text_of(&self.term, row)) .collect() } + + fn alt_leave_floor(&self) -> Option<&str> { + self.alt_title.leave_floor.as_deref() + } } /// The last non-blank live row, with trailing padding removed. This free @@ -578,11 +548,11 @@ fn live_row_text_of(term: &Term, row: i32) -> String { text } -/// Alternate-screen and title state updated at parser-event boundaries. Each nonempty -/// sanitized primary-screen title is retained and staged. On alternate-screen entry, -/// consume the staged copy unless already discarded on printable output. +/// Update title state at parser-event boundaries. Retain and stage each nonempty +/// sanitized primary-screen title. On alternate-screen entry, promote the staged +/// title unless the parser has already cleared it after printable output. #[derive(Default)] -struct AltScreen { +struct AltTitleState { /// Count of alt-screen entries. Compared against /// `CapturedTitle::alt_epoch` to expire titles at app boundaries. epoch: u64, @@ -608,7 +578,7 @@ struct AltScreen { title_stack: Vec>, } -/// Capacity of the backend title stack mirrored by [`AltScreen::title_stack`]. +/// Match the backend title-stack capacity in [`AltTitleState::title_stack`]. const TITLE_STACK_SHADOW_MAX: usize = 4096; /// Delegating [`Handler`] that forwards every parser event to the wrapped @@ -630,7 +600,7 @@ const TITLE_STACK_SHADOW_MAX: usize = 4096; /// before buffered updates are applied. struct ObservedTerm<'a> { term: &'a mut Term, - alt: &'a mut AltScreen, + alt_title: &'a mut AltTitleState, clipboard: &'a mut ClipboardStores, } @@ -642,18 +612,18 @@ impl ObservedTerm<'_> { fn observe_alt(&mut self, was: bool) { let alt = self.term.mode().contains(TermMode::ALT_SCREEN); if alt && !was { - self.alt.epoch += 1; + self.alt_title.epoch += 1; // Promote a staged primary-screen announce into the new epoch: consume the // staged copy so it cannot be reused for another entry. - if let Some(text) = self.alt.staged_title.take() { - self.alt.title = Some(CapturedTitle { + if let Some(text) = self.alt_title.staged_title.take() { + self.alt_title.title = Some(CapturedTitle { text, - alt_epoch: self.alt.epoch, + alt_epoch: self.alt_title.epoch, }); } } if !alt && was { - self.alt.leave_floor = Some(live_floor_of(self.term)); + self.alt_title.leave_floor = Some(live_floor_of(self.term)); } } @@ -662,24 +632,24 @@ impl ObservedTerm<'_> { /// titles on an empty title. Discard a staged title on printable output, but retain /// it across control traffic. fn observe_title(&mut self, title: Option) { - self.alt.raw_title.clone_from(&title); + self.alt_title.raw_title.clone_from(&title); let text = title .map(|raw| sanitize_title(&raw)) .filter(|text| !text.is_empty()); let Some(text) = text else { - self.alt.title = None; - self.alt.staged_title = None; - self.alt.primary_title = None; + self.alt_title.title = None; + self.alt_title.staged_title = None; + self.alt_title.primary_title = None; return; }; if self.term.mode().contains(TermMode::ALT_SCREEN) { - self.alt.title = Some(CapturedTitle { + self.alt_title.title = Some(CapturedTitle { text, - alt_epoch: self.alt.epoch, + alt_epoch: self.alt_title.epoch, }); } else { - self.alt.staged_title = Some(text.clone()); - self.alt.primary_title = Some(text); + self.alt_title.staged_title = Some(text.clone()); + self.alt_title.primary_title = Some(text); } } } @@ -713,7 +683,7 @@ impl Handler for ObservedTerm<'_> { fn input(&mut self, a0: char) { self.term.input(a0); // Discard a staged primary-screen title on printable output. - self.alt.staged_title = None; + self.alt_title.staged_title = None; } delegate! { goto(a0: i32, a1: usize); @@ -757,10 +727,10 @@ impl Handler for ObservedTerm<'_> { self.observe_alt(was); // RIS clears the backend title and title stack without separate // handler events. The captured title remains epoch-gated. - self.alt.raw_title = None; - self.alt.title_stack.clear(); - self.alt.staged_title = None; - self.alt.primary_title = None; + self.alt_title.raw_title = None; + self.alt_title.title_stack.clear(); + self.alt_title.staged_title = None; + self.alt_title.primary_title = None; } delegate! { reverse_index(); @@ -817,16 +787,18 @@ impl Handler for ObservedTerm<'_> { fn push_title(&mut self) { self.term.push_title(); // Mirror the backend's bounded push of its current raw title. - if self.alt.title_stack.len() >= TITLE_STACK_SHADOW_MAX { - self.alt.title_stack.remove(0); + if self.alt_title.title_stack.len() >= TITLE_STACK_SHADOW_MAX { + self.alt_title.title_stack.remove(0); } - self.alt.title_stack.push(self.alt.raw_title.clone()); + self.alt_title + .title_stack + .push(self.alt_title.raw_title.clone()); } fn pop_title(&mut self) { self.term.pop_title(); // Replay the pop against the shadow: the restored value is a title // event in every sense (a popped `None` is a reset). - if let Some(popped) = self.alt.title_stack.pop() { + if let Some(popped) = self.alt_title.title_stack.pop() { self.observe_title(popped); } } @@ -1734,24 +1706,6 @@ mod tests { assert_eq!(emu.live_floor(), "coalesced"); } - /// Advance the epoch and expire the preceding alternate-screen title on leave and - /// re-entry within one read. - #[test] - fn same_read_alt_bounce_advances_the_epoch_and_expires_the_title() { - let mut emu = Emulator::new(4, 20, 0); - emu.process(b"\x1b[?1049h\x1b]0;first app\x07ui"); - assert_eq!(emu.alt_epoch(), 1); - assert_eq!(emu.title(), Some("first app"), "premise: title honored"); - - emu.process(b"\x1b[?1049l\x1b[?1049h"); - assert_eq!(emu.alt_epoch(), 2, "the bounce is two transitions"); - assert_eq!( - emu.title(), - None, - "the old app's title must not survive the swap" - ); - } - /// `live_floor` reads the live grid's last non-blank row even while the /// viewport is scrolled back; `contents` follows the offset instead. #[test] diff --git a/src/terminal/golden.rs b/src/terminal/golden.rs index 7ae9b5f..bfb7de0 100644 --- a/src/terminal/golden.rs +++ b/src/terminal/golden.rs @@ -1,6 +1,6 @@ -//! Plain text can remain correct after terminal parser changes even with incorrect -//! cursor state, styling, or scrollback. Replay the recorded PTY corpus -//! (`tests/corpus`) and report the exact row, cell, or count on a mismatch. +//! Replay the recorded PTY corpus (`tests/corpus`) to check text, cursor state, +//! styling, and scrollback together. Report the exact row, cell, or count on a +//! mismatch. //! //! **Displayed state.** Verify every final plain-text row via [`ansi::contents`], //! cursor position and visibility via [`ansi::formatted`], and selected styled @@ -8,6 +8,14 @@ //! //! **Parser semantics.** Verify exact scrollback retention, bold-plus-dim intensity //! stacking, DEC charset translation, and VS16 width with targeted fixtures. +//! +//! **Wrapper oracle.** Replay every fixture through [`Emulator`] and a raw `Term`. +//! Require identical styled bytes, text, cursor, and alternate-screen state to +//! check that `ObservedTerm` forwards each `Handler` event to the backend. +//! +//! **Codex release replay.** Walk the `codex_0158_terminal` captures checkpoint +//! by checkpoint, apply resizes at their recorded offsets, and assert +//! alternate-screen transitions, grid geometry, and OSC 52 stores. use alacritty_terminal::{ Term, @@ -20,6 +28,9 @@ use alacritty_terminal::{ use crate::{ ansi, + emulator::Emulator, + preview::ScreenFacts, + protocol::ClipboardKind, testutil::{CORPUS_COLS as COLS, CORPUS_LINES as LINES, parse_term}, }; @@ -27,8 +38,8 @@ fn alacritty(bytes: &[u8]) -> Term { parse_term(bytes, LINES, COLS) } -/// Pin every visible text row, cursor position and visibility, and the active -/// primary screen. Any row omitted from `rows` must be blank. +/// Compare every visible text row, the cursor position and visibility, and the +/// active primary screen. Require every row omitted from `rows` to be blank. fn assert_screen( fixture: &str, al: &Term, @@ -54,26 +65,22 @@ fn assert_screen( assert!(!hidden, "{fixture}: cursor visibility"); } -/// Pin one load-bearing styled cell: character, colors, exact flag set. -fn assert_cell( - fixture: &str, - al: &Term, - (row, col): (usize, usize), - c: char, - fg: Color, - bg: Color, - flags: Flags, -) { - let cell = &al.grid()[Line(row as i32)][Column(col)]; - assert_eq!(cell.c, c, "{fixture}: char at ({row},{col})"); - assert_eq!(cell.fg, fg, "{fixture}: fg at ({row},{col})"); - assert_eq!(cell.bg, bg, "{fixture}: bg at ({row},{col})"); - assert_eq!(cell.flags, flags, "{fixture}: flags at ({row},{col})"); +/// Record each pinned cell as `((row, col), char, fg, bg, exact flag set)`. +type StyledCell = ((usize, usize), char, Color, Color, Flags); + +/// Compare each cell's character, foreground, background, and flags with its fixture values. +fn assert_cells(fixture: &str, al: &Term, cells: &[StyledCell]) { + for &((row, col), c, fg, bg, flags) in cells { + let cell = &al.grid()[Line(row as i32)][Column(col)]; + assert_eq!(cell.c, c, "{fixture}: char at ({row},{col})"); + assert_eq!(cell.fg, fg, "{fixture}: fg at ({row},{col})"); + assert_eq!(cell.bg, bg, "{fixture}: bg at ({row},{col})"); + assert_eq!(cell.flags, flags, "{fixture}: flags at ({row},{col})"); + } } -/// Pin default characters, colors, and flags across the primary grid after an -/// alternate-screen fixture exits. This catches styling on blank cells, which -/// the plain-text assertion cannot observe. +/// Compare every primary-screen cell after the alternate-screen fixture exits. +/// Check blank cells too; compare their colors and flags because text output omits them. fn assert_grid_unstyled(fixture: &str, al: &Term) { for row in 0..LINES { for col in 0..COLS { @@ -105,14 +112,16 @@ fn compat_tmux_split() { &[(0, "[detached (from session 0)]")], (1, 0), ); - assert_cell( + assert_cells( "tmux_split.bin", &al, - (0, 0), - '[', - Color::Named(NamedColor::Foreground), - Color::Named(NamedColor::Background), - Flags::empty(), + &[( + (0, 0), + '[', + Color::Named(NamedColor::Foreground), + Color::Named(NamedColor::Background), + Flags::empty(), + )], ); } @@ -295,86 +304,34 @@ fn compat_shell_colors() { let fg = Color::Named(NamedColor::Foreground); let bg = Color::Named(NamedColor::Background); - // ls colors: executable, setuid (fg plus bg), symlink. - assert_cell( - F, - &al, - (0, 52), - 'a', - Color::Named(NamedColor::Red), - bg, - Flags::empty(), - ); - assert_cell( - F, - &al, - (6, 52), - 'a', - Color::Named(NamedColor::Black), - Color::Named(NamedColor::Red), - Flags::empty(), - ); - assert_cell( - F, - &al, - (13, 52), - 'a', - Color::Named(NamedColor::Magenta), - bg, - Flags::empty(), - ); - // git log hash. - assert_cell( + let red = Color::Named(NamedColor::Red); + let black = Color::Named(NamedColor::Black); + let magenta = Color::Named(NamedColor::Magenta); + let yellow = Color::Named(NamedColor::Yellow); + let green = Color::Named(NamedColor::Green); + let truecolor = Color::Spec(Rgb { + r: 100, + g: 200, + b: 50, + }); + assert_cells( F, &al, - (17, 0), - 'f', - Color::Named(NamedColor::Yellow), - bg, - Flags::empty(), - ); - // Scripted attribute row: bold, underline, reverse. - assert_cell( - F, - &al, - (37, 0), - 'b', - Color::Named(NamedColor::Red), - bg, - Flags::BOLD, - ); - assert_cell( - F, - &al, - (37, 9), - 'u', - Color::Named(NamedColor::Green), - bg, - Flags::UNDERLINE, - ); - assert_cell(F, &al, (37, 25), 'r', fg, bg, Flags::INVERSE); - // Color depth: 256-color index and truecolor RGB. - assert_cell( - F, - &al, - (38, 0), - '2', - Color::Indexed(208), - bg, - Flags::empty(), - ); - assert_cell( - F, - &al, - (38, 9), - 't', - Color::Spec(Rgb { - r: 100, - g: 200, - b: 50, - }), - bg, - Flags::empty(), + &[ + // ls colors: executable, setuid (fg plus bg), symlink. + ((0, 52), 'a', red, bg, Flags::empty()), + ((6, 52), 'a', black, red, Flags::empty()), + ((13, 52), 'a', magenta, bg, Flags::empty()), + // git log hash. + ((17, 0), 'f', yellow, bg, Flags::empty()), + // Scripted attribute row: bold, underline, reverse. + ((37, 0), 'b', red, bg, Flags::BOLD), + ((37, 9), 'u', green, bg, Flags::UNDERLINE), + ((37, 25), 'r', fg, bg, Flags::INVERSE), + // Color depth: 256-color index and truecolor RGB. + ((38, 0), '2', Color::Indexed(208), bg, Flags::empty()), + ((38, 9), 't', truecolor, bg, Flags::empty()), + ], ); } @@ -442,23 +399,14 @@ fn compat_build_log() { ); let bg = Color::Named(NamedColor::Background); - assert_cell( + let bright_green = Color::Named(NamedColor::BrightGreen); + assert_cells( F, &al, - (0, 3), - 'C', - Color::Named(NamedColor::BrightGreen), - bg, - Flags::BOLD, - ); - assert_cell( - F, - &al, - (34, 4), - 'F', - Color::Named(NamedColor::BrightGreen), - bg, - Flags::BOLD, + &[ + ((0, 3), 'C', bright_green, bg, Flags::BOLD), + ((34, 4), 'F', bright_green, bg, Flags::BOLD), + ], ); } @@ -570,7 +518,7 @@ fn semantic_dec_scrollregion_charset_translation() { } /// Assert identical styled cells, screen text, cursor state, and alternate-screen mode -/// for one fixture replayed through [`crate::emulator::Emulator`] and a raw `Term`. +/// for one fixture replayed through [`Emulator`] and a raw `Term`. fn assert_wrapper_matches(file: &str, bytes: &[u8]) { let al = alacritty(bytes); let mut emu = crate::testutil::corpus_emulator(); @@ -613,3 +561,107 @@ wrapper_oracle!(wrapper_grok_resume, "grok_resume.bin"); wrapper_oracle!(wrapper_wide_emoji, "wide_emoji.bin"); wrapper_oracle!(wrapper_dec_scrollregion, "dec_scrollregion.bin"); wrapper_oracle!(wrapper_topregion_scroll, "topregion_scroll.bin"); + +// codex_0158_terminal: release-binary output with locally scripted model responses. + +const REPLY: &str = "PHASE0 COMPLETE. Deterministic local response."; +const PROMPT: &str = "Phase zero fixture: reply with the completion marker."; +const MANIFEST: &str = include_str!("../../tests/corpus/codex_0158_terminal/manifest.json"); +const STREAMS: [&[u8]; 4] = [ + include_bytes!("../../tests/corpus/codex_0158_terminal/0.157.1-fullscreen-transitions.bin"), + include_bytes!("../../tests/corpus/codex_0158_terminal/0.157.1-fullscreen-fleetcom-load.bin"), + include_bytes!("../../tests/corpus/codex_0158_terminal/0.158.0-fullscreen-transitions.bin"), + include_bytes!("../../tests/corpus/codex_0158_terminal/0.158.0-fullscreen-fleetcom-load.bin"), +]; + +#[test] +fn codex_release_terminal_transitions() { + let manifest = jzon::parse(MANIFEST).unwrap(); + assert_eq!(manifest["fixtures"].len(), STREAMS.len()); + for (fixture, bytes) in manifest["fixtures"].members().zip(STREAMS) { + let name = fixture["name"].as_str().unwrap(); + let outer = fixture["surface"] == "outer fleetcom client"; + assert_eq!(fixture["bytes"].as_usize().unwrap(), bytes.len(), "{name}"); + // Feed small chunks to split UTF-8 and escape sequences across parser calls. + for chunk_size in [usize::MAX, 7] { + let mut emu = Emulator::new(40, 120, 10_000); + assert!(!emu.alternate_screen()); + let mut at = 0; + let mut resizes = fixture["resizes"].members().peekable(); + for mark in fixture["marks"].members() { + let checkpoint = mark["name"].as_str().unwrap(); + let end = mark["offset"].as_usize().unwrap(); + // Apply a resize at a checkpoint's end before checking the next state. + while let Some(resize) = resizes.peek() { + let offset = resize["offset"].as_usize().unwrap(); + if offset >= end { + break; + } + for chunk in bytes[at..offset].chunks(chunk_size) { + emu.process(chunk); + } + emu.resize( + resize["resize"][0].as_u16().unwrap(), + resize["resize"][1].as_u16().unwrap(), + ); + at = offset; + resizes.next(); + } + for chunk in bytes[at..end].chunks(chunk_size) { + emu.process(chunk); + } + at = end; + let context = format!("{name}/{checkpoint}, chunk size {chunk_size}"); + assert_eq!( + emu.size(), + ( + mark["rows"].as_u16().unwrap(), + mark["cols"].as_u16().unwrap() + ), + "{context}" + ); + assert_eq!(emu.alternate_screen(), checkpoint != "exit", "{context}"); + let text = emu.live_rows().join("\n"); + match checkpoint { + "attached" | "completed" | "copied" | "narrow" | "restored" => { + assert!(text.contains(PROMPT), "{context}: missing prompt: {text}"); + assert!(text.contains(REPLY), "{context}: missing reply: {text}"); + if outer { + assert!(text.contains("[attached] codex resume '"), "{context}"); + let replies = if checkpoint == "attached" { 1 } else { 2 }; + assert_eq!(text.matches(REPLY).count(), replies, "{context}"); + } + } + "new-thread" | "exit" => { + assert!(!text.contains(PROMPT), "{context}: stale prompt"); + assert!(!text.contains(REPLY), "{context}: stale reply"); + } + "startup" | "copy-menu" => {} + other => panic!("{context}: unknown checkpoint {other}"), + } + if checkpoint == "copied" { + assert!( + text.contains("Copied Whole response to clipboard"), + "{context}" + ); + if name.starts_with("0.158.0") { + assert!(text.contains("Copying Whole response"), "{context}"); + } + } + let clipboard = emu.drain_clipboard(); + assert_eq!(clipboard.oversized_len, None, "{context}"); + let expected = if checkpoint == "copied" { + vec![(ClipboardKind::Clipboard, REPLY.to_owned())] + } else { + vec![] + }; + assert_eq!(clipboard.stores, expected, "{context}"); + assert!( + emu.drain_clipboard().stores.is_empty(), + "{context}: drained twice" + ); + } + assert_eq!(at, bytes.len(), "{name}: untested trailing output"); + } + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index e7a6658..edafb13 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -2,12 +2,10 @@ //! serialized back to ANSI; `input` runs the reverse direction, encoding client //! events into PTY bytes. -pub(crate) mod ansi; -#[cfg(test)] -mod codex_tests; -pub(crate) mod emulator; +pub mod ansi; +pub mod emulator; // Absolute pins over the recorded PTY corpus, plus the wrapper-vs-backend // oracle. #[cfg(test)] mod golden; -pub(crate) mod input; +pub mod input; diff --git a/src/testutil.rs b/src/testutil.rs index 94a9ebf..02580ff 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -1,6 +1,8 @@ -//! Test scaffolds shared by the in-src test modules: scratch directories, -//! deadline polling, and corpus fixtures. Test-only (`#[cfg(test)]` -//! at the declaration in `main.rs`), so nothing here ships. +//! Test helpers shared by in-source modules: scratch directories, deadline +//! polling, child processes and launch environments, stub executables (resident +//! shells and fake notifiers), Codex rollout and Claude hook fixtures, neutral +//! `ScreenView` values, and row builders. Compile this module only under +//! `#[cfg(test)]` in `main.rs` so these helpers stay out of builds. use std::{ ffi::OsString, @@ -24,6 +26,7 @@ use alacritty_terminal::{ use crate::{ emulator::Emulator, + protocol::ScreenView, task::{pid_is_dead, positive_pid}, }; @@ -32,7 +35,7 @@ const SCRATCH_PREFIX: &str = "fleetcom_test2_"; /// Scratch directory removed on drop. A panic preserves it for inspection; /// later runs reclaim it after the owner exits. -pub(crate) struct Scratch(PathBuf); +pub struct Scratch(PathBuf); impl std::ops::Deref for Scratch { type Target = Path; @@ -52,7 +55,7 @@ impl Drop for Scratch { /// Create an empty `__` directory under the system temp /// directory. The PID separates processes; the sequence separates calls. -pub(crate) fn temp(tag: &str) -> Scratch { +pub fn temp(tag: &str) -> Scratch { static SEQ: AtomicU32 = AtomicU32::new(0); sweep_dead_scratch(); let seq = SEQ.fetch_add(1, Ordering::Relaxed); @@ -95,8 +98,7 @@ fn scratch_pid_of(name: &str) -> Option { fn scratch_pid(suffix: &str) -> Option { let (rest, seq) = suffix.rsplit_once('_')?; let (_, pid) = rest.rsplit_once('_')?; - let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); - if !digits(seq) || !digits(pid) { + if seq.is_empty() || !seq.bytes().all(|b| b.is_ascii_digit()) { return None; } positive_pid(pid) @@ -113,6 +115,7 @@ fn scratch_pid_reads_the_pid_field() { // A suffix without all three components is invalid. assert_eq!(scratch_pid("tag_123"), None); assert_eq!(scratch_pid("tag_0_4"), None); + assert_eq!(scratch_pid("tag_+123_4"), None); } /// Names outside the current namespace are ineligible for sweeping. @@ -127,7 +130,7 @@ fn legacy_scratch_names_are_rejected_by_the_prefix() { /// Poll `pred` until it holds or `budget` elapses; returns the final answer. /// `pred` always runs at least once. -pub(crate) fn wait_until(budget: Duration, mut pred: impl FnMut() -> bool) -> bool { +pub fn wait_until(budget: Duration, mut pred: impl FnMut() -> bool) -> bool { let deadline = Instant::now() + budget; loop { if pred() { @@ -141,7 +144,7 @@ pub(crate) fn wait_until(budget: Duration, mut pred: impl FnMut() -> bool) -> bo } /// Milliseconds since the Unix epoch. -pub(crate) fn now_ms() -> u64 { +pub fn now_ms() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() @@ -149,7 +152,7 @@ pub(crate) fn now_ms() -> u64 { } /// Read a pid a test task wrote, waiting for the write to land. -pub(crate) fn read_pid(path: &Path) -> nix::unistd::Pid { +pub fn read_pid(path: &Path) -> nix::unistd::Pid { let mut pid = None; wait_until(Duration::from_secs(5), || { pid = fs::read_to_string(path) @@ -161,7 +164,7 @@ pub(crate) fn read_pid(path: &Path) -> nix::unistd::Pid { } /// Return the PID of a child process after reaping it. -pub(crate) fn dead_pid() -> u32 { +pub fn dead_pid() -> u32 { let mut child = Command::new("sh").arg("-c").arg("exit 0").spawn().unwrap(); let pid = child.id(); child.wait().unwrap(); @@ -169,26 +172,34 @@ pub(crate) fn dead_pid() -> u32 { } /// Return this process's working directory. -pub(crate) fn here() -> PathBuf { +pub fn here() -> PathBuf { std::env::current_dir().unwrap() } /// Snapshot this process's environment for a launch context. -pub(crate) fn env_here() -> Vec<(OsString, OsString)> { +pub fn env_here() -> Vec<(OsString, OsString)> { std::env::vars_os().collect() } -/// `env_here` with `SHELL` pinned to `/bin/sh` for portable background-job -/// behavior in process-group tests. -pub(crate) fn sh_env() -> Vec<(OsString, OsString)> { - let mut env = env_here(); +/// Return `env` with inherited `SHELL` entries removed and `shell` as the sole +/// value passed to the launch. +pub fn with_shell( + mut env: Vec<(OsString, OsString)>, + shell: impl Into, +) -> Vec<(OsString, OsString)> { env.retain(|(k, _)| k != "SHELL"); - env.push(("SHELL".into(), "/bin/sh".into())); + env.push(("SHELL".into(), shell.into())); env } +/// Return this process's environment with `SHELL=/bin/sh` for predictable +/// background-job behavior in process-group tests. +pub fn sh_env() -> Vec<(OsString, OsString)> { + with_shell(env_here(), "/bin/sh") +} + /// Write an executable `#!/bin/sh` script at `path`. The parent must exist. -pub(crate) fn write_executable(path: &Path, body: &str) { +pub fn write_executable(path: &Path, body: &str) { fs::write(path, format!("#!/bin/sh\n{body}\n")).unwrap(); fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); } @@ -198,7 +209,7 @@ pub(crate) fn write_executable(path: &Path, body: &str) { /// keeps it resident: `sh` execs a script's last simple command in its own place, which /// would hand the child the leader PID. Set it as `SHELL` to verify that a managed launch /// bypasses the shell. -pub(crate) fn install_resident_shell(dir: &Path) -> PathBuf { +pub fn install_resident_shell(dir: &Path) -> PathBuf { let shell = dir.join("resident-sh"); write_executable(&shell, "[ \"$1\" = -c ] || exit 2\n/bin/sh -c \"$2\"\n:"); shell @@ -206,7 +217,7 @@ pub(crate) fn install_resident_shell(dir: &Path) -> PathBuf { /// Install a fake notifier at `path` that records its argv, one token /// per line, into `record`. -pub(crate) fn install_fake_notifier(path: &Path, record: &Path) { +pub fn install_fake_notifier(path: &Path, record: &Path) { fs::create_dir_all(path.parent().unwrap()).unwrap(); write_executable( path, @@ -216,7 +227,7 @@ pub(crate) fn install_fake_notifier(path: &Path, record: &Path) { /// First line of a Codex rollout: the `session_meta` envelope around /// `payload`, newline-terminated like every rollout line. -pub(crate) fn codex_session_meta(payload: &str) -> String { +pub fn codex_session_meta(payload: &str) -> String { format!( r#"{{"timestamp":"2026-10-04T17:49:56.012Z","ordinal":0,"type":"session_meta","payload":{payload}}}"# ) + "\n" @@ -224,11 +235,7 @@ pub(crate) fn codex_session_meta(payload: &str) -> String { /// Write `contents` as the rollout of `thread` under the Codex `home` and /// return its path. -pub(crate) fn install_codex_rollout( - home: &Path, - thread: &str, - contents: impl AsRef<[u8]>, -) -> PathBuf { +pub fn install_codex_rollout(home: &Path, thread: &str, contents: impl AsRef<[u8]>) -> PathBuf { let day = home.join("sessions/2026/10/04"); fs::create_dir_all(&day).unwrap(); let path = day.join(format!("rollout-2026-10-04T13-49-56-{thread}.jsonl")); @@ -239,7 +246,7 @@ pub(crate) fn install_codex_rollout( /// Install the rollout header of a root thread: `session_id` equals `id` and /// `source` is a string. Include only the fields used for capture validation; /// other fields are present in a real header. -pub(crate) fn install_codex_root(home: &Path, thread: &str) -> PathBuf { +pub fn install_codex_root(home: &Path, thread: &str) -> PathBuf { install_codex_rollout( home, thread, @@ -249,15 +256,46 @@ pub(crate) fn install_codex_root(home: &Path, thread: &str) -> PathBuf { ) } +/// Build the JSON object Claude's `SessionStart` hook receives for session `id` +/// started from `source`. Omit the newline present in hook stdin; callers that need +/// the complete capture payload can append it. +pub fn hook_json(id: &str, source: &str) -> String { + format!( + r#"{{"session_id":"{id}","transcript_path":"/t/x.jsonl","cwd":"/w","hook_event_name":"SessionStart","source":"{source}"}}"# + ) +} + +/// Build owned rows from string literals. +pub fn rows(spec: &[&str]) -> Vec { + spec.iter().map(|s| s.to_string()).collect() +} + +/// Build an empty screen for task `id` with the cursor shown at `(0, 0)`, +/// mouse reporting and alternate-screen modes off, and no scrollback offset. +/// Use struct update syntax to override only the fields a test exercises. +pub fn screen(id: u64) -> ScreenView { + ScreenView { + id, + lines: Vec::new(), + formatted: Vec::new(), + cursor: (0, 0), + hide_cursor: false, + wants_mouse: false, + alt_screen: false, + alt_scroll: false, + scrollback: 0, + } +} + /// Corpus geometry: the fixture recordings in `tests/corpus/` were captured /// under a 40-row, 120-column PTY (tests/corpus/README.md). -pub(crate) const CORPUS_LINES: usize = 40; -pub(crate) const CORPUS_COLS: usize = 120; +pub const CORPUS_LINES: usize = 40; +pub const CORPUS_COLS: usize = 120; /// A raw backend `Term` of `lines`×`cols` with `bytes` parsed into it: the /// reference grid for tests that compare the wrapper or the serializer /// against the backend without going through `Emulator`. -pub(crate) fn parse_term(bytes: &[u8], lines: usize, cols: usize) -> Term { +pub fn parse_term(bytes: &[u8], lines: usize, cols: usize) -> Term { let mut term = Term::new(Config::default(), &TermSize::new(cols, lines), VoidListener); let mut parser: Processor = Processor::new(); parser.advance(&mut term, bytes); @@ -266,6 +304,6 @@ pub(crate) fn parse_term(bytes: &[u8], lines: usize, cols: usize) -> Term Emulator { +pub fn corpus_emulator() -> Emulator { Emulator::new(CORPUS_LINES as u16, CORPUS_COLS as u16, 2000) } diff --git a/src/ui.rs b/src/ui.rs index eb67079..c60c86c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -31,40 +31,29 @@ pub fn render(out: &mut impl Write, app: &mut App) -> io::Result { let mut buf: Vec = Vec::with_capacity(app.cols as usize * app.rows as usize * 3 + 128); // DECSET 2026 around the whole frame queue!(buf, BeginSynchronizedUpdate)?; - match app.mode { - Mode::Attached => render_attached(&mut buf, app)?, - Mode::Peek => { - render_dashboard(&mut buf, app)?; - render_peek(&mut buf, app)?; - } - Mode::PickDir => { - render_dashboard(&mut buf, app)?; - render_pickdir(&mut buf, app)?; - } - Mode::PickGroup(_) => { - render_dashboard(&mut buf, app)?; - render_pickgroup(&mut buf, app)?; - } - Mode::Find => { - render_dashboard(&mut buf, app)?; - render_find(&mut buf, app)?; - } - Mode::Controls => { - render_dashboard(&mut buf, app)?; - render_controls(&mut buf, app)?; - } - Mode::LoadSession => { - render_dashboard(&mut buf, app)?; - render_session_picker(&mut buf, app)?; - } + // Render `Attached` and `Disconnected` without a dashboard. Start every other mode + // from the dashboard and add an overlay where needed. Keep this match exhaustive + // so the compiler requires a rendering choice for each new `Mode`. + type Layer = fn(&mut Vec, &App) -> io::Result<()>; + let (base, overlay): (Layer, Option) = match app.mode { + Mode::Attached => (render_attached, None), + Mode::Disconnected => (render_disconnected, None), + Mode::Peek => (render_dashboard, Some(render_peek)), + Mode::PickDir => (render_dashboard, Some(render_pickdir)), + Mode::PickGroup(_) => (render_dashboard, Some(render_pickgroup)), + Mode::Find => (render_dashboard, Some(render_find)), + Mode::Controls => (render_dashboard, Some(render_controls)), + Mode::LoadSession => (render_dashboard, Some(render_session_picker)), Mode::Spawn if app.spawn_page == SpawnPage::Agent => { - render_dashboard(&mut buf, app)?; - render_agent_page(&mut buf, app)?; + (render_dashboard, Some(render_agent_page)) } - Mode::Disconnected => render_disconnected(&mut buf, app)?, Mode::Dashboard | Mode::Spawn | Mode::SaveSession | Mode::Rename(_) => { - render_dashboard(&mut buf, app)? + (render_dashboard, None) } + }; + base(&mut buf, app)?; + if let Some(overlay) = overlay { + overlay(&mut buf, app)?; } queue!(buf, EndSynchronizedUpdate)?; // Repaint only on change: a stable frame (idle tasks, no input) is a no-op, @@ -216,7 +205,7 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { // Keep common actions visible and route the remaining bindings through `?`. In the // spawn prompt, use the footer for the Tab hint. let footer = match app.mode { - Mode::Spawn => spawn_page_hint(app.agents.len()), + Mode::Spawn => tab_hint(" enter run", "agents", app.agents.len()), _ => dashboard_hint(app.chord_target()), }; dim(out, rows.saturating_sub(1), &footer, cols)?; @@ -243,13 +232,13 @@ fn transient_line(notice: Option<&str>, status: Option<&str>) -> Option notice.or(status).map(|s| format!(" {s}")) } -/// Build the Command-page footer. Include the Agent-page hint only when agents are -/// installed, as with the Recovery-page hint in the session picker. -fn spawn_page_hint(agents: usize) -> String { - if agents > 0 { - format!(" enter run · tab agents ({agents}) · esc") +/// Build a footer for a page that can switch to a sibling with Tab. Include the +/// destination name and count only when the destination has entries. +fn tab_hint(base: &str, page: &str, entries: usize) -> String { + if entries > 0 { + format!("{base} · tab {page} ({entries}) · esc") } else { - " enter run · esc".to_string() + format!("{base} · esc") } } @@ -908,15 +897,6 @@ fn recovery_row(e: &RecoveryEntry) -> String { ) } -/// Include the recovery Tab hint only when snapshots exist. -fn saved_page_hint(recovery: usize) -> String { - if recovery > 0 { - format!("↑↓ pick · enter load · tab recovery ({recovery}) · esc") - } else { - "↑↓ pick · enter load · esc".to_string() - } -} - /// Render the saved-session or recovery page of the session picker. fn render_session_picker(out: &mut impl Write, app: &App) -> io::Result<()> { // Preformat recovery rows for the recovery page. @@ -926,7 +906,7 @@ fn render_session_picker(out: &mut impl Write, app: &App) -> io::Result<()> { " load session", app.session_names.as_slice(), app.session_sel, - saved_page_hint(recovery.len()), + tab_hint("↑↓ pick · enter load", "recovery", recovery.len()), Some(" (no saved sessions)"), ), SessionPage::Recovery => ( @@ -1114,13 +1094,11 @@ pub fn scroll_window(sel: usize, total: usize, max: usize) -> (usize, usize) { #[cfg(test)] mod tests { use super::*; + use crate::testutil::rows; #[test] fn selection_overlay_spans_rows_and_rounds_wide_glyphs() { - let lines: Vec = ["a日本b", " mid ", "tail"] - .iter() - .map(|s| s.to_string()) - .collect(); + let lines = rows(&["a日本b", " mid ", "tail"]); // A boundary inside 日 expands to the glyph's first cell; the middle // row starts at column 0 and excludes trailing whitespace. let mut sel = Selection::begin(0, 2); @@ -1133,10 +1111,6 @@ mod tests { assert!(selection_overlay(Some(&sel), &[]).is_empty()); } - fn rows(spec: &[&str]) -> Vec { - spec.iter().map(|s| s.to_string()).collect() - } - /// A pane-sized grid whose first rows carry `content`, the rest blank. fn grid(content: &[&str], height: usize) -> Vec { let mut g = rows(content); @@ -1249,12 +1223,15 @@ mod tests { } } - /// Include recovery in the saved-page hint only when snapshots exist. + /// Add the sibling page's name to the hint only when it has entries. #[test] - fn saved_page_hint_shows_the_count_only_when_nonzero() { - assert_eq!(saved_page_hint(0), "↑↓ pick · enter load · esc"); + fn tab_hint_shows_the_count_only_when_nonzero() { + assert_eq!( + tab_hint("↑↓ pick · enter load", "recovery", 0), + "↑↓ pick · enter load · esc" + ); assert_eq!( - saved_page_hint(3), + tab_hint("↑↓ pick · enter load", "recovery", 3), "↑↓ pick · enter load · tab recovery (3) · esc" ); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3e174cf..8af359a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -15,6 +15,8 @@ use std::{ time::{Duration, Instant}, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; + /// Protocol version used by this test suite; must match /// `protocol::PROTOCOL_VERSION`. pub const PROTOCOL_VERSION: u32 = 14; @@ -35,27 +37,7 @@ pub fn control_frame(json: &str) -> Vec { /// Encode bytes as padded standard base64. pub fn b64(bytes: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let n = (u32::from(chunk[0]) << 16) - | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) - | u32::from(*chunk.get(2).unwrap_or(&0)); - let idx = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63]; - out.push(ALPHABET[idx[0] as usize] as char); - out.push(ALPHABET[idx[1] as usize] as char); - out.push(if chunk.len() > 1 { - ALPHABET[idx[2] as usize] as char - } else { - '=' - }); - out.push(if chunk.len() > 2 { - ALPHABET[idx[3] as usize] as char - } else { - '=' - }); - } - out + B64.encode(bytes) } /// Read one frame, blocking: `(kind, payload)`. Mirrors `frame::read_frame`. @@ -247,6 +229,83 @@ pub fn scratch(tag: &str) -> RuntimeDir { RuntimeDir(dir) } +/// Create a scratch tree for a managed-launch test with stub executables in +/// `bin`, an empty `nobin`, daemon runtime and config directories, harness homes, +/// a working directory, and argv records for the stubs. +pub struct Scratch { + pub root: RuntimeDir, +} + +impl Scratch { + pub fn new(tag: &str) -> Self { + // Keep the scratch tree separate from start_daemon_raw's directory, + // which is cleared during daemon setup. + let root = scratch(&format!("{tag}_scratch")); + for sub in [ + "bin", + "nobin", + "run", + "config", + "claude-home", + "codex-home", + "work", + ] { + std::fs::create_dir_all(root.join(sub)).unwrap(); + } + Self { root } + } + + pub fn bin(&self) -> PathBuf { + self.root.join("bin") + } + + /// Return the runtime directory to pass in the daemon handshake. + pub fn runtime(&self) -> PathBuf { + self.root.join("run") + } + + pub fn work(&self) -> PathBuf { + self.root.join("work") + } + + pub fn recipe(&self, name: &str) -> PathBuf { + self.root + .join("config") + .join("sessions") + .join(format!("{name}.json")) + } + + /// Return the named stub's argv record, with one argument per line. + pub fn record(&self, tool: &str) -> PathBuf { + self.root.join(format!("{tool}-argv")) + } + + /// Build the handshake environment with Fleetcom and harness paths under `root`. + /// Use `path` verbatim for `PATH`; callers choose whether to include system directories. + pub fn hello_env(&self, path: String) -> Vec<(String, String)> { + vec![ + ("PATH".into(), path), + ("SHELL".into(), "/bin/sh".into()), + ( + "FLEETCOM_CONFIG_DIR".into(), + self.root.join("config").display().to_string(), + ), + ( + "FLEETCOM_RUNTIME_DIR".into(), + self.runtime().display().to_string(), + ), + ( + "CLAUDE_CONFIG_DIR".into(), + self.root.join("claude-home").display().to_string(), + ), + ( + "CODEX_HOME".into(), + self.root.join("codex-home").display().to_string(), + ), + ] + } +} + /// Start a `fleetcom --daemon` against an isolated runtime dir and connect a /// raw socket to it with no handshake, for tests that exercise the handshake /// itself. `configure` tweaks the daemon's `Command` (extra env vars) before diff --git a/tests/daemon_agents.rs b/tests/daemon_agents.rs index 2649da3..efa4c5e 100644 --- a/tests/daemon_agents.rs +++ b/tests/daemon_agents.rs @@ -4,69 +4,25 @@ mod common; -use std::{io::Write, os::unix::net::UnixStream, path::PathBuf, time::Duration}; +use std::{io::Write, os::unix::net::UnixStream, time::Duration}; use common::{ - RuntimeDir, next_frame, scratch, shake_hands_env, spawn_agent_frame, start_daemon_raw, - stop_daemon, wait_until, write_executable, + Scratch, next_frame, shake_hands_env, spawn_agent_frame, start_daemon_raw, stop_daemon, + wait_until, write_executable, }; -/// Scratch tree: the stub in `bin`, an empty `nobin`, and isolated runtime, config, and -/// registry paths. -struct Scratch { - root: RuntimeDir, -} - -impl Scratch { - fn new(tag: &str) -> Self { - let root = scratch(&format!("{tag}_scratch")); - for sub in ["bin", "nobin", "run", "config", "claude-home", "work"] { - std::fs::create_dir_all(root.join(sub)).unwrap(); - } - Self { root } - } - - fn work(&self) -> PathBuf { - self.root.join("work") - } - - /// The stub's argv record, one element per line. - fn record(&self) -> PathBuf { - self.root.join("claude-argv") - } - - /// Handshake environment with exactly `/` as `PATH`. Managed launches - /// require no shell lookup, so no other PATH entries are needed. - fn hello_env(&self, bin: &str) -> Vec<(String, String)> { - vec![ - ("PATH".into(), self.root.join(bin).display().to_string()), - ("SHELL".into(), "/bin/sh".into()), - ( - "FLEETCOM_CONFIG_DIR".into(), - self.root.join("config").display().to_string(), - ), - ( - "FLEETCOM_RUNTIME_DIR".into(), - self.root.join("run").display().to_string(), - ), - ( - "CLAUDE_CONFIG_DIR".into(), - self.root.join("claude-home").display().to_string(), - ), - ] - } -} - -/// Send a handshake whose `PATH` is the scratch tree's `bin` subdirectory. +/// Send a handshake whose `PATH` is exactly `/`. Managed launches require no +/// shell lookup, so no other PATH entries are needed. fn hello(stream: &mut UnixStream, s: &Scratch, bin: &str) { - shake_hands_env(stream, &s.work().display().to_string(), &s.hello_env(bin)); + let path = s.root.join(bin).display().to_string(); + shake_hands_env(stream, &s.work().display().to_string(), &s.hello_env(path)); } /// Install a `claude` stub to record argv and exit. fn install_claude_stub(s: &Scratch) { write_executable( - &s.root.join("bin").join("claude"), - &format!("printf '%s\\n' \"$@\" > '{}'", s.record().display()), + &s.bin().join("claude"), + &format!("printf '%s\\n' \"$@\" > '{}'", s.record("claude").display()), ); } @@ -97,10 +53,10 @@ fn hello_discovers_agents_and_spawn_agent_launches_a_managed_task() { "the view must say managed: {tasks}" ); assert!( - wait_until(Duration::from_secs(5), || s.record().exists()), + wait_until(Duration::from_secs(5), || s.record("claude").exists()), "the stub never ran" ); - let argv = std::fs::read_to_string(s.record()).unwrap(); + let argv = std::fs::read_to_string(s.record("claude")).unwrap(); assert!( argv.lines().any(|l| l == "--session-id"), "the launch must carry the harness argv: {argv:?}" diff --git a/tests/daemon_handshake.rs b/tests/daemon_handshake.rs index 7c0c2ef..689c745 100644 --- a/tests/daemon_handshake.rs +++ b/tests/daemon_handshake.rs @@ -2,15 +2,16 @@ mod common; -use std::{io::Write, time::Duration}; +use std::{io::Write, os::unix::net::UnixStream, time::Duration}; use common::{ - control_frame, frame, hello_frame, read_frame, start_daemon, start_daemon_raw, wait_until, + PROTOCOL_VERSION, control_frame, frame, hello_frame, read_frame, shake_hands, start_daemon, + start_daemon_raw, wait_until, }; /// Read the refusal `Status`, assert `needle` appears, then require EOF: the /// daemon must close, not serve. Return the refusal text for further assertions. -fn expect_refusal(stream: &mut std::os::unix::net::UnixStream, needle: &str) -> String { +fn expect_refusal(stream: &mut UnixStream, needle: &str) -> String { let (kind, payload) = read_frame(stream).expect("no refusal frame"); let text = String::from_utf8_lossy(&payload).into_owned(); assert_eq!(kind, 1, "refusal must be a control frame"); @@ -27,70 +28,55 @@ fn expect_refusal(stream: &mut std::os::unix::net::UnixStream, needle: &str) -> #[test] fn version_mismatch_is_refused_with_both_versions_named() { - let (dir, daemon, mut stream) = start_daemon_raw("mismatch", |_| {}); + let (dir, _daemon, mut stream) = start_daemon_raw("mismatch", |_| {}); let cwd = dir.display().to_string(); - let previous = common::PROTOCOL_VERSION - 1; + let previous = PROTOCOL_VERSION - 1; stream.write_all(&hello_frame(previous, &[], &cwd)).unwrap(); let refusal = expect_refusal(&mut stream, &format!("client speaks v{previous}")); assert!( - refusal.contains(&format!("speaks v{}, client", common::PROTOCOL_VERSION)), + refusal.contains(&format!("speaks v{PROTOCOL_VERSION}, client")), "refusal must name the daemon version: {refusal}" ); // The daemon survives the refusal and accepts the next (correct) client. let sock = dir.join("default.sock"); - let mut retry = std::os::unix::net::UnixStream::connect(&sock).unwrap(); - common::shake_hands(&mut retry, &cwd); - - drop(daemon); + let mut retry = UnixStream::connect(&sock).unwrap(); + shake_hands(&mut retry, &cwd); } /// A hello with version 3 but invalid field encoding is still reported as a version /// mismatch. #[test] fn v3_hello_is_refused_as_a_version_mismatch() { - let (dir, daemon, mut stream) = start_daemon_raw("v3hello", |_| {}); + let (dir, _daemon, mut stream) = start_daemon_raw("v3hello", |_| {}); // The underscore in the runtime path makes this cwd invalid standard base64. let v3 = format!(r#"{{"v":3,"cwd":"{}","env":[]}}"#, dir.display()); stream.write_all(&frame(3, v3.as_bytes())).unwrap(); expect_refusal(&mut stream, "v3"); - drop(daemon); -} - -/// A v2 control-frame hello is reported as a version mismatch. -#[test] -fn v2_hello_is_refused_as_a_version_mismatch() { - let (dir, daemon, mut stream) = start_daemon_raw("v2hello", |_| {}); - let cwd = dir.display().to_string(); - let v2 = format!(r#"{{"t":"hello","v":2,"cwd":"{cwd}","env":[[[65],[66]]]}}"#); - stream.write_all(&control_frame(&v2)).unwrap(); - expect_refusal(&mut stream, "v2"); - drop(daemon); } #[test] fn pre_handshake_command_is_refused() { - let (_dir, daemon, mut stream) = start_daemon_raw("nohello", |_| {}); + let (_dir, _daemon, mut stream) = start_daemon_raw("nohello", |_| {}); // A command before `Hello` is rejected. stream .write_all(&control_frame(r#"{"t":"resize","rows":40,"cols":120}"#)) .unwrap(); expect_refusal(&mut stream, "hello"); - drop(daemon); } #[test] fn silent_client_cannot_wedge_the_daemon() { - let (dir, daemon, stream) = start_daemon_raw("silent", |_| {}); + let (dir, _daemon, _stream) = start_daemon_raw("silent", |_| {}); // Connect and send nothing: the handshake read must time out and the // daemon must come back to accept() for the next client. let sock = dir.join("default.sock"); let cwd = dir.display().to_string(); let served_next = wait_until(Duration::from_secs(10), || { - std::os::unix::net::UnixStream::connect(&sock) + UnixStream::connect(&sock) .map(|mut s| { // A successful handshake proves the accept loop is live again. - s.write_all(&hello_frame(common::PROTOCOL_VERSION, &[], &cwd)) + s.write_all(&hello_frame(PROTOCOL_VERSION, &[], &cwd)) .is_ok() && read_frame(&mut s) .map(|(_, p)| String::from_utf8_lossy(&p).contains("hello_ok")) @@ -99,8 +85,6 @@ fn silent_client_cannot_wedge_the_daemon() { .unwrap_or(false) }); assert!(served_next, "daemon wedged behind a silent connection"); - drop(stream); - drop(daemon); } /// The documented single-client semantics: a second client's hello gets no @@ -109,13 +93,13 @@ fn silent_client_cannot_wedge_the_daemon() { /// the first detaches. #[test] fn second_client_queues_until_first_detaches() { - let (dir, daemon, first) = start_daemon("queued", |_| {}); + let (dir, _daemon, first) = start_daemon("queued", |_| {}); let sock = dir.join("default.sock"); let cwd = dir.display().to_string(); - let mut second = std::os::unix::net::UnixStream::connect(&sock).unwrap(); + let mut second = UnixStream::connect(&sock).unwrap(); second - .write_all(&hello_frame(common::PROTOCOL_VERSION, &[], &cwd)) + .write_all(&hello_frame(PROTOCOL_VERSION, &[], &cwd)) .unwrap(); // While the first client is attached the daemon cannot even accept: the // read must sit on its deadline, not fail or get an answer. @@ -148,6 +132,4 @@ fn second_client_queues_until_first_detaches() { "queued client got a non-ack: {}", String::from_utf8_lossy(&payload) ); - - drop(daemon); } diff --git a/tests/daemon_kill.rs b/tests/daemon_kill.rs index d6335c8..03340d5 100644 --- a/tests/daemon_kill.rs +++ b/tests/daemon_kill.rs @@ -1,5 +1,6 @@ -//! `fleetcom --kill` must stop the daemon and its tasks while another client is -//! attached. +//! Test daemon shutdown while a client remains attached. Whether the test sends +//! SIGTERM directly or runs `fleetcom --kill`, the daemon must kill each task's +//! process group, remove its socket, and exit. mod common; @@ -10,7 +11,7 @@ use std::{ use nix::sys::signal::kill; -use common::{spawn_task, start_daemon, wait_until}; +use common::{spawn_task, start_daemon, stop_daemon, wait_until}; #[test] fn kill_works_while_a_client_is_attached() { @@ -61,3 +62,35 @@ fn kill_works_while_a_client_is_attached() { ); assert!(!sock.exists(), "socket file left behind"); } + +#[test] +fn sigterm_kills_daemon_and_its_tasks() { + let (dir, mut daemon, mut stream) = start_daemon("sigterm", |_| {}); + let sock = dir.join("default.sock"); + + // Record the setsid shell's PID; it is also the task's process-group ID. + let pidfile = dir.join("task.pid"); + let task = spawn_task( + &mut stream, + &dir, + &pidfile, + &format!("echo $$ > {} && sleep 300", pidfile.display()), + ); + // Use signal 0 only to check that the task exists. + assert!( + kill(task, None).is_ok(), + "task should be alive before SIGTERM" + ); + + // Send SIGTERM while this client remains attached. Verify the signal flag + // interrupts `run_loop` during the client loop, not only the idle accept loop. + stop_daemon(&mut daemon); + + // Daemon shutdown kills the task process group. Wait for init to reap the + // reparented child before signal 0 reports `ESRCH`. + let task_dead = wait_until(Duration::from_secs(5), || kill(task, None).is_err()); + assert!(task_dead, "task survived the daemon's SIGTERM shutdown"); + + // Assert that clean shutdown removed the socket. + assert!(!sock.exists(), "socket file left behind"); +} diff --git a/tests/daemon_lifetime.rs b/tests/daemon_lifetime.rs index 68112c9..0698c57 100644 --- a/tests/daemon_lifetime.rs +++ b/tests/daemon_lifetime.rs @@ -1,22 +1,18 @@ -//! The fleet's lifetime is bounded by the daemon's: SIGKILLing the daemon closes every -//! PTY master, and the resulting hangup SIGHUPs each task's foreground group. Ordinary -//! tasks die; only HUP-immune tasks survive, unowned. Verify both cases against the -//! documented lifetime guarantees. +//! Test task lifetime after daemon exit. When a SIGKILLed daemon exits, the kernel +//! closes each PTY master and sends SIGHUP to the task's foreground group. Ordinary +//! tasks exit; HUP-immune tasks survive without an owner. Verify both outcomes. mod common; use std::time::{Duration, Instant}; -use nix::{ - sys::signal::{Signal, kill, killpg}, - unistd::Pid, -}; +use nix::sys::signal::{Signal, kill, killpg}; use common::{spawn_task, start_daemon, wait_until}; #[test] fn ordinary_tasks_die_with_a_sigkilled_daemon() { - let (dir, mut daemon, mut stream) = start_daemon("hup_dies", |_| {}); + let (dir, daemon, mut stream) = start_daemon("hup_dies", |_| {}); let pidfile = dir.join("task.pid"); let task = spawn_task( &mut stream, @@ -26,9 +22,9 @@ fn ordinary_tasks_die_with_a_sigkilled_daemon() { ); assert!(kill(task, None).is_ok(), "task should be alive"); - // SIGKILL: no shutdown path runs; only the fd-close/HUP mechanism remains. - kill(Pid::from_raw(daemon.0.id() as i32), Signal::SIGKILL).unwrap(); - let _ = daemon.0.wait(); + // Drop the daemon handle to send SIGKILL; this bypasses shutdown. The kernel + // closes the PTY master on process exit and sends SIGHUP to the foreground group. + drop(daemon); assert!( wait_until(Duration::from_secs(5), || kill(task, None).is_err()), @@ -38,7 +34,7 @@ fn ordinary_tasks_die_with_a_sigkilled_daemon() { #[test] fn hup_immune_tasks_survive_a_sigkilled_daemon_unowned() { - let (dir, mut daemon, mut stream) = start_daemon("hup_immune", |_| {}); + let (dir, daemon, mut stream) = start_daemon("hup_immune", |_| {}); let pidfile = dir.join("task.pid"); // The trap precedes the long sleep, and the fg child inherits the ignore. let task = spawn_task( @@ -49,8 +45,7 @@ fn hup_immune_tasks_survive_a_sigkilled_daemon_unowned() { ); assert!(kill(task, None).is_ok(), "task should be alive"); - kill(Pid::from_raw(daemon.0.id() as i32), Signal::SIGKILL).unwrap(); - let _ = daemon.0.wait(); + drop(daemon); // Survival can't be polled-to-true (it's the absence of death): hold the // assertion window open, then check it's still there. diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 372ef1e..29cc36c 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -13,8 +13,8 @@ use std::{ }; use common::{ - RuntimeDir, control_frame, read_frame, scratch, shake_hands_env, spawn_agent_frame, - start_daemon_raw, stop_daemon, wait_until, write_executable, + Scratch, control_frame, read_frame, shake_hands_env, spawn_agent_frame, start_daemon_raw, + stop_daemon, wait_until, write_executable, }; /// Delimiter separating argv records in a stub's append-only output. @@ -40,84 +40,11 @@ fn turn_complete(thread: &str) -> String { /// every instrumented `codex` launch. const CODEX_EMBEDDED: &str = "features.daemon_auto_start=false"; -/// Scratch tree containing every executable, store, working directory, and argv -/// record used by one test. -struct Scratch { - root: RuntimeDir, -} - -impl Scratch { - fn new(tag: &str) -> Self { - // Keep the scratch tree separate from start_daemon_raw's directory, - // which is cleared during daemon setup. - let root = scratch(&format!("{tag}_scratch")); - for sub in ["bin", "run", "config", "claude-home", "codex-home", "work"] { - std::fs::create_dir_all(root.join(sub)).unwrap(); - } - Self { root } - } - - fn bin(&self) -> PathBuf { - self.root.join("bin") - } - - /// Runtime directory passed to the daemon handshake. - fn runtime(&self) -> PathBuf { - self.root.join("run") - } - - fn work(&self) -> PathBuf { - self.root.join("work") - } - - fn recipe(&self, name: &str) -> PathBuf { - self.root - .join("config") - .join("sessions") - .join(format!("{name}.json")) - } - - /// The named stub's argv record. - fn record(&self, tool: &str) -> PathBuf { - self.root.join(format!("{tool}-argv")) - } - - /// Marker touched by the `codex` stub after both notifications. - fn notified(&self) -> PathBuf { - self.root.join("codex-notified") - } - - /// Explicit handshake environment with every resolved path under `root`. - fn hello_env(&self) -> Vec<(String, String)> { - vec![ - ( - "PATH".into(), - format!("{}:/usr/bin:/bin", self.bin().display()), - ), - ("SHELL".into(), "/bin/sh".into()), - ( - "FLEETCOM_CONFIG_DIR".into(), - self.root.join("config").display().to_string(), - ), - ( - "FLEETCOM_RUNTIME_DIR".into(), - self.runtime().display().to_string(), - ), - ( - "CLAUDE_CONFIG_DIR".into(), - self.root.join("claude-home").display().to_string(), - ), - ( - "CODEX_HOME".into(), - self.root.join("codex-home").display().to_string(), - ), - ] - } -} - -/// Send a handshake scoped to the scratch tree. +/// Send a handshake scoped to the scratch tree. Put `/usr/bin:/bin` after the +/// stubs in `PATH` because the `codex` stub runs `mkdir`. fn hello(stream: &mut UnixStream, s: &Scratch) { - shake_hands_env(stream, &s.work().display().to_string(), &s.hello_env()); + let path = format!("{}:/usr/bin:/bin", s.bin().display()); + shake_hands_env(stream, &s.work().display().to_string(), &s.hello_env(path)); } /// Drain daemon events while a test polls files. Without this reader, snapshot @@ -180,7 +107,7 @@ fi"#, id = CODEX_ID, root = turn_complete(CODEX_ID), title = turn_complete(CODEX_TITLE), - notified = s.notified().display(), + notified = s.root.join("codex-notified").display(), ); write_executable(&s.bin().join("codex"), &body); } @@ -241,9 +168,8 @@ fn assert_daemon_namespaced(asset: &Path, daemon_pid: u32, what: &str, argv: &[S ); } -/// Save once and return the persisted recipe. Wait for the ID channel at each call site; -/// scrape finished tasks in the daemon before reading IDs. Require the resume ID in that -/// first save. +/// Send `save`, wait for the recipe file, and return its contents. Callers +/// verify the persisted resume ID. fn save_once(stream: &mut UnixStream, recipe: &Path, name: &str) -> String { stream .write_all(&control_frame(&format!( @@ -377,7 +303,9 @@ fn codex_capture_file_drives_save_and_load_resumes() { // Exit the stub silently so the notify script is the only ID channel. Wait for both // notifications, then require a bare root UUID and no temporary file. Preserve the root // after the title notification and persist the resume ID on the first save. - let ok = wait_until(Duration::from_secs(10), || s.notified().exists()); + let ok = wait_until(Duration::from_secs(10), || { + s.root.join("codex-notified").exists() + }); assert!(ok, "the codex stub never finished notifying"); let files = namespace_files(&s.runtime()); let names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); @@ -490,7 +418,6 @@ fn saved_recipe_resumes_across_a_daemon_restart() { "the recipe must resume the pinned id: {recipe}" ); stop_daemon(&mut daemon_a); - drop(stream_a); // Start another daemon with the same config and handshake environment. let (_dir_b, mut daemon_b, mut stream_b) = start_daemon_raw("resume_restart_b", |_| {}); diff --git a/tests/daemon_session.rs b/tests/daemon_session.rs index ae175f6..457fb81 100644 --- a/tests/daemon_session.rs +++ b/tests/daemon_session.rs @@ -59,10 +59,11 @@ fn session_commands_follow_the_hello_config_dir() { }); // Hand-rolled hello whose env carries the client-side config override. let cwd = dir.display().to_string(); - let client_cfg_str = client_cfg.display().to_string(); - let env: Vec<(&[u8], &[u8])> = - vec![(b"FLEETCOM_CONFIG_DIR".as_slice(), client_cfg_str.as_bytes())]; - shake_hands_env(&mut stream, &cwd, &env); + shake_hands_env( + &mut stream, + &cwd, + &[("FLEETCOM_CONFIG_DIR", client_cfg.display().to_string())], + ); stream .write_all(&control_frame(r#"{"t":"save","name":"where"}"#)) diff --git a/tests/daemon_signal.rs b/tests/daemon_signal.rs deleted file mode 100644 index 923ea1e..0000000 --- a/tests/daemon_signal.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! End-to-end daemon signal handling: SIGTERM to a serving daemon must -//! group-kill its tasks, remove its socket, and exit. The tasks live in their own -//! process groups and must be terminated explicitly during daemon shutdown. - -mod common; - -use std::time::Duration; - -use nix::sys::signal::kill; - -use common::{spawn_task, start_daemon, stop_daemon, wait_until}; - -#[test] -fn sigterm_kills_daemon_and_its_tasks() { - let (dir, mut daemon, mut stream) = start_daemon("sigterm", |_| {}); - let sock = dir.join("default.sock"); - - // Spawn a task that records its own pid ($$ is the setsid'd shell, so pid == - // pgid) and then outlives the test unless killed. - let pidfile = dir.join("task.pid"); - let task = spawn_task( - &mut stream, - &dir, - &pidfile, - &format!("echo $$ > {} && sleep 300", pidfile.display()), - ); - // Signal 0: existence check only. - assert!( - kill(task, None).is_ok(), - "task should be alive before SIGTERM" - ); - - // SIGTERM the daemon *while our client is attached*: the flag must - // interrupt `run_loop` mid-serve, not just the idle accept loop. - stop_daemon(&mut daemon); - - // The task was group-killed on the way out (grace: init still has to reap - // the reparented child before ESRCH). - let task_dead = wait_until(Duration::from_secs(5), || kill(task, None).is_err()); - assert!(task_dead, "task survived the daemon's SIGTERM shutdown"); - - // Clean shutdown removes the socket. - assert!(!sock.exists(), "socket file left behind"); -}