From 18031061684dd053bb45ca35f046aa0334cf4736 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Wed, 11 Nov 2026 06:46:55 -0500 Subject: [PATCH 1/2] Capture Launch directory inventory per operation (#2014) --- docs/code-navigation.md | 2 +- src/console/cronjobs.rs | 9 +- src/console/start_controls.rs | 108 +++++++------ src/cronjobs.rs | 49 +++--- src/launch_directories.rs | 195 +++++++++++++++++------ src/launch_directories/tests.rs | 166 ++++++++++++++++++++ tests/console/cronjobs.rs | 263 ++++++++++++++++++++++++++++++++ tests/console/start.rs | 43 ++++++ 8 files changed, 725 insertions(+), 110 deletions(-) create mode 100644 src/launch_directories/tests.rs diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 2d2d1a1c..00a45a52 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -80,7 +80,7 @@ Start with the issue's terms in `GLOSSARY.md`, then use this index to find their | Theme, hidden sections, Pause motion | `src/config/console.rs` — built-in names and restricted User config writes; `src/console/look.rs` — CLI/HTTP look actions; `src/console/assets/themes.css` — tokens; `src/console/assets/look.js` — picker and browser motion preference | `tests/console/look.rs`, `console/tests/inbox.test.cjs` | | Custom Theme, Theme import/export, custom fonts | `src/config/console/themes.rs` — portable format, defaults, validation, folder and confined font reads; `src/console/look.rs` — shared saves and Console log; `src/console/assets/look.js` — JSON editor | `tests/console/look.rs`, `console/tests/custom-themes.test.cjs`; format in `console/README.md` | | Console installation, proxy site, system service | `src/console/install.rs` — printed Linux installation script; `src/config/console.rs` — address and port | `tests/console_install.rs` | -| Cron job, Command kind, crontab inventory | `src/cronjobs.rs` — read-only account crontab and CLI; `src/cronjobs/command.rs` — owned `Interpretation`, recognition and independent Command-kind/Launch/start facts; `src/launch_directories.rs` — filesystem/origin resolution; `src/console/start_controls.rs` — supported Pass start choices; `src/console/cronjobs.rs`, `src/console/cronjobs.html` — page rendering | `src/cronjobs/command.rs` interface behavior tables; `tests/console/cronjobs.rs`, `tests/console/start.rs`, `console/tests/cronjobs.test.cjs`, `console/tests/start.test.cjs` | +| Cron job, Command kind, Launch directory discovery | `src/launch_directories.rs` — request-owned `Captured`: one crontab outcome, interpreted entry/checkout associations, memoized literal-path and canonical-origin observations (including failures), ordered directory merging and lazy configured validation; `src/cronjobs.rs` — account crontab acquisition, retained interpretations and strict display projection; `src/cronjobs/command.rs` — recognition and independent Command-kind/Launch/start facts; `src/console/start_controls.rs` — supported Pass choices and repository forms over the same capture; `src/console/cronjobs.rs`, `src/console/cronjobs.html` — page inventory and optional starts over one local capture; `src/console/commands.rs` — fresh submission discovery | `src/launch_directories/tests.rs` — associated projections, aliases, lexical log precedence and projection-specific errors with temporary Git checkouts; `src/cronjobs/command.rs` behavior tables; `tests/console/cronjobs.rs` — changing counted crontabs, counted Git adapter, error matrix and CLI/HTTP parity; `tests/console/start.rs` — fresh submission and existing command fixtures; `console/tests/cronjobs.test.cjs`, `console/tests/start.test.cjs` | | Pass last-fired time | `src/launch.rs` — shared Pass entry; `src/logs/recording.rs` — atomic per-kind start replacement; `src/logs/pass_starts.rs` — saved times and shared age display; `src/logs/reading.rs` — per-repository inventory shared by status and Cron jobs | `tests/activity_log.rs`, `tests/status.rs`, `tests/console/cronjobs.rs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — shared request, signaling, waiting and inspection; `src/console/stop.rs` — confirmation and authenticated Console action; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`, `tests/console/stop.rs`, `console/tests/stop.test.cjs`; inline Claim and Stop tests | | Dismissal | `src/logs/dismiss.rs` — shared CLI/Console dismissal and take-back using `Evaluation::into_target`, record-side `*.dismiss.json` files, `Captured` normalizes legacy and accumulated kinds for one read per owning path; `src/console/dismiss.rs` — authenticated Dismiss/Undo form posts; `src/console/peek.rs` and `src/console/peek.html` — Command details actions and CLI forms; `src/logs/reading/inbox.rs` — target visibility before collapse (including explicit History Undo), captured coverage, return on an uncovered kind, pending Finding after a dismissed Failed row, waiting exit, Resolved retention from the dismissal and collapse partition; `src/logs/status.rs` and `src/logs/status/inbox.rs` — text, JSON (`dismissal.kinds`) and Console rows | `tests/dismiss.rs` — health transitions, live endings, Security findings, hidden History membership and Spec-wide kind coverage; `src/logs/reading/tests.rs`, `tests/console/dismiss.rs` | diff --git a/src/console/cronjobs.rs b/src/console/cronjobs.rs index c03fb9b4..5229634d 100644 --- a/src/console/cronjobs.rs +++ b/src/console/cronjobs.rs @@ -58,6 +58,11 @@ pub(super) async fn show( }); } let remote = selection.remote(&config, &viewer, format)?; + let captured = if remote.is_none() { + Some(crate::launch_directories::Captured::capture(&config)?) + } else { + None + }; let mut remote_passes = None; // Run lists startable Passes. The crontab and last-fired times stay behind read. let (inventory, show_jobs) = if let Some(remote) = &remote { @@ -81,7 +86,7 @@ pub(super) async fn show( ) } } else { - (cronjobs::load()?, true) + (captured.as_ref().unwrap().cronjobs()?.clone(), true) }; format.render(inventory, |inventory| { let can_run = remote_passes.is_some() @@ -89,7 +94,7 @@ pub(super) async fn show( let (passes, start_error) = if let Some(passes) = remote_passes { (passes, None) } else if remote.is_none() && can_run { - match super::start_controls::passes(&config, &inventory.jobs) { + match super::start_controls::cron_passes(&config, captured.as_ref().unwrap()) { Ok(passes) => (passes, None), Err(error) => (Vec::new(), Some(error.to_string())), } diff --git a/src/console/start_controls.rs b/src/console/start_controls.rs index 86bc8287..aa0a99af 100644 --- a/src/console/start_controls.rs +++ b/src/console/start_controls.rs @@ -55,6 +55,16 @@ impl Form { } pub fn load(config: &UserConfig) -> Self { + match crate::launch_directories::Captured::capture(config) { + Ok(captured) => Self::from_directories(config, captured.directories()), + Err(error) => Self::from_directories(config, Err(error)), + } + } + + fn from_directories( + config: &UserConfig, + directories: Result<&crate::launch_directories::Inventory>, + ) -> Self { let mut form = Self { repositories: Vec::new(), harness: config @@ -69,12 +79,12 @@ impl Form { .collect(), error: None, }; - match crate::launch_directories::load() { + match directories { Ok(inventory) => { let mut repositories: Vec<_> = inventory .directories - .into_iter() - .map(|d| d.repository) + .iter() + .map(|d| d.repository.clone()) .collect(); repositories.sort(); repositories.dedup(); @@ -149,20 +159,10 @@ pub(super) struct Options { impl Options { pub fn load(config: &UserConfig) -> Result { - let jobs = match crate::cronjobs::jobs() { - Ok(jobs) => jobs, - Err(error) - if error - .downcast_ref::() - .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => - { - Vec::new() - } - Err(error) => return Err(error), - }; + let captured = crate::launch_directories::Captured::capture(config)?; Ok(Self { - form: Form::load(config), - passes: passes(config, &jobs)?, + form: Form::from_directories(config, captured.directories()), + passes: passes(config, &captured)?, }) } } @@ -176,22 +176,41 @@ pub(super) async fn options() -> axum::response::Response { } /// Preserve the cron Pass's CLI options, while the API still decides admission. -pub(super) fn passes(config: &UserConfig, jobs: &[crate::cronjobs::CronJob]) -> Result> { - let directories = crate::launch_directories::load()?; - let home = crate::config::home()?; +pub(super) fn passes( + config: &UserConfig, + captured: &crate::launch_directories::Captured, +) -> Result> { + Ok(pass_choices(config, captured)? + .into_iter() + .map(|(_, pass)| pass) + .collect()) +} + +/// The local Cron jobs page has always presented controls in repository order. +pub(super) fn cron_passes( + config: &UserConfig, + captured: &crate::launch_directories::Captured, +) -> Result> { + let mut choices = pass_choices(config, captured)?; + choices.sort_by(|(a, _), (b, _)| a.cmp(b)); + Ok(choices.into_iter().map(|(_, pass)| pass).collect()) +} + +fn pass_choices<'a>( + config: &UserConfig, + captured: &'a crate::launch_directories::Captured, +) -> Result> { + captured.directories()?; let mut passes = Vec::new(); - for job in jobs { - let Some(facts) = crate::cronjobs::Interpretation::new(&job.command).into_launch(&home) - else { - continue; - }; - let Ok(path) = facts.directory.canonicalize() else { + for entry in captured.entries()? { + let Some(directory) = &entry.directory else { continue; }; - let Some(directory) = directories.directories.iter().find(|d| d.path == path) else { - continue; - }; - let Some(parsed) = facts.command else { + let Some(parsed) = entry + .launch + .as_ref() + .and_then(|launch| launch.command.as_ref()) + else { continue; }; let (mut request, flags, security) = match parsed { @@ -200,36 +219,36 @@ pub(super) fn passes(config: &UserConfig, jobs: &[crate::cronjobs::CronJob]) -> crate::ready::PickupOrder::LowestFirst => "lowest-first", crate::ready::PickupOrder::HighestFirst => "highest-first", })}), - pass.flags, + pass.flags.clone(), false, ), args::Command::Secure(pass) => ( json!({"command":"secure", "base":pass.base}), - pass.flags, + pass.flags.clone(), true, ), args::Command::Architect(pass) => ( json!({"command":"architect", "base":pass.base, "focus":pass.focus, "plan_only":pass.plan_only}), - pass.flags, + pass.flags.clone(), false, ), args::Command::Review(review) => { - let crate::review::Target::Author(author) = review.target else { + let crate::review::Target::Author(author) = &review.target else { continue; }; ( json!({"command":"review", "author":author, "recommend_only":review.mode == crate::review::Mode::RecommendOnly}), - review.flags, + review.flags.clone(), false, ) } args::Command::Fix(fix) => { - let crate::fix::Target::Pass { base, author } = fix.target else { + let crate::fix::Target::Pass { base, author } = &fix.target else { continue; }; ( json!({"command":"fix", "base":base, "author":author}), - fix.flags, + fix.flags.clone(), false, ) } @@ -240,8 +259,8 @@ pub(super) fn passes(config: &UserConfig, jobs: &[crate::cronjobs::CronJob]) -> json!({"command":"process", "name":process.name, "plan_only":process.plan_only}), Flags { goal: process.goal, - email: process.email, - harness: process.asked, + email: process.email.clone(), + harness: process.asked.clone(), security_review: process.security_review, security_fix: process.security_fix, security_close: process.security_close, @@ -315,11 +334,14 @@ pub(super) fn passes(config: &UserConfig, jobs: &[crate::cronjobs::CronJob]) -> } else { flags.harness(config) }; - passes.push(Pass { - command: job.command.clone(), - request: serde_json::to_string(&request)?, - harness: harness.harness.name().into(), - }); + passes.push(( + directory.repository.as_str(), + Pass { + command: entry.job.command.clone(), + request: serde_json::to_string(&request)?, + harness: harness.harness.name().into(), + }, + )); } Ok(passes) } diff --git a/src/cronjobs.rs b/src/cronjobs.rs index c147864c..7e51c3af 100644 --- a/src/cronjobs.rs +++ b/src/cronjobs.rs @@ -11,7 +11,7 @@ mod command; pub(crate) mod fleet; pub(crate) use command::{Interpretation, LaunchFacts}; -#[derive(Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub(crate) struct CronJobs { pub jobs: Vec, pub pass_starts: Vec, @@ -35,7 +35,7 @@ impl CronJobs { } } -#[derive(Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct CronJob { pub schedule: String, pub command: String, @@ -124,15 +124,14 @@ where pub(crate) fn load() -> Result { let config = UserConfig::load()?; - let home = crate::config::home()?; - let mut jobs = jobs()?; + Ok(crate::launch_directories::Captured::capture(&config)? + .cronjobs()? + .clone()) +} + +pub(crate) fn inventory(mut jobs: Vec, logs_dir: &std::path::Path) -> Result { for job in &mut jobs { - job.repository = repository_label( - Interpretation::new(&job.command) - .into_launch(&home) - .and_then(crate::launch_directories::from_facts) - .map(|directory| directory.repository), - ); + job.repository = repository_label(job.repository.take()); } // Stable sorting retains crontab order and duplicates within each repository. jobs.sort_by(|a, b| { @@ -140,11 +139,16 @@ pub(crate) fn load() -> Result { }); Ok(CronJobs { jobs, - pass_starts: pass_starts::load(&config.logs_dir)?, + pass_starts: pass_starts::load(logs_dir)?, }) } -pub(crate) fn jobs() -> Result> { +pub(crate) struct InterpretedEntry { + pub job: CronJob, + pub interpretation: Interpretation, +} + +pub(crate) fn entries() -> Result> { let output = Command::new("crontab") .arg("-l") .env("LC_ALL", "C") @@ -166,7 +170,11 @@ pub(crate) fn jobs() -> Result> { ); } let crontab = String::from_utf8(output.stdout).context("crontab is not UTF-8")?; - Ok(crontab.lines().filter_map(entry).collect()) + Ok(parse(&crontab)) +} + +pub(crate) fn parse(crontab: &str) -> Vec { + crontab.lines().filter_map(entry).collect() } pub fn show(json: bool) -> Result { @@ -194,7 +202,7 @@ pub fn show(json: bool) -> Result { Ok(lines.join("\n")) } -fn entry(line: &str) -> Option { +fn entry(line: &str) -> Option { let mut command = line.trim(); // User crontabs have five schedule fields, or one @-schedule field. // Requiring a schedule excludes comments and environment assignments. @@ -212,11 +220,14 @@ fn entry(line: &str) -> Option { command = command[end..].trim_start(); } let interpreted = Interpretation::new(command); - interpreted.recognized().then(|| CronJob { - schedule: schedule.join(" "), - command: command.into(), - repository: None, - command_kind: interpreted.command_kind(), + interpreted.recognized().then(|| InterpretedEntry { + job: CronJob { + schedule: schedule.join(" "), + command: command.into(), + repository: None, + command_kind: interpreted.command_kind(), + }, + interpretation: interpreted, }) } diff --git a/src/launch_directories.rs b/src/launch_directories.rs index 7c49ba0a..ab0342dc 100644 --- a/src/launch_directories.rs +++ b/src/launch_directories.rs @@ -1,4 +1,5 @@ //! Known Launch directories, shared by CLI discovery and Console starts. +use std::cell::{OnceCell, RefCell}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -7,7 +8,10 @@ use serde::Serialize; use crate::{config, cronjobs, git::Git, issue::Repo}; -#[derive(Serialize)] +#[cfg(test)] +mod tests; + +#[derive(Clone, Serialize)] pub(crate) struct Directory { pub repository: String, pub path: PathBuf, @@ -15,64 +19,165 @@ pub(crate) struct Directory { pub output_log: Option, } -#[derive(Serialize)] +#[derive(Clone, Serialize)] pub(crate) struct Inventory { pub directories: Vec, } pub(crate) fn load() -> Result { - let home = config::home()?; let config = config::UserConfig::load()?; - let jobs = match cronjobs::jobs() { - Ok(jobs) => jobs, - Err(error) - if error - .downcast_ref::() - .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => - { - Vec::new() + Ok(Captured::capture(&config)?.directories()?.clone()) +} + +/// Associated facts for one entry, independent of directory deduplication. +pub(crate) struct Entry { + pub job: cronjobs::CronJob, + pub launch: Option, + pub directory: Option, +} + +/// One owning-machine operation. Failed observations live as long as successes; +/// configured directories and Pass history are acquired only by their projections. +pub(crate) struct Captured { + entries: Result>, + configured: Vec, + logs_dir: PathBuf, + checkouts: RefCell, + directories: OnceCell>, + cronjobs: OnceCell>, +} + +impl Captured { + pub(crate) fn capture(config: &config::UserConfig) -> Result { + let home = config::home()?; + Ok(Self::new(config, &home, cronjobs::entries())) + } + + fn new( + config: &config::UserConfig, + home: &Path, + input: Result>, + ) -> Self { + let mut checkouts = Checkouts::default(); + let entries = input.map(|entries| { + entries + .into_iter() + .map(|entry| { + let launch = entry.interpretation.into_launch(home); + let directory = launch.as_ref().and_then(|facts| { + let mut directory = checkouts.resolve(&facts.directory).ok()?; + directory.output_log = facts.output_log.clone(); + Some(directory) + }); + Entry { + job: entry.job, + launch, + directory, + } + }) + .collect() + }); + Self { + entries, + configured: config.launch_directories.clone(), + logs_dir: config.logs_dir.clone(), + checkouts: RefCell::new(checkouts), + directories: OnceCell::new(), + cronjobs: OnceCell::new(), } - Err(error) => return Err(error), - }; - let mut directories = BTreeMap::new(); - for job in jobs { - if let Some(directory) = cronjobs::Interpretation::new(&job.command) - .into_launch(&home) - .and_then(from_facts) - { - directories - .entry(directory.path.clone()) - .or_insert(directory); + } + + /// Start/discovery projections tolerate only an absent crontab executable. + pub(crate) fn entries(&self) -> Result<&[Entry]> { + match &self.entries { + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) => + { + Ok(&[]) + } + result => Ok(observed(result)?.as_slice()), } } - for path in config.launch_directories { - let directory = resolve(&path) - .with_context(|| format!("invalid configured Launch directory {}", path.display()))?; - directories - .entry(directory.path.clone()) - .or_insert(directory); + + pub(crate) fn directories(&self) -> Result<&Inventory> { + observed(self.directories.get_or_init(|| { + let mut directories = BTreeMap::new(); + for directory in self + .entries()? + .iter() + .filter_map(|entry| entry.directory.as_ref()) + { + directories + .entry(directory.path.clone()) + .or_insert_with(|| directory.clone()); + } + for path in &self.configured { + // Validate aliases too; a Cron entry never authorizes a configured path. + let directory = self.checkouts.borrow_mut().resolve(path).with_context(|| { + format!("invalid configured Launch directory {}", path.display()) + })?; + directories + .entry(directory.path.clone()) + .or_insert(directory); + } + Ok(Inventory { + directories: directories.into_values().collect(), + }) + })) + } + + /// Strict inventory, without configured-directory validation or start data. + pub(crate) fn cronjobs(&self) -> Result<&cronjobs::CronJobs> { + observed(self.cronjobs.get_or_init(|| { + let jobs = observed(&self.entries)? + .iter() + .map(|entry| { + let mut job = entry.job.clone(); + job.repository = entry + .directory + .as_ref() + .map(|directory| directory.repository.clone()); + job + }) + .collect(); + cronjobs::inventory(jobs, &self.logs_dir) + })) } - Ok(Inventory { - directories: directories.into_values().collect(), - }) } -fn resolve(path: &Path) -> Result { - let path = path.canonicalize()?; - let origin = Git::new(&path).run(&["config", "--get", "remote.origin.url"])?; - let repo = Repo::of_origin(&origin).context("Launch directory origin is not on GitHub")?; - Ok(Directory { - repository: repo.slug(), - path, - output_log: None, - }) +fn observed(result: &Result) -> Result<&T> { + result + .as_ref() + .map_err(|error| anyhow::anyhow!("{error:#}")) +} + +#[derive(Default)] +struct Checkouts { + canonical: BTreeMap>, + origins: BTreeMap>, } -/// Resolve literal facts supplied by Cron job interpretation; never run Cron text. -pub(crate) fn from_facts(facts: cronjobs::LaunchFacts) -> Option { - let mut directory = resolve(&facts.directory).ok()?; - directory.output_log = facts.output_log; - Some(directory) +impl Checkouts { + fn resolve(&mut self, literal: &Path) -> Result { + let path = observed( + self.canonical + .entry(literal.to_owned()) + .or_insert_with(|| Ok(literal.canonicalize()?)), + )?; + let repository = observed(self.origins.entry(path.clone()).or_insert_with(|| { + let origin = Git::new(path).run(&["config", "--get", "remote.origin.url"])?; + Ok(Repo::of_origin(&origin) + .context("Launch directory origin is not on GitHub")? + .slug()) + }))?; + Ok(Directory { + repository: repository.clone(), + path: path.clone(), + output_log: None, + }) + } } pub fn show(json: bool) -> Result { diff --git a/src/launch_directories/tests.rs b/src/launch_directories/tests.rs new file mode 100644 index 00000000..322e5b63 --- /dev/null +++ b/src/launch_directories/tests.rs @@ -0,0 +1,166 @@ +use super::*; +use std::fs; +use std::os::unix::fs::symlink; + +fn checkout(home: &Path, name: &str, origin: &str) -> PathBuf { + let path = home.join(name); + fs::create_dir_all(&path).unwrap(); + let git = Git::new(&path); + git.run(&["init", "-q"]).unwrap(); + git.run(&["remote", "add", "origin", origin]).unwrap(); + path.canonicalize().unwrap() +} + +fn config(home: &Path) -> config::UserConfig { + config::UserConfig::parse("", &home.join("config.toml"), home).unwrap() +} + +#[test] +fn captured_projections_preserve_entry_associations_order_and_literal_logs() { + let home = tempfile::tempdir().unwrap(); + let a = checkout(home.path(), "a", "https://github.com/acme/z.git"); + let b = checkout(home.path(), "b", "https://github.com/acme/a.git"); + let alias = home.path().join("alias"); + symlink(&a, &alias).unwrap(); + let mut config = config(home.path()); + config.launch_directories = vec![alias.clone(), a.clone()]; + let text = "@daily cd ~/alias && thirdshift pickup\n@hourly cd ~/a && thirdshift secure >> later.log\n@daily cd ~/b && thirdshift architect > first.log\n@daily cd ~/b && thirdshift architect > first.log\n"; + let captured = Captured::new(&config, home.path(), Ok(cronjobs::parse(text))); + let entries = captured.entries().unwrap(); + assert_eq!(entries.len(), 4); + assert_eq!(entries[0].directory.as_ref().unwrap().path, a); + assert!(entries[0].launch.as_ref().unwrap().command.is_some()); + assert_eq!( + entries[1].launch.as_ref().unwrap().output_log, + Some(a.join("later.log")) + ); + let directories = &captured.directories().unwrap().directories; + assert_eq!( + directories.iter().map(|d| &d.path).collect::>(), + [&a, &b] + ); + assert_eq!(directories[0].output_log, None); + assert_eq!(directories[1].output_log, Some(b.join("first.log"))); + let jobs = &captured.cronjobs().unwrap().jobs; + assert_eq!( + jobs.iter() + .map(|job| job.schedule.as_str()) + .collect::>(), + ["@daily", "@daily", "@daily", "@hourly"] + ); + assert_eq!(jobs[0].repository.as_deref(), Some("acme/a")); + assert_eq!(jobs[2].repository.as_deref(), Some("acme/z")); + + let text = "@daily cd ~/alias && thirdshift pickup >> output.log\n@daily cd ~/a && thirdshift pickup\n"; + let captured = Captured::new(&config, home.path(), Ok(cronjobs::parse(text))); + assert_eq!( + captured.directories().unwrap().directories[0].output_log, + Some(alias.join("output.log")) + ); + // A captured checkout remains an observation; the next operation sees edits. + Git::new(&a) + .run(&[ + "remote", + "set-url", + "origin", + "https://github.com/acme/changed.git", + ]) + .unwrap(); + assert_eq!( + captured.cronjobs().unwrap().jobs[0].repository.as_deref(), + Some("acme/z") + ); + let fresh = Captured::new(&config, home.path(), Ok(cronjobs::parse(text))); + assert_eq!( + fresh.cronjobs().unwrap().jobs[0].repository.as_deref(), + Some("acme/changed") + ); +} + +#[test] +fn unavailable_checkout_observations_are_shared_with_configured_validation() { + let home = tempfile::tempdir().unwrap(); + let invalid = checkout( + home.path(), + "invalid", + "https://gitlab.com/acme/widgets.git", + ); + let missing = home.path().join("missing"); + symlink(&invalid, home.path().join("alias")).unwrap(); + let mut config = config(home.path()); + let text = + "@daily cd ~/missing && thirdshift pickup\n@daily cd ~/invalid && thirdshift pickup\n"; + config.launch_directories = vec![missing.clone()]; + let captured = Captured::new(&config, home.path(), Ok(cronjobs::parse(text))); + checkout( + home.path(), + "missing", + "https://github.com/acme/widgets.git", + ); + assert!( + captured + .cronjobs() + .unwrap() + .jobs + .iter() + .all(|job| job.repository.is_none()) + ); + assert!( + captured + .directories() + .err() + .unwrap() + .to_string() + .contains("invalid configured Launch directory") + ); + + config.launch_directories = vec![home.path().join("alias")]; + let captured = Captured::new(&config, home.path(), Ok(cronjobs::parse(text))); + Git::new(&invalid) + .run(&[ + "remote", + "set-url", + "origin", + "https://github.com/acme/widgets.git", + ]) + .unwrap(); + assert!( + captured + .directories() + .err() + .unwrap() + .to_string() + .contains("not on GitHub") + ); + assert_eq!(captured.cronjobs().unwrap().jobs.len(), 2); + let fresh = Captured::new(&config, home.path(), Ok(cronjobs::parse(text))); + assert_eq!(fresh.directories().unwrap().directories.len(), 2); +} + +#[test] +fn projection_errors_preserve_strict_cron_and_missing_tool_discovery() { + let home = tempfile::tempdir().unwrap(); + let path = checkout(home.path(), "repo", "https://github.com/acme/widgets.git"); + let mut config = config(home.path()); + config.launch_directories = vec![path]; + for kind in [ + std::io::ErrorKind::NotFound, + std::io::ErrorKind::PermissionDenied, + ] { + let error = + Err(std::io::Error::new(kind, "fixture crontab error")).context("can't read cron jobs"); + let captured = Captured::new(&config, home.path(), error); + assert!(captured.cronjobs().is_err()); + assert_eq!( + captured.directories().is_ok(), + kind == std::io::ErrorKind::NotFound + ); + if kind == std::io::ErrorKind::NotFound { + assert!(captured.entries().unwrap().is_empty()); + assert_eq!(captured.directories().unwrap().directories.len(), 1); + } + } + let captured = Captured::new(&config, home.path(), Ok(Vec::new())); + assert!(captured.cronjobs().unwrap().jobs.is_empty()); + assert_eq!(captured.directories().unwrap().directories.len(), 1); +} diff --git a/tests/console/cronjobs.rs b/tests/console/cronjobs.rs index 23b38fd6..b1157006 100644 --- a/tests/console/cronjobs.rs +++ b/tests/console/cronjobs.rs @@ -39,6 +39,269 @@ fn checkout(home: &Path, name: &str, origin: &str) -> std::path::PathBuf { path } +fn changing_crontab(home: &Path) { + checkout(home, "first", "https://github.com/acme/first.git"); + checkout(home, "second", "https://github.com/acme/second.git"); + support::write_executable( + &home.join("bin/crontab"), + "#!/bin/sh\nn=0\n[ ! -f \"$HOME/reads\" ] || n=$(cat \"$HOME/reads\")\nn=$((n+1))\necho $n > \"$HOME/reads\"\nif [ $n = 1 ]; then repo=first; else repo=second; fi\necho \"@daily cd ~/$repo && thirdshift pickup\"\n", + ); +} + +#[test] +fn start_options_share_one_crontab_capture_and_refresh_on_the_next_request() { + let home = fixture(); + changing_crontab(home.path()); + let mut start = command(home.path()); + start.args(["console", "--no-sign-in"]); + let console = Console::start_command(start); + for (read, repo) in [(1, "first"), (2, "second")] { + let options = api::json(console.get("/api/v1/start-options")); + assert_eq!( + fs::read_to_string(home.path().join("reads")) + .unwrap() + .trim(), + read.to_string() + ); + assert_eq!( + options["form"]["repositories"][0]["repository"], + format!("acme/{repo}") + ); + let request: Value = + serde_json::from_str(options["passes"][0]["request"].as_str().unwrap()).unwrap(); + assert_eq!(request["repository"], format!("acme/{repo}")); + } +} + +#[test] +fn local_cron_page_shares_its_capture_with_run_now() { + let home = fixture(); + changing_crontab(home.path()); + let mut start = command(home.path()); + start.args(["console", "--no-sign-in"]); + let console = Console::start_command(start); + for (read, repo) in [(1, "first"), (2, "second")] { + let html = console.get("/cronjobs").text().unwrap(); + let document = scraper::Html::parse_document(&html); + let commands = scraper::Selector::parse(".cron-command").unwrap(); + let buttons = scraper::Selector::parse("[data-start-request]").unwrap(); + assert_eq!( + document + .select(&commands) + .next() + .unwrap() + .text() + .collect::(), + format!("cd ~/{repo} && thirdshift pickup") + ); + let request: Value = serde_json::from_str( + document + .select(&buttons) + .next() + .unwrap() + .attr("data-start-request") + .unwrap(), + ) + .unwrap(); + assert_eq!(request["repository"], format!("acme/{repo}")); + assert_eq!( + fs::read_to_string(home.path().join("reads")) + .unwrap() + .trim(), + read.to_string() + ); + } +} + +#[test] +fn cron_page_keeps_repository_order_while_start_options_keep_crontab_order() { + let home = fixture(); + checkout(home.path(), "a", "https://github.com/acme/a.git"); + checkout(home.path(), "b", "https://github.com/acme/b.git"); + fs::write(home.path().join("crontab"), "@daily cd ~/b && thirdshift pickup\n@hourly cd ~/a && thirdshift architect\n@daily cd ~/b && thirdshift secure\n").unwrap(); + let mut start = command(home.path()); + start.args(["console", "--no-sign-in"]); + let console = Console::start_command(start); + let options = api::json(console.get("/api/v1/start-options")); + assert_eq!( + options["passes"] + .as_array() + .unwrap() + .iter() + .map(|pass| pass["command"].as_str().unwrap()) + .collect::>(), + [ + "cd ~/b && thirdshift pickup", + "cd ~/a && thirdshift architect", + "cd ~/b && thirdshift secure" + ] + ); + let html = console.get("/cronjobs").text().unwrap(); + let document = scraper::Html::parse_document(&html); + let buttons = scraper::Selector::parse("[data-start-request]").unwrap(); + let commands: Vec<_> = document + .select(&buttons) + .map(|button| { + let request: Value = + serde_json::from_str(button.attr("data-start-request").unwrap()).unwrap(); + request["command"].as_str().unwrap().to_owned() + }) + .collect(); + assert_eq!(commands, ["architect", "pickup", "secure"]); +} + +#[test] +fn aliases_and_duplicate_entries_share_successful_and_failed_origin_observations() { + let home = fixture(); + let valid = checkout(home.path(), "valid", "https://github.com/acme/widgets.git"); + let invalid = checkout( + home.path(), + "invalid", + "https://gitlab.com/acme/widgets.git", + ); + std::os::unix::fs::symlink(&valid, home.path().join("alias")).unwrap(); + std::os::unix::fs::symlink(&invalid, home.path().join("bad-alias")).unwrap(); + fs::write( + home.path().join(".thirdshift/config.toml"), + "launch_directories = ['~/alias', '~/valid']\n[console]\nport = 0\n", + ) + .unwrap(); + fs::write(home.path().join("crontab"), "@daily cd ~/alias && thirdshift pickup\n@daily cd ~/valid && thirdshift pickup\n@hourly cd ~/alias && thirdshift secure\n@daily cd ~/invalid && thirdshift pickup\n@daily cd ~/bad-alias && thirdshift pickup\n").unwrap(); + support::write_executable( + &home.path().join("bin/git"), + "#!/bin/sh\nif [ \"$1 $2 $3\" = 'config --get remote.origin.url' ]; then echo \"$PWD\" >> \"$HOME/origin-reads\"; fi\nexec /usr/bin/git \"$@\"\n", + ); + let mut start = command(home.path()); + start.args(["console", "--no-sign-in"]); + let console = Console::start_command(start); + let options = api::json(console.get("/api/v1/start-options")); + assert_eq!(options["form"]["repositories"].as_array().unwrap().len(), 1); + assert_eq!(options["passes"].as_array().unwrap().len(), 3); + let reads = fs::read_to_string(home.path().join("origin-reads")).unwrap(); + assert_eq!( + reads.lines().collect::>(), + [valid.to_str().unwrap(), invalid.to_str().unwrap()] + ); +} + +#[test] +fn invalid_configured_directory_keeps_cron_inventory_and_reports_start_error() { + let home = fixture(); + checkout(home.path(), "repo", "https://github.com/acme/widgets.git"); + fs::write( + home.path().join("crontab"), + "@daily cd ~/repo && thirdshift pickup\n", + ) + .unwrap(); + fs::write( + home.path().join(".thirdshift/config.toml"), + "launch_directories = ['~/missing']\n[console]\nport = 0\n", + ) + .unwrap(); + let mut start = command(home.path()); + start.args(["console", "--no-sign-in"]); + let console = Console::start_command(start); + assert_eq!( + api::json(console.get("/api/v1/cronjobs"))["jobs"][0]["repository"], + "acme/widgets" + ); + assert_eq!(console.get("/api/v1/launch-directories").status(), 422); + assert_eq!(console.get("/api/v1/start-options").status(), 422); + let page = console.get("/cronjobs"); + assert_eq!(page.status(), 200); + let html = page.text().unwrap(); + assert!(html.contains("acme/widgets"), "{html}"); + assert!( + html.contains("Could not load startable Passes: invalid configured Launch directory"), + "{html}" + ); + assert!( + console + .get("/") + .text() + .unwrap() + .contains("invalid configured Launch directory") + ); +} + +#[test] +fn owning_machine_projections_keep_crontab_error_rules() { + let home = fixture(); + checkout(home.path(), "repo", "https://github.com/acme/widgets.git"); + fs::write( + home.path().join(".thirdshift/config.toml"), + "launch_directories = ['~/repo']\n[console]\nport = 0\n", + ) + .unwrap(); + std::os::unix::fs::symlink("/usr/bin/git", home.path().join("bin/git")).unwrap(); + let mut start = command(home.path()); + start + .env("PATH", home.path().join("bin")) + .args(["console", "--no-sign-in"]); + let console = Console::start_command(start); + for (script, strict, discovery) in [ + (Some("#!/bin/sh\nexit 0\n"), 200, 200), + ( + Some("#!/bin/sh\necho 'crontab: no crontab for owner' >&2\nexit 1\n"), + 200, + 200, + ), + (None, 422, 200), + ( + Some("#!/bin/sh\necho 'crontab: permission denied' >&2\nexit 1\n"), + 422, + 422, + ), + (Some("#!/bin/sh\nprintf '\\377'\n"), 422, 422), + ] { + let executable = home.path().join("bin/crontab"); + if let Some(script) = script { + support::write_executable(&executable, script); + } else { + fs::remove_file(&executable).unwrap(); + } + for path in ["/api/v1/cronjobs", "/cronjobs"] { + let status = if path == "/cronjobs" && strict == 422 { + 500 + } else { + strict + }; + assert_eq!(console.get(path).status(), status, "{script:?}: {path}"); + } + for path in ["/api/v1/launch-directories", "/api/v1/start-options"] { + let response = console.get(path); + assert_eq!(response.status(), discovery, "{script:?}: {path}"); + if discovery == 200 { + let value = api::json(response); + if path.ends_with("start-options") { + assert_eq!( + value["form"]["repositories"][0]["repository"], + "acme/widgets" + ); + assert!(value["passes"].as_array().unwrap().is_empty()); + } else { + assert_eq!(value["directories"][0]["repository"], "acme/widgets"); + } + } + } + for (args, expected) in [ + (vec!["cronjobs", "--json"], strict), + (vec!["launch-directories", "--json"], discovery), + ] { + let output = command(home.path()) + .env("PATH", home.path().join("bin")) + .args(args) + .output() + .unwrap(); + assert_eq!( + output.status.success(), + expected == 200, + "{script:?}: {output:?}" + ); + } + } +} + #[test] fn cron_job_projections_agree_without_conflating_kind_directory_and_start() { let home = fixture(); diff --git a/tests/console/start.rs b/tests/console/start.rs index 78d0ff41..e06cc1de 100644 --- a/tests/console/start.rs +++ b/tests/console/start.rs @@ -31,6 +31,49 @@ fn targeted_fix_keeps_run_scope_csrf_and_launch_directory_checks() { assert!(!scenario.path("claude-calls.json").exists()); } +#[test] +fn submission_captures_fresh_directories_instead_of_trusting_a_start_choice() { + let scenario = fixture(); + let console = server(&scenario); + let runner = tokens::create(&scenario.path("home"), "runner", &["--scopes", "run"]); + let options = api::json(console.get("/api/v1/start-options")); + assert_eq!( + options["form"]["repositories"][0]["repository"], + "acme/widgets" + ); + let request: Value = + serde_json::from_str(options["passes"][0]["request"].as_str().unwrap()).unwrap(); + for change in ["crontab", "origin"] { + if change == "crontab" { + fs::write(scenario.path("home/crontab"), "").unwrap(); + } else { + scenario.user_config_is(&format!( + "launch_directories = [{}]\n[console]\nport = 0\n", + serde_json::to_string(&scenario.launch_dir()).unwrap() + )); + let output = Command::new("git") + .current_dir(scenario.launch_dir()) + .args([ + "remote", + "set-url", + "origin", + "https://github.com/acme/changed.git", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + } + let response = post(&console, &runner, &request); + assert_eq!( + response.status(), + 422, + "{change}: {}", + response.text().unwrap() + ); + } + assert!(!scenario.path("claude-calls.json").exists()); +} + #[test] fn fix_row_action_keeps_other_account_commands_read_only() { let scenario = fixture(); From 27873c3a4fb680e3528202526461fcf20abd5bd0 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Wed, 11 Nov 2026 06:54:55 -0500 Subject: [PATCH 2/2] Use key sorting for repository-ordered Pass choices --- src/console/start_controls.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/console/start_controls.rs b/src/console/start_controls.rs index aa0a99af..6aef56e0 100644 --- a/src/console/start_controls.rs +++ b/src/console/start_controls.rs @@ -192,7 +192,7 @@ pub(super) fn cron_passes( captured: &crate::launch_directories::Captured, ) -> Result> { let mut choices = pass_choices(config, captured)?; - choices.sort_by(|(a, _), (b, _)| a.cmp(b)); + choices.sort_by_key(|(repository, _)| *repository); Ok(choices.into_iter().map(|(_, pass)| pass).collect()) }