Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/code-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
9 changes: 7 additions & 2 deletions src/console/cronjobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -81,15 +86,15 @@ 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()
|| (remote.is_none() && viewer.scopes.contains(&super::Scope::Run));
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())),
}
Expand Down
108 changes: 65 additions & 43 deletions src/console/start_controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -149,20 +159,10 @@ pub(super) struct Options {

impl Options {
pub fn load(config: &UserConfig) -> Result<Self> {
let jobs = match crate::cronjobs::jobs() {
Ok(jobs) => jobs,
Err(error)
if error
.downcast_ref::<std::io::Error>()
.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)?,
})
}
}
Expand All @@ -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<Vec<Pass>> {
let directories = crate::launch_directories::load()?;
let home = crate::config::home()?;
pub(super) fn passes(
config: &UserConfig,
captured: &crate::launch_directories::Captured,
) -> Result<Vec<Pass>> {
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<Vec<Pass>> {
let mut choices = pass_choices(config, captured)?;
choices.sort_by_key(|(repository, _)| *repository);
Ok(choices.into_iter().map(|(_, pass)| pass).collect())
}

fn pass_choices<'a>(
config: &UserConfig,
captured: &'a crate::launch_directories::Captured,
) -> Result<Vec<(&'a str, Pass)>> {
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 {
Expand All @@ -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,
)
}
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
49 changes: 30 additions & 19 deletions src/cronjobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CronJob>,
pub pass_starts: Vec<PassStart>,
Expand All @@ -35,7 +35,7 @@ impl CronJobs {
}
}

#[derive(Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct CronJob {
pub schedule: String,
pub command: String,
Expand Down Expand Up @@ -124,27 +124,31 @@ where

pub(crate) fn load() -> Result<CronJobs> {
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<CronJob>, logs_dir: &std::path::Path) -> Result<CronJobs> {
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| {
(a.repository.is_none(), &a.repository).cmp(&(b.repository.is_none(), &b.repository))
});
Ok(CronJobs {
jobs,
pass_starts: pass_starts::load(&config.logs_dir)?,
pass_starts: pass_starts::load(logs_dir)?,
})
}

pub(crate) fn jobs() -> Result<Vec<CronJob>> {
pub(crate) struct InterpretedEntry {
pub job: CronJob,
pub interpretation: Interpretation,
}

pub(crate) fn entries() -> Result<Vec<InterpretedEntry>> {
let output = Command::new("crontab")
.arg("-l")
.env("LC_ALL", "C")
Expand All @@ -166,7 +170,11 @@ pub(crate) fn jobs() -> Result<Vec<CronJob>> {
);
}
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<InterpretedEntry> {
crontab.lines().filter_map(entry).collect()
}

pub fn show(json: bool) -> Result<String> {
Expand Down Expand Up @@ -194,7 +202,7 @@ pub fn show(json: bool) -> Result<String> {
Ok(lines.join("\n"))
}

fn entry(line: &str) -> Option<CronJob> {
fn entry(line: &str) -> Option<InterpretedEntry> {
let mut command = line.trim();
// User crontabs have five schedule fields, or one @-schedule field.
// Requiring a schedule excludes comments and environment assignments.
Expand All @@ -212,11 +220,14 @@ fn entry(line: &str) -> Option<CronJob> {
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,
})
}

Expand Down
Loading