use std::path::{Path, PathBuf};
use clap::Subcommand;
use newt_core::timer::{AckOutcome, SystemClock, TimerStore};
#[derive(Subcommand, Debug)]
pub enum TimerCmd {
Schedule {
duration: String,
prompt: String,
#[arg(long)]
every: Option<String>,
#[arg(long, value_name = "DIR")]
dir: Option<PathBuf>,
},
List {
#[arg(long, value_name = "DIR")]
dir: Option<PathBuf>,
},
Fire {
#[arg(long)]
run: bool,
#[arg(long, value_name = "DIR")]
dir: Option<PathBuf>,
},
Dismiss {
id: String,
#[arg(long, value_name = "DIR")]
dir: Option<PathBuf>,
},
}
pub async fn run(cmd: TimerCmd, config: Option<&Path>) -> anyhow::Result<i32> {
match cmd {
TimerCmd::Schedule {
duration,
prompt,
every,
dir,
} => {
let after = parse_duration(&duration)?;
let repeat = every.as_deref().map(parse_duration).transpose()?;
if let Some(0) = repeat {
anyhow::bail!("--every must be greater than zero (got 0)");
}
let store = TimerStore::open(dir.as_deref())?;
let clock = SystemClock;
let workspace = std::env::current_dir().ok();
let timer = store.schedule(after, &prompt, &clock, repeat, workspace)?;
println!("scheduled {} (fires in {duration})", timer.id);
Ok(0)
}
TimerCmd::List { dir } => {
let store = TimerStore::open(dir.as_deref())?;
for t in store.list()? {
let rep = t
.repeat_secs
.map_or_else(|| "one-shot".to_string(), |s| format!("every {s}s"));
println!("{}\t{}\t{}", t.id, t.fire_at, rep);
}
Ok(0)
}
TimerCmd::Fire { run, dir } => {
let store = TimerStore::open(dir.as_deref())?;
let clock = SystemClock;
if !run {
for t in store.due(&clock)? {
println!("PROM\t{}\t{}", t.id, encode_prompt_field(&t.prompt));
}
return Ok(0);
}
loop {
let Some(timer) = store.claim_next_due(&clock)? else {
return Ok(0); };
let Some(token) = timer.claim_token.clone() else {
anyhow::bail!("claimed timer {} carries no claim token", timer.id);
};
let code = fire_solve(&timer.prompt, timer.workspace.as_deref(), config).await?;
match store.acknowledge(&timer.id, &token, code == 0, &clock)? {
AckOutcome::Applied => {}
other => {
eprintln!(
"timer {}: claim no longer held ({other:?}); leaving it to its owner",
timer.id
);
return Ok(if code == 0 { 0 } else { code });
}
}
if code != 0 {
return Ok(code);
}
}
}
TimerCmd::Dismiss { id, dir } => {
let store = TimerStore::open(dir.as_deref())?;
if store.dismiss(&id)? {
println!("dismissed {id}");
Ok(0)
} else {
eprintln!("no timer uniquely matches '{id}'");
Ok(1)
}
}
}
}
async fn fire_solve(
prompt: &str,
workspace: Option<&Path>,
config: Option<&Path>,
) -> anyhow::Result<i32> {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!("newt-timer-{}-{nonce}.txt", std::process::id()));
std::fs::write(&path, prompt)?;
let _remove = TempRemove(path.clone());
let args = crate::headless::HeadlessArgs {
cwd: workspace.map_or_else(|| PathBuf::from("."), Path::to_path_buf),
instruction_file: path,
profile: config.map(Path::to_path_buf),
non_interactive: true,
unsafe_host_exec: false,
confined: false,
events: None,
smart_harness: false,
frame_dir: None,
hermetic_explicit: true,
launch: newt_launch::LaunchConfig::default(),
max_rounds: None,
context_window: None,
model_digest: None,
scratchpad_state: None,
require_feature: Vec::new(),
output_allowance: None,
run_allowance: None,
};
crate::headless::run(args).await
}
struct TempRemove(PathBuf);
impl Drop for TempRemove {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn parse_duration(s: &str) -> anyhow::Result<u64> {
let s = s.trim();
if s.is_empty() {
anyhow::bail!("empty duration");
}
let (num, unit) = s.split_at(s.len() - 1);
let scale = |n: u64, mul: u64, what: &str| -> anyhow::Result<u64> {
n.checked_mul(mul)
.ok_or_else(|| anyhow::anyhow!("duration '{s}' overflows: too many {what}"))
};
match unit {
"s" => num
.parse::<u64>()
.map_err(|e| anyhow::anyhow!("bad seconds '{s}': {e}")),
"m" => scale(
num.parse::<u64>()
.map_err(|e| anyhow::anyhow!("bad minutes '{s}': {e}"))?,
60,
"minutes",
),
"h" => scale(
num.parse::<u64>()
.map_err(|e| anyhow::anyhow!("bad hours '{s}': {e}"))?,
3600,
"hours",
),
_ => s
.parse::<u64>()
.map_err(|e| anyhow::anyhow!("bad duration '{s}': {e}")),
}
}
#[must_use]
pub fn encode_prompt_field(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_duration_units() {
assert_eq!(parse_duration("30s").unwrap(), 30);
assert_eq!(parse_duration("5m").unwrap(), 300);
assert_eq!(parse_duration("2h").unwrap(), 7200);
assert_eq!(parse_duration("300").unwrap(), 300);
assert_eq!(parse_duration("0").unwrap(), 0);
assert_eq!(parse_duration("0s").unwrap(), 0);
}
#[test]
fn parse_duration_rejects_garbage() {
assert!(parse_duration("").is_err());
assert!(parse_duration("abc").is_err());
assert!(parse_duration("5x").is_err());
}
#[tokio::test]
async fn schedule_rejects_zero_repeat() {
for bad in ["0", "0s", "0m", "0h"] {
let cmd = TimerCmd::Schedule {
duration: "5m".into(),
prompt: "p".into(),
every: Some(bad.into()),
dir: None,
};
let err = run(cmd, None).await.unwrap_err().to_string();
assert!(
err.contains("greater than zero"),
"--every {bad} should be rejected: {err}"
);
}
let blocker = std::env::temp_dir().join(format!(
"newt-test-block-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
));
std::fs::write(&blocker, "x").unwrap();
let cmd = TimerCmd::Schedule {
duration: "5m".into(),
prompt: "p".into(),
every: Some("60".into()),
dir: Some(blocker.join("sub")),
};
assert!(run(cmd, None).await.is_err());
let _ = std::fs::remove_file(&blocker);
}
#[test]
fn parse_duration_rejects_overflowing_units() {
let m = format!("{}m", (u64::MAX / 60) + 1);
let err = parse_duration(&m).unwrap_err().to_string();
assert!(err.contains("overflows"), "{err}");
let h = format!("{}h", (u64::MAX / 3600) + 1);
let err = parse_duration(&h).unwrap_err().to_string();
assert!(err.contains("overflows"), "{err}");
assert_eq!(
parse_duration(&format!("{}m", u64::MAX / 60)).unwrap(),
(u64::MAX / 60) * 60
);
assert_eq!(
parse_duration(&format!("{}h", u64::MAX / 3600)).unwrap(),
(u64::MAX / 3600) * 3600
);
assert_eq!(parse_duration(&u64::MAX.to_string()).unwrap(), u64::MAX);
}
#[test]
fn prom_field_encoding_keeps_one_timer_on_one_line() {
let nasty = "line one\nline two\tcolumn\r\nback\\slash";
let encoded = encode_prompt_field(nasty);
assert!(
!encoded.contains('\n') && !encoded.contains('\t') && !encoded.contains('\r'),
"encoded field must stay on one line and one column: {encoded:?}"
);
assert_eq!(encoded, r"line one\nline two\tcolumn\r\nback\\slash");
let record = format!("PROM\t{}\t{}", "tm_1_1000", encoded);
let fields: Vec<&str> = record.split('\t').collect();
assert_eq!(fields.len(), 3);
assert_eq!(fields[1], "tm_1_1000");
assert_eq!(record.lines().count(), 1);
assert_ne!(encode_prompt_field(r"a\nb"), encode_prompt_field("a\nb"));
}
}