use crate::{bytes_to_hex_str, collect_flags, fmt_lh, load_signer, registry, truncate_words, wallet, SCHEDULE_DEFAULT_RUNS, SCHEDULE_MIN_INTERVAL_SECS};
#[derive(Debug)]
pub(crate) struct ParsedSchedule {
target: String,
task: String,
interval_secs: u64,
max_runs: u32,
}
pub(crate) const SCHEDULE_USAGE: &str = "usage: localharness schedule [--as <me>] <target> <task> \
--every <dur> [--runs <n>]\n \
dur: 60s / 5m / 1h (min 60s). Runs OFF-CHAIN, billed per run from \
your meter (no escrow).";
pub(crate) const GOAL_USAGE: &str = "usage: localharness goal [--as <me>] <target> <goal text> \
[--every <dur>] [--runs <n>]\n \
defaults: --every 5m, --runs 100 dur: 60s / 5m / 1h (min 60s). \
Off-chain, billed per run from your meter; the first \
fire lands one full interval after creation.";
pub(crate) const BUDGET_REMOVED: &str = "--budget is no longer used: scheduled agent jobs run \
OFF-CHAIN now and bill your meter per run (~1 $LH/model call) — there is no upfront escrow. \
Remove --budget and re-run.";
pub(crate) const GOAL_TASK_PREFIX: &str = "GOAL: ";
pub(crate) const GOAL_DEFAULT_INTERVAL_SECS: u64 = 300;
pub(crate) fn task_is_blank(task: &str) -> bool {
let t = task.trim();
t.is_empty() || t == GOAL_TASK_PREFIX.trim()
}
pub(crate) fn parse_interval(raw: &str) -> Result<u64, String> {
let s = raw.trim().to_ascii_lowercase();
if s.is_empty() {
return Err("interval is empty".to_string());
}
let (num_part, mult) = match s.strip_suffix('s') {
Some(n) => (n, 1u64),
None => match s.strip_suffix('m') {
Some(n) => (n, 60u64),
None => match s.strip_suffix('h') {
Some(n) => (n, 3600u64),
None => (s.as_str(), 1u64), },
},
};
let n: u64 = num_part
.parse()
.map_err(|_| format!("invalid interval '{raw}' (use 60s / 5m / 1h)"))?;
let secs = n
.checked_mul(mult)
.ok_or_else(|| format!("interval '{raw}' overflows"))?;
if secs < SCHEDULE_MIN_INTERVAL_SECS {
return Err(format!(
"interval '{raw}' is below the {SCHEDULE_MIN_INTERVAL_SECS}s minimum"
));
}
Ok(secs)
}
pub(crate) fn fmt_interval(secs: u64) -> String {
if secs == 0 {
return "0s".to_string();
}
if secs % 3600 == 0 {
return format!("{}h", secs / 3600);
}
if secs % 60 == 0 {
return if secs > 3600 {
format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}m", secs / 60)
};
}
format!("{secs}s")
}
pub(crate) fn parse_schedule_args(rest: &[String]) -> Result<ParsedSchedule, String> {
let ([every, budget, runs], positional) =
collect_flags(rest, ["--every", "--budget", "--runs"], SCHEDULE_USAGE)?;
if budget.is_some() {
return Err(BUDGET_REMOVED.to_string());
}
if positional.len() < 2 {
return Err(SCHEDULE_USAGE.to_string());
}
let target = positional[0].clone();
let task = positional[1..].join(" ");
let interval_secs = parse_interval(&every.ok_or(SCHEDULE_USAGE)?)?;
let max_runs = match runs {
None => SCHEDULE_DEFAULT_RUNS,
Some(r) => r
.parse::<u32>()
.ok()
.filter(|&n| n > 0)
.ok_or_else(|| format!("--runs must be a positive integer, got '{r}'"))?,
};
Ok(ParsedSchedule {
target,
task,
interval_secs,
max_runs,
})
}
pub(crate) fn parse_goal_args(rest: &[String]) -> Result<ParsedSchedule, String> {
let ([every, budget, runs], positional) =
collect_flags(rest, ["--every", "--budget", "--runs"], GOAL_USAGE)?;
if budget.is_some() {
return Err(BUDGET_REMOVED.to_string());
}
if positional.len() < 2 {
return Err(GOAL_USAGE.to_string());
}
let target = positional[0].clone();
let goal_text = positional[1..].join(" ");
let interval_secs = match every {
None => GOAL_DEFAULT_INTERVAL_SECS,
Some(e) => parse_interval(&e)?,
};
let max_runs = match runs {
None => SCHEDULE_DEFAULT_RUNS,
Some(r) => r
.parse::<u32>()
.ok()
.filter(|&n| n > 0)
.ok_or_else(|| format!("--runs must be a positive integer, got '{r}'"))?,
};
Ok(ParsedSchedule {
target,
task: format!("{GOAL_TASK_PREFIX}{goal_text}"),
interval_secs,
max_runs,
})
}
pub(crate) async fn schedule(caller_name: Option<&str>, rest: &[String]) -> i32 {
match parse_schedule_args(rest) {
Ok(p) => submit_job(caller_name, p, false).await,
Err(usage) => {
eprintln!("{usage}");
2
}
}
}
pub(crate) async fn goal(caller_name: Option<&str>, rest: &[String]) -> i32 {
match parse_goal_args(rest) {
Ok(p) => submit_job(caller_name, p, true).await,
Err(usage) => {
eprintln!("{usage}");
2
}
}
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) const REMIND_USAGE: &str = "usage: localharness remind [--as <me>] <text> --in <dur> [--runs <n>]\n \
dur: 60s / 15m / 1h (min 60s); --runs N repeats it (default 1 = one-shot).\n \
Fires OFF-CHAIN (free, no $LH) and web-pushes you — enable notifications in \
the browser app first to receive it.";
pub(crate) async fn remind(caller_name: Option<&str>, rest: &[String]) -> i32 {
let ([in_dur, runs_arg], positional) =
match collect_flags(rest, ["--in", "--runs"], REMIND_USAGE) {
Ok(v) => v,
Err(u) => {
eprintln!("{u}");
return 2;
}
};
if positional.is_empty() {
eprintln!("{REMIND_USAGE}");
return 2;
}
let task = positional.join(" ");
let interval_secs = match in_dur {
Some(d) => match parse_interval(&d) {
Ok(s) => s,
Err(e) => {
eprintln!("{e}");
return 2;
}
},
None => {
eprintln!("{REMIND_USAGE}");
return 2;
}
};
let runs = match runs_arg {
None => 1u32,
Some(r) => match r.parse::<u32>().ok().filter(|&n| n > 0) {
Some(n) => n,
None => {
eprintln!("--runs must be a positive integer, got '{r}'");
return 2;
}
},
};
let signer = match load_signer(caller_name) {
Ok(s) => s,
Err(code) => return code,
};
println!("scheduling a reminder in {} (×{runs}) …", fmt_interval(interval_secs));
match registry::create_offchain_job(&signer, now_unix(), "reminder", "", &task, interval_secs, runs)
.await
{
Ok(id) => {
println!("✓ reminder scheduled — job {id} (off-chain, free)");
println!(" it web-pushes you at the due time (enable notifications in the browser app to receive it).");
println!(" cancel: localharness unschedule {id}");
0
}
Err(e) => {
eprintln!("remind failed: {e}");
1
}
}
}
async fn submit_job(caller_name: Option<&str>, parsed: ParsedSchedule, goal_mode: bool) -> i32 {
let ParsedSchedule {
target,
task,
interval_secs,
max_runs,
} = parsed;
if task_is_blank(&task) {
let label = if goal_mode { "goal: goal text" } else { "schedule: task" };
eprintln!("{label} is empty — nothing to send");
return 1;
}
let signer = match load_signer(caller_name) {
Ok(s) => s,
Err(code) => return code,
};
let every = fmt_interval(interval_secs);
println!("scheduling {target} every {every}, up to {max_runs} run(s) (off-chain) …");
match registry::create_offchain_job(&signer, now_unix(), "agent", &target, &task, interval_secs, max_runs)
.await
{
Ok(id) => {
println!("✓ job {id}: {target} every {every}, ~{max_runs} runs (off-chain)");
if goal_mode {
println!(" goal loop: each fire re-feeds the goal and the agent takes ONE step;");
println!(" it self-ends when the agent declares the goal complete (finish_goal).");
} else {
println!(" runs tab-free; each fire bills your meter (~1 $LH/model call).");
}
println!(" cancel: localharness unschedule {id}");
0
}
Err(e) => {
eprintln!("schedule failed: {e}");
1
}
}
}
pub(crate) fn job_is_terminal(status: u8) -> bool {
matches!(status, 2 | 3)
}
pub(crate) fn format_job_row(id: u64, target: &str, job: ®istry::ScheduledJob, task: &str, now: u64) -> String {
let terminal = job_is_terminal(job.status);
let next = if terminal || job.next_run == 0 {
"—".to_string()
} else if job.next_run <= now {
"due now".to_string()
} else {
format!("in {}", fmt_interval(job.next_run - now))
};
let runs = if terminal { "—".to_string() } else { job.runs_left.to_string() };
let budget = if terminal { "—".to_string() } else { fmt_lh(job.budget_wei) };
let snippet = truncate_words(task, 60);
format!(
" #{id} {target} every {interval} next {next} budget {budget} runs-left {runs} [{status}]\n {snippet}",
interval = fmt_interval(job.interval),
status = job.status_label(),
)
}
fn format_offchain_row(j: &serde_json::Value, now: u64) -> String {
let id = j.get("id").and_then(|v| v.as_str()).unwrap_or("?");
let kind = j.get("kind").and_then(|v| v.as_str()).unwrap_or("?");
let task = j.get("task").and_then(|v| v.as_str()).unwrap_or("");
let interval = j.get("intervalSecs").and_then(|v| v.as_u64()).unwrap_or(0);
let runs_left = j.get("runsLeft").and_then(|v| v.as_u64()).unwrap_or(0);
let next = j.get("nextRun").and_then(|v| v.as_u64()).unwrap_or(0);
let target = j.get("target").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let next_s = if next == 0 {
"—".to_string()
} else if next <= now {
"due".to_string()
} else {
format!("in {}", fmt_interval(next - now))
};
let tgt = target.map(|t| format!(" → {t}")).unwrap_or_default();
format!(
" {id} [{kind}]{tgt} every {iv} next {next_s} runs-left {runs_left}\n {snippet}",
iv = fmt_interval(interval),
snippet = truncate_words(task, 60),
)
}
pub(crate) async fn list_jobs(caller_name: Option<&str>) -> i32 {
let signer = match load_signer(caller_name) {
Ok(s) => s,
Err(code) => return code,
};
let addr = bytes_to_hex_str(&wallet::address(&signer));
let now = now_unix();
let offchain_jobs = match registry::list_offchain_jobs(&signer, now).await {
Ok(j) => j,
Err(e) => {
eprintln!("(off-chain list unavailable: {e})");
Vec::new()
}
};
let ids = registry::jobs_of(&addr).await.unwrap_or_default();
if offchain_jobs.is_empty() && ids.is_empty() {
println!("no scheduled jobs for {addr}");
return 0;
}
if !offchain_jobs.is_empty() {
println!("{} off-chain job(s):", offchain_jobs.len());
for j in &offchain_jobs {
println!("{}", format_offchain_row(j, now));
}
}
if ids.is_empty() {
return 0;
}
println!("{} on-chain (legacy) job(s):", ids.len());
for id in ids {
let job = match registry::get_job(id).await {
Ok(j) => j,
Err(e) => {
println!(" #{id} (could not read: {e})");
continue;
}
};
let target = registry::name_of_id(job.target_id)
.await
.ok()
.filter(|n| !n.is_empty())
.unwrap_or_else(|| format!("token#{}", job.target_id));
let task = registry::task_of(id).await.unwrap_or_default();
println!("{}", format_job_row(id, &target, &job, &task, now));
if let Ok((ts, status)) = registry::last_run_of(id).await {
if ts == 0 {
println!(" last run: — (not yet run)");
} else {
let ago = now.saturating_sub(ts);
let post = match status {
0 => "active",
3 => "exhausted",
_ => "ran",
};
println!(" last run: {ago}s ago [{post}]");
}
}
}
0
}
pub(crate) async fn unschedule(caller_name: Option<&str>, job_id_arg: &str) -> i32 {
let raw = job_id_arg.trim().trim_start_matches('#');
if raw.is_empty() {
eprintln!("unschedule: missing job id");
return 2;
}
let is_onchain = raw.chars().all(|c| c.is_ascii_digit());
if !is_onchain {
let signer = match load_signer(caller_name) {
Ok(s) => s,
Err(code) => return code,
};
return match registry::cancel_offchain_job(&signer, now_unix(), raw).await {
Ok(()) => {
println!("✓ cancelled off-chain job {raw}");
0
}
Err(e) => {
eprintln!("unschedule failed: {e}");
1
}
};
}
let job_id: u64 = match raw.parse() {
Ok(n) => n,
Err(_) => {
eprintln!("unschedule: '{job_id_arg}' is not a job id");
return 2;
}
};
let signer = match load_signer(caller_name) {
Ok(pair) => pair,
Err(code) => return code,
};
match registry::cancel_job_sponsored(&signer, job_id).await
{
Ok(tx) => {
println!("✓ cancelled job #{job_id} — remaining budget refunded to your wallet");
println!(" tx: {tx}");
0
}
Err(e) => {
eprintln!("unschedule failed: {e}");
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::args;
#[test]
fn task_is_blank_catches_empty_and_bare_goal_marker() {
assert!(task_is_blank(""));
assert!(task_is_blank(" \t"));
assert!(task_is_blank(GOAL_TASK_PREFIX));
assert!(task_is_blank(" GOAL: "));
assert!(!task_is_blank("check the price"));
assert!(!task_is_blank("GOAL: win"));
}
#[test]
fn parse_interval_units_and_floor() {
assert_eq!(parse_interval("60s"), Ok(60));
assert_eq!(parse_interval("5m"), Ok(300));
assert_eq!(parse_interval("1h"), Ok(3600));
assert_eq!(parse_interval("2h"), Ok(7200));
assert_eq!(parse_interval(" 90 "), Ok(90));
assert_eq!(parse_interval("5M"), Ok(300));
assert!(parse_interval("59s").is_err());
assert!(parse_interval("0m").is_err());
assert!(parse_interval("30").is_err());
assert!(parse_interval("abc").is_err());
assert!(parse_interval("").is_err());
assert!(parse_interval("m").is_err());
assert!(parse_interval(&format!("{}h", u64::MAX)).is_err());
}
#[test]
fn fmt_interval_compact() {
assert_eq!(fmt_interval(60), "1m");
assert_eq!(fmt_interval(300), "5m");
assert_eq!(fmt_interval(3600), "1h");
assert_eq!(fmt_interval(90), "90s");
assert_eq!(fmt_interval(5400), "1h30m");
assert_eq!(fmt_interval(0), "0s");
}
#[test]
fn parse_schedule_args_full_and_defaults() {
let p = parse_schedule_args(&args(&[
"oracle", "check", "the", "price", "--every", "5m", "--runs", "50",
]))
.unwrap();
assert_eq!(p.target, "oracle");
assert_eq!(p.task, "check the price"); assert_eq!(p.interval_secs, 300);
assert_eq!(p.max_runs, 50);
let p = parse_schedule_args(&args(&["bot", "--every", "1h", "ping"])).unwrap();
assert_eq!(p.target, "bot");
assert_eq!(p.task, "ping");
assert_eq!(p.interval_secs, 3600);
assert_eq!(p.max_runs, SCHEDULE_DEFAULT_RUNS);
}
#[test]
fn parse_schedule_args_rejects_bad_input() {
assert!(parse_schedule_args(&args(&["t", "task"])).is_err());
assert!(parse_schedule_args(&args(&["t", "--every", "5m"])).is_err());
let e = parse_schedule_args(&args(&["t", "x", "--every", "5m", "--budget", "1"])).unwrap_err();
assert!(e.contains("--budget"), "budget rejection message: {e}");
assert!(parse_schedule_args(&args(&["t", "x", "--every", "5m", "--runs", "0"])).is_err());
assert!(parse_schedule_args(&args(&["t", "x", "--every", "10s"])).is_err());
}
#[test]
fn parse_goal_args_defaults_and_marker() {
let p = parse_goal_args(&args(&["claude", "get", "my", "TBA", "to", "1", "$LH"])).unwrap();
assert_eq!(p.target, "claude");
assert_eq!(p.task, "GOAL: get my TBA to 1 $LH"); assert!(p.task.starts_with(GOAL_TASK_PREFIX));
assert_eq!(p.interval_secs, GOAL_DEFAULT_INTERVAL_SECS); assert_eq!(p.max_runs, SCHEDULE_DEFAULT_RUNS); }
#[test]
fn parse_goal_args_explicit_flags() {
let p = parse_goal_args(&args(&["bot", "--every", "1h", "--runs", "10", "win"])).unwrap();
assert_eq!(p.target, "bot");
assert_eq!(p.task, "GOAL: win");
assert_eq!(p.interval_secs, 3600);
assert_eq!(p.max_runs, 10);
}
#[test]
fn parse_goal_args_rejects_bad_input() {
assert!(parse_goal_args(&args(&["t"])).is_err());
assert!(parse_goal_args(&args(&["t", "x", "--budget", "1"])).is_err());
assert!(parse_goal_args(&args(&["t", "x", "--runs", "0"])).is_err());
assert!(parse_goal_args(&args(&["t", "x", "--every", "10s"])).is_err());
}
#[test]
fn format_job_row_contains_key_fields() {
let job = registry::ScheduledJob {
owner: "0xowner".into(),
interval: 300,
status: 0,
next_run: 1_000 + 120, budget_wei: 1_000_000_000_000_000_000,
runs_left: 42,
target_id: 7,
};
let row = format_job_row(3, "oracle", &job, "check\nthe price", 1_000);
assert!(row.contains("#3"));
assert!(row.contains("oracle"));
assert!(row.contains("every 5m"));
assert!(row.contains("next in 2m"));
assert!(row.contains("runs-left 42"));
assert!(row.contains("[active]"));
assert!(row.contains("check the price")); }
#[test]
fn job_is_terminal_flags_cancelled_and_exhausted() {
assert!(!job_is_terminal(0)); assert!(!job_is_terminal(1)); assert!(job_is_terminal(2)); assert!(job_is_terminal(3)); }
#[test]
fn format_job_row_cancelled_does_not_advertise_next_due() {
let job = registry::ScheduledJob {
owner: "0x0".into(),
interval: 300,
status: 2, next_run: 100, budget_wei: 1_000_000_000_000_000_000,
runs_left: 5,
target_id: 1,
};
let row = format_job_row(7, "bot", &job, "", 5_000);
assert!(row.contains("next —"), "cancelled job must not say due now: {row}");
assert!(!row.contains("due now"));
assert!(row.contains("[cancelled]"));
assert!(row.contains("runs-left —"), "terminal runs collapse to —: {row}");
assert!(row.contains("budget —"));
}
#[test]
fn format_job_row_terminal_and_due() {
let job = registry::ScheduledJob {
owner: "0x0".into(),
interval: 60,
status: 3,
next_run: 0,
budget_wei: 0,
runs_left: 0,
target_id: 1,
};
let row = format_job_row(1, "bot", &job, "", 5_000);
assert!(row.contains("next —"));
assert!(row.contains("[exhausted]"));
let mut due = job.clone();
due.status = 0;
due.next_run = 100;
let row = format_job_row(2, "bot", &due, "", 5_000);
assert!(row.contains("next due now"));
}
}