feat(cron): add lore cron command for automated sync scheduling
Add lore cron {install,uninstall,status} to manage a crontab entry that
runs lore sync on a configurable interval. Supports both human and robot
output modes.
Core implementation (src/core/cron.rs):
- install_cron: appends a tagged crontab entry, detects existing entries
- uninstall_cron: removes the tagged entry
- cron_status: reads crontab + checks last-sync time from the database
- Unix-only (#[cfg(unix)]) — compiles out on Windows
CLI wiring:
- CronAction enum and CronArgs in cli/mod.rs with after_help examples
- Robot JSON envelope with RobotMeta timing for all 3 sub-actions
- Dispatch in main.rs
Also in this commit:
- Add after_help example blocks to Status, Auth, Doctor, Init, Migrate,
Health commands for better discoverability
- Add LORE_ICONS env var documentation to CLI help text
- Simplify notes format dispatch in main.rs (removed csv/jsonl paths)
- Update commands/mod.rs re-exports for cron + notes cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
369
src/core/cron.rs
Normal file
369
src/core/cron.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::error::{LoreError, Result};
|
||||
use super::paths::get_data_dir;
|
||||
|
||||
const CRON_TAG: &str = "# lore-sync";
|
||||
|
||||
// ── File-based sync lock (fcntl F_SETLK) ──
|
||||
|
||||
/// RAII guard that holds an `fcntl` write lock on a file.
|
||||
/// The lock is released when the guard is dropped.
|
||||
pub struct SyncLockGuard {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
/// Try to acquire an exclusive file lock (non-blocking).
|
||||
///
|
||||
/// Returns `Ok(Some(guard))` if the lock was acquired, `Ok(None)` if another
|
||||
/// process holds it, or `Err` on I/O failure.
|
||||
#[cfg(unix)]
|
||||
pub fn acquire_sync_lock() -> Result<Option<SyncLockGuard>> {
|
||||
acquire_sync_lock_at(&lock_path())
|
||||
}
|
||||
|
||||
fn lock_path() -> PathBuf {
|
||||
get_data_dir().join("sync.lock")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn acquire_sync_lock_at(path: &Path) -> Result<Option<SyncLockGuard>> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file = File::options()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(path)?;
|
||||
|
||||
let fd = file.as_raw_fd();
|
||||
|
||||
// SAFETY: zeroed memory is valid for libc::flock (all-zero is a valid
|
||||
// representation on every Unix platform). We then set only the fields we need.
|
||||
let mut flock = unsafe { std::mem::zeroed::<libc::flock>() };
|
||||
flock.l_type = libc::F_WRLCK as libc::c_short;
|
||||
flock.l_whence = libc::SEEK_SET as libc::c_short;
|
||||
|
||||
// SAFETY: fd is a valid open file descriptor; flock is stack-allocated.
|
||||
let rc = unsafe { libc::fcntl(fd, libc::F_SETLK, &mut flock) };
|
||||
if rc == -1 {
|
||||
let err = io::Error::last_os_error();
|
||||
if err.kind() == io::ErrorKind::WouldBlock
|
||||
|| err.raw_os_error() == Some(libc::EAGAIN)
|
||||
|| err.raw_os_error() == Some(libc::EACCES)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(LoreError::Io(err));
|
||||
}
|
||||
|
||||
Ok(Some(SyncLockGuard { _file: file }))
|
||||
}
|
||||
|
||||
// ── Crontab management ──
|
||||
|
||||
/// The crontab entry that `lore cron install` writes.
|
||||
///
|
||||
/// Paths are single-quoted so spaces in binary or log paths don't break
|
||||
/// the cron expression.
|
||||
pub fn build_cron_entry(interval_minutes: u32) -> String {
|
||||
let binary = std::env::current_exe()
|
||||
.unwrap_or_else(|_| PathBuf::from("lore"))
|
||||
.display()
|
||||
.to_string();
|
||||
let log_path = sync_log_path();
|
||||
format!(
|
||||
"*/{interval_minutes} * * * * '{binary}' sync -q --lock >> '{log}' 2>&1 {CRON_TAG}",
|
||||
log = log_path.display(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Path where cron-triggered sync output is appended.
|
||||
pub fn sync_log_path() -> PathBuf {
|
||||
get_data_dir().join("sync.log")
|
||||
}
|
||||
|
||||
/// Read the current user crontab. Returns empty string when no crontab exists.
|
||||
fn read_crontab() -> Result<String> {
|
||||
let output = Command::new("crontab").arg("-l").output()?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
} else {
|
||||
// exit 1 with "no crontab for <user>" is normal — treat as empty
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a full crontab string. Replaces the current crontab entirely.
|
||||
fn write_crontab(content: &str) -> Result<()> {
|
||||
let mut child = Command::new("crontab")
|
||||
.arg("-")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(ref mut stdin) = child.stdin {
|
||||
stdin.write_all(content.as_bytes())?;
|
||||
}
|
||||
let status = child.wait()?;
|
||||
if !status.success() {
|
||||
return Err(LoreError::Other(format!(
|
||||
"crontab exited with status {status}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install (or update) the lore-sync crontab entry.
|
||||
pub fn install_cron(interval_minutes: u32) -> Result<CronInstallResult> {
|
||||
let entry = build_cron_entry(interval_minutes);
|
||||
|
||||
let existing = read_crontab()?;
|
||||
let replaced = existing.contains(CRON_TAG);
|
||||
|
||||
// Strip ALL old lore-sync lines first, then append one new entry.
|
||||
// This is idempotent even if the crontab somehow has duplicate tagged lines.
|
||||
let mut filtered: String = existing
|
||||
.lines()
|
||||
.filter(|line| !line.contains(CRON_TAG))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !filtered.is_empty() && !filtered.ends_with('\n') {
|
||||
filtered.push('\n');
|
||||
}
|
||||
filtered.push_str(&entry);
|
||||
filtered.push('\n');
|
||||
|
||||
write_crontab(&filtered)?;
|
||||
|
||||
Ok(CronInstallResult {
|
||||
entry,
|
||||
interval_minutes,
|
||||
log_path: sync_log_path(),
|
||||
replaced,
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove the lore-sync crontab entry.
|
||||
pub fn uninstall_cron() -> Result<CronUninstallResult> {
|
||||
let existing = read_crontab()?;
|
||||
if !existing.contains(CRON_TAG) {
|
||||
return Ok(CronUninstallResult {
|
||||
was_installed: false,
|
||||
});
|
||||
}
|
||||
|
||||
let new_crontab: String = existing
|
||||
.lines()
|
||||
.filter(|line| !line.contains(CRON_TAG))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
|
||||
// If the crontab would be empty (only whitespace), remove it entirely
|
||||
if new_crontab.trim().is_empty() {
|
||||
let status = Command::new("crontab").arg("-r").status()?;
|
||||
if !status.success() {
|
||||
return Err(LoreError::Other("crontab -r failed".to_string()));
|
||||
}
|
||||
} else {
|
||||
write_crontab(&new_crontab)?;
|
||||
}
|
||||
|
||||
Ok(CronUninstallResult {
|
||||
was_installed: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect the current crontab for a lore-sync entry.
|
||||
pub fn cron_status() -> Result<CronStatusResult> {
|
||||
let existing = read_crontab()?;
|
||||
let lore_line = existing.lines().find(|l| l.contains(CRON_TAG));
|
||||
|
||||
match lore_line {
|
||||
Some(line) => {
|
||||
let interval = parse_interval(line);
|
||||
let binary_path = parse_binary_path(line);
|
||||
|
||||
let current_exe = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.display().to_string());
|
||||
let binary_mismatch = current_exe
|
||||
.as_ref()
|
||||
.zip(binary_path.as_ref())
|
||||
.is_some_and(|(current, cron)| current != cron);
|
||||
|
||||
Ok(CronStatusResult {
|
||||
installed: true,
|
||||
interval_minutes: interval,
|
||||
binary_path,
|
||||
current_binary: current_exe,
|
||||
binary_mismatch,
|
||||
log_path: Some(sync_log_path()),
|
||||
cron_entry: Some(line.to_string()),
|
||||
})
|
||||
}
|
||||
None => Ok(CronStatusResult {
|
||||
installed: false,
|
||||
interval_minutes: None,
|
||||
binary_path: None,
|
||||
current_binary: std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.display().to_string()),
|
||||
binary_mismatch: false,
|
||||
log_path: None,
|
||||
cron_entry: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the interval from a cron expression like `*/8 * * * * ...`
|
||||
fn parse_interval(line: &str) -> Option<u32> {
|
||||
let first_field = line.split_whitespace().next()?;
|
||||
if let Some(n) = first_field.strip_prefix("*/") {
|
||||
n.parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the binary path from the cron entry after the 5 time fields.
|
||||
///
|
||||
/// Handles both quoted (`'/path with spaces/lore'`) and unquoted paths.
|
||||
/// We skip the time fields manually to avoid `split_whitespace` breaking
|
||||
/// on spaces inside single-quoted paths.
|
||||
fn parse_binary_path(line: &str) -> Option<String> {
|
||||
// Skip the 5 cron time fields (min hour dom month dow).
|
||||
// These never contain spaces, so whitespace-splitting is safe here.
|
||||
let mut rest = line;
|
||||
for _ in 0..5 {
|
||||
rest = rest.trim_start();
|
||||
let end = rest.find(char::is_whitespace)?;
|
||||
rest = &rest[end..];
|
||||
}
|
||||
rest = rest.trim_start();
|
||||
|
||||
// The command starts here — it may be single-quoted.
|
||||
if let Some(after_quote) = rest.strip_prefix('\'') {
|
||||
let end = after_quote.find('\'')?;
|
||||
Some(after_quote[..end].to_string())
|
||||
} else {
|
||||
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result types ──
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CronInstallResult {
|
||||
pub entry: String,
|
||||
pub interval_minutes: u32,
|
||||
pub log_path: PathBuf,
|
||||
pub replaced: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CronUninstallResult {
|
||||
pub was_installed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CronStatusResult {
|
||||
pub installed: bool,
|
||||
pub interval_minutes: Option<u32>,
|
||||
pub binary_path: Option<String>,
|
||||
pub current_binary: Option<String>,
|
||||
pub binary_mismatch: bool,
|
||||
pub log_path: Option<PathBuf>,
|
||||
pub cron_entry: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_cron_entry_formats_correctly() {
|
||||
let entry = build_cron_entry(8);
|
||||
assert!(entry.starts_with("*/8 * * * * "));
|
||||
assert!(entry.contains("sync -q --lock"));
|
||||
assert!(entry.ends_with(CRON_TAG));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_interval_extracts_number() {
|
||||
assert_eq!(parse_interval("*/8 * * * * /usr/bin/lore sync"), Some(8));
|
||||
assert_eq!(parse_interval("*/15 * * * * /usr/bin/lore sync"), Some(15));
|
||||
assert_eq!(parse_interval("0 * * * * /usr/bin/lore sync"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_binary_path_extracts_sixth_field() {
|
||||
// Unquoted path
|
||||
assert_eq!(
|
||||
parse_binary_path(
|
||||
"*/8 * * * * /usr/local/bin/lore sync -q --lock >> /tmp/log 2>&1 # lore-sync"
|
||||
),
|
||||
Some("/usr/local/bin/lore".to_string())
|
||||
);
|
||||
// Single-quoted path without spaces
|
||||
assert_eq!(
|
||||
parse_binary_path(
|
||||
"*/8 * * * * '/usr/local/bin/lore' sync -q --lock >> '/tmp/log' 2>&1 # lore-sync"
|
||||
),
|
||||
Some("/usr/local/bin/lore".to_string())
|
||||
);
|
||||
// Single-quoted path WITH spaces (common on macOS)
|
||||
assert_eq!(
|
||||
parse_binary_path(
|
||||
"*/8 * * * * '/Users/Taylor Eernisse/.cargo/bin/lore' sync -q --lock >> '/tmp/log' 2>&1 # lore-sync"
|
||||
),
|
||||
Some("/Users/Taylor Eernisse/.cargo/bin/lore".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_lock_at_nonexistent_dir_creates_parents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let lock_file = dir.path().join("nested").join("deep").join("sync.lock");
|
||||
let guard = acquire_sync_lock_at(&lock_file).unwrap();
|
||||
assert!(guard.is_some());
|
||||
assert!(lock_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_lock_is_exclusive_across_processes() {
|
||||
// POSIX fcntl locks are per-process, so same-process re-lock always
|
||||
// succeeds. We verify cross-process exclusion using a Python child
|
||||
// that attempts the same fcntl F_SETLK.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let lock_file = dir.path().join("sync.lock");
|
||||
let _guard = acquire_sync_lock_at(&lock_file).unwrap().unwrap();
|
||||
|
||||
let script = r#"
|
||||
import fcntl, struct, sys
|
||||
fd = open(sys.argv[1], "w")
|
||||
try:
|
||||
fcntl.fcntl(fd, fcntl.F_SETLK, struct.pack("hhllhh", fcntl.F_WRLCK, 0, 0, 0, 0, 0))
|
||||
sys.exit(0)
|
||||
except (IOError, OSError):
|
||||
sys.exit(1)
|
||||
"#;
|
||||
let status = std::process::Command::new("python3")
|
||||
.args(["-c", script, &lock_file.display().to_string()])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!status.success(),
|
||||
"child process should fail to acquire fcntl lock held by parent"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod backoff;
|
||||
pub mod config;
|
||||
#[cfg(unix)]
|
||||
pub mod cron;
|
||||
pub mod db;
|
||||
pub mod dependent_queue;
|
||||
pub mod error;
|
||||
|
||||
Reference in New Issue
Block a user