use wasm_bindgen::prelude::*;
use crate::app::{dom, templates};
const SCHEDULE_MIN_INTERVAL_SECS: u64 = 60;
const SCHEDULE_DEFAULT_RUNS: u32 = 100;
fn parse_schedule_interval(raw: &str) -> Option<u64> {
let s = raw.trim().to_ascii_lowercase();
if s.is_empty() {
return None;
}
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 secs = num_part.parse::<u64>().ok()?.checked_mul(mult)?;
(secs >= SCHEDULE_MIN_INTERVAL_SECS).then_some(secs)
}
fn fmt_schedule_interval(secs: u64) -> String {
if secs == 0 {
return "0s".to_string();
}
if secs % 3600 == 0 {
return format!("{}h", secs / 3600);
}
if secs >= 3600 {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let rest_s = secs % 60;
if rest_s == 0 {
return format!("{h}h{m}m");
}
}
if secs % 60 == 0 {
return format!("{}m", secs / 60);
}
format!("{secs}s")
}
pub(super) fn schedule_job_pressed() {
let target = dom::input_by_id("schedule-target")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let task = dom::input_by_id("schedule-task")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let interval_raw = dom::input_by_id("schedule-interval")
.map(|i| i.value())
.unwrap_or_default();
let budget_raw = dom::input_by_id("schedule-budget")
.map(|i| i.value())
.unwrap_or_default();
let runs_raw = dom::input_by_id("schedule-runs")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if target.is_empty() || task.is_empty() {
return;
}
let Some(interval_secs) = parse_schedule_interval(&interval_raw) else {
return;
};
let Some(budget_wei) = crate::encoding::parse_token_amount(&budget_raw) else {
return;
};
if budget_wei == 0 {
return;
}
let max_runs = if runs_raw.is_empty() {
SCHEDULE_DEFAULT_RUNS
} else {
match runs_raw.parse::<u32>() {
Ok(n) if n > 0 => n,
_ => return,
}
};
dom::swap_inner(
"schedule-result",
"<span style=\"color:var(--muted)\">scheduling…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match submit_schedule_job(&target, &task, interval_secs, budget_wei, max_runs).await {
Ok(new_id) => {
dom::swap_inner(
"schedule-result",
&templates::schedule_result_panel(new_id).into_string(),
);
refresh_jobs_list().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("schedule job: {e}")));
dom::swap_inner(
"schedule-result",
&dom::msg_span(
dom::Msg::Error,
"job couldn't be scheduled (need $LH to escrow)",
),
);
}
}
});
}
async fn submit_schedule_job(
target: &str,
task: &str,
interval_secs: u64,
budget_wei: u128,
max_runs: u32,
) -> Result<u64, String> {
super::sponsor_rate_guard()?;
let target_id = crate::app::registry::id_of_name(target).await?;
if target_id == 0 {
return Err("target agent not found".to_string());
}
let (signer, addr) = crate::app::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let from_hex = crate::encoding::bytes_to_hex_str(&addr);
let bridge_wei = crate::app::chat::escrow_bridge_wei(&from_hex, budget_wei).await?;
let fee_payer = crate::app::sponsor::signer()?;
crate::app::registry::schedule_job_sponsored_bridged(
&signer,
&fee_payer,
target_id,
task.as_bytes(),
interval_secs,
budget_wei,
max_runs,
crate::app::registry::ALPHA_USD_ADDRESS,
bridge_wei,
)
.await?;
super::refresh_credits_pill().await;
let new_id = match crate::app::chat::credit_address_existing().await {
Some(addr) => crate::app::registry::jobs_of(&addr)
.await
.ok()
.and_then(|ids| ids.last().copied())
.unwrap_or(0),
None => 0,
};
Ok(new_id)
}
pub(super) fn cancel_job_pressed(job_id_raw: String) {
let Ok(job_id) = job_id_raw.trim().parse::<u64>() else {
return;
};
dom::swap_inner(
"schedule-result",
"<span style=\"color:var(--muted)\">cancelling…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
super::sponsor_rate_guard()?;
let (signer, _) = crate::app::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = crate::app::sponsor::signer()?;
crate::app::registry::cancel_job_sponsored(
&signer,
&fee_payer,
job_id,
crate::app::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
"schedule-result",
&dom::msg_span(dom::Msg::Muted, "cancelled — remaining $LH refunded"),
);
super::refresh_credits_pill().await;
refresh_jobs_list().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("cancel job: {e}")));
dom::swap_inner(
"schedule-result",
&dom::msg_span(dom::Msg::Error, "couldn't cancel that job"),
);
}
}
});
}
pub(crate) async fn refresh_jobs_list() {
if dom::by_id("schedule-jobs").is_none() {
return;
}
let Some(addr) = crate::app::chat::credit_address_existing().await else {
return;
};
let ids = match crate::app::registry::jobs_of(&addr).await {
Ok(v) => v,
Err(_) => {
dom::swap_inner("schedule-jobs", "");
return;
}
};
if ids.is_empty() {
dom::swap_inner(
"schedule-jobs",
&dom::msg_span(dom::Msg::Muted, "no scheduled jobs"),
);
return;
}
let now = (js_sys::Date::now() / 1000.0) as u64;
let mut rows: Vec<maud::Markup> = Vec::new();
for id in ids {
let Ok(job) = crate::app::registry::get_job(id).await else {
continue;
};
let target = crate::app::registry::name_of_id(job.target_id)
.await
.ok()
.filter(|n| !n.is_empty())
.unwrap_or_else(|| format!("token#{}", job.target_id));
let budget_whole = job.budget_wei / 1_000_000_000_000_000_000u128;
let budget_cents =
(job.budget_wei % 1_000_000_000_000_000_000u128) / 10_000_000_000_000_000u128;
let cadence = fmt_schedule_interval(job.interval);
let status = job.status_label();
let next = if job.next_run == 0 {
"—".to_string()
} else if job.next_run <= now {
"due".to_string()
} else {
let delta = job.next_run - now;
format!("in {}", fmt_schedule_interval(delta.max(1)))
};
let cancellable = matches!(job.status, 0 | 1);
rows.push(maud::html! {
div style="border-top:1px solid var(--border);padding:6px 0;font-size:11px;color:var(--fg)" {
div style="display:flex;align-items:center;gap:8px" {
code style="color:var(--muted)" { "#" (id) }
span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" { (target) }
span style="color:var(--muted)" { (status) }
@if cancellable {
button type="button" data-action="cancel-job" data-arg=(id.to_string())
.ghost style="padding:0 6px" { "cancel" }
}
}
div style="display:flex;flex-wrap:wrap;gap:10px;color:var(--muted);margin-top:2px" {
span { "every " (cadence) }
span { "next " (next) }
span { (budget_whole) "." (format!("{budget_cents:02}")) " LH" }
span { (job.runs_left) " runs left" }
}
}
});
}
let html = maud::html! {
div style="margin-top:8px" { @for r in &rows { (r) } }
}
.into_string();
dom::swap_inner("schedule-jobs", &html);
}