use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{json, Value};
use super::link::LinkSession;
use super::{Daemon, Inner};
use crate::remote::state::{Task, TaskStatus};
use crate::remote::util::{hex, random_bytes};
const MAX_OPEN_TASKS: usize = 10_000;
const FINISHED_RETENTION_SECS: u64 = 86_400;
const MAX_LEASE_TTL_SECS: u64 = 86_400;
const DEFAULT_LEASE_TTL_SECS: u64 = 120;
#[derive(Debug, Clone)]
pub struct TaskSpec {
pub method: String,
pub params: Value,
pub target: Option<String>,
pub lease_ttl_secs: u64,
}
impl TaskSpec {
pub fn new(method: impl Into<String>, params: Value) -> Self {
Self {
method: method.into(),
params,
target: None,
lease_ttl_secs: DEFAULT_LEASE_TTL_SECS,
}
}
pub fn target(mut self, device_id: impl Into<String>) -> Self {
self.target = Some(device_id.into());
self
}
pub fn lease_ttl(mut self, secs: u64) -> Self {
self.lease_ttl_secs = secs;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TaskInfo {
pub id: String,
pub seq: u64,
pub status: TaskStatus,
pub owner: Option<String>,
pub epoch: u64,
pub expires: u64,
pub acked: bool,
pub outcome: Option<Value>,
}
fn info(t: &Task) -> TaskInfo {
TaskInfo {
id: t.id.clone(),
seq: t.seq,
status: t.status,
owner: t.owner.clone(),
epoch: t.epoch,
expires: t.expires,
acked: t.acked,
outcome: t.outcome.clone(),
}
}
fn task_frame(t: &Task) -> Value {
json!({
"t": "task",
"id": t.id,
"seq": t.seq,
"epoch": t.epoch,
"method": t.method,
"params": t.params,
"lease_ttl": t.lease_ttl,
})
}
type Lapsed = (String, String, u64);
impl Daemon {
pub fn enqueue_task(&self, spec: TaskSpec) -> std::io::Result<String> {
let bad = |m: &str| std::io::Error::new(std::io::ErrorKind::InvalidInput, m.to_string());
if spec.method.is_empty()
|| spec.method.len() > 128
|| !spec.method.bytes().all(|b| b.is_ascii_graphic())
{
return Err(bad("task method must be 1-128 printable ASCII characters"));
}
let ttl = spec.lease_ttl_secs.clamp(1, MAX_LEASE_TTL_SECS);
let now = self.clock.now_secs();
let mut g = self.lock();
if let Some(target) = &spec.target {
let ok = g
.state
.devices
.get(target)
.is_some_and(|d| !d.revoked && d.scopes.iter().any(|s| s == "link"));
if !ok {
return Err(bad("target is not an enrolled, unrevoked link device"));
}
}
let open = g
.state
.tasks
.values()
.filter(|t| matches!(t.status, TaskStatus::Queued | TaskStatus::Leased))
.count();
if open >= MAX_OPEN_TASKS {
return Err(std::io::Error::other("task queue is full"));
}
g.state.task_seq += 1;
let seq = g.state.task_seq;
let id = format!("tsk_{}", hex(&random_bytes::<8>()));
g.state.tasks.insert(
id.clone(),
Task {
id: id.clone(),
seq,
target: spec.target.clone(),
method: spec.method,
params: spec.params,
lease_ttl: ttl,
status: TaskStatus::Queued,
owner: None,
epoch: 0,
expires: 0,
acked: false,
created_at: now,
outcome: None,
},
);
if let Err(e) = self.save(&mut g) {
g.state.tasks.remove(&id);
return Err(e);
}
drop(g);
self.emit(
"TASK_QUEUED",
json!({ "taskId": id, "seq": seq, "target": spec.target }),
);
self.dispatch();
Ok(id)
}
pub fn task(&self, id: &str) -> Option<TaskInfo> {
self.lock().state.tasks.get(id).map(info)
}
pub fn tasks(&self) -> Vec<TaskInfo> {
let g = self.lock();
let mut v: Vec<TaskInfo> = g.state.tasks.values().map(info).collect();
v.sort_by_key(|t| t.seq);
v
}
pub fn tasks_sweep(&self) -> std::io::Result<usize> {
let now = self.clock.now_secs();
let mut g = self.lock();
let lapsed = self.sweep_locked(&mut g, now);
self.flush(&mut g)?;
drop(g);
let n = lapsed.len();
self.announce_lapsed(lapsed);
self.dispatch();
Ok(n)
}
pub(super) fn announce_lapsed(&self, lapsed: Vec<Lapsed>) {
for (task, device, epoch) in lapsed {
self.emit(
"TASK_LEASE_LOST",
json!({ "taskId": task, "deviceId": device, "epoch": epoch, "reason": "expired" }),
);
}
}
pub(super) fn sweep_locked(&self, g: &mut Inner, now: u64) -> Vec<Lapsed> {
let mut lapsed = Vec::new();
for t in g.state.tasks.values_mut() {
if t.status == TaskStatus::Leased && t.expires <= now {
lapsed.push((t.id.clone(), t.owner.clone().unwrap_or_default(), t.epoch));
t.status = TaskStatus::Queued;
t.owner = None;
t.acked = false;
}
}
let before = g.state.tasks.len();
g.state.tasks.retain(|_, t| {
matches!(t.status, TaskStatus::Queued | TaskStatus::Leased)
|| t.expires + FINISHED_RETENTION_SECS > now
});
if !lapsed.is_empty() || g.state.tasks.len() != before {
g.dirty = true;
}
lapsed
}
pub(super) fn dispatch(&self) {
let sessions = self.ready_links();
let now = self.clock.now_secs();
let mut g = self.lock();
let lapsed = self.sweep_locked(&mut g, now);
let mut loads: BTreeMap<String, usize> = BTreeMap::new();
for t in g.state.tasks.values() {
if t.status == TaskStatus::Leased {
if let Some(o) = &t.owner {
*loads.entry(o.clone()).or_default() += 1;
}
}
}
let mut queued: Vec<(u64, String)> = g
.state
.tasks
.values()
.filter(|t| t.status == TaskStatus::Queued)
.map(|t| (t.seq, t.id.clone()))
.collect();
queued.sort();
let mut sends: Vec<(Arc<LinkSession>, Value, String, u64)> = Vec::new();
if !sessions.is_empty() {
for (_, id) in queued {
let Some(t) = g.state.tasks.get_mut(&id) else {
continue;
};
let chosen = match &t.target {
Some(d) => sessions.iter().find(|s| s.device() == d.as_str()),
None => sessions.iter().min_by_key(|s| {
(
loads.get(s.device()).copied().unwrap_or(0),
s.device().to_string(),
)
}),
};
let Some(s) = chosen else {
continue;
};
t.epoch += 1;
t.status = TaskStatus::Leased;
t.owner = Some(s.device().to_string());
t.expires = now + t.lease_ttl;
t.acked = false;
*loads.entry(s.device().to_string()).or_default() += 1;
sends.push((s.clone(), task_frame(t), t.id.clone(), t.epoch));
}
}
if !sends.is_empty() {
g.dirty = true;
}
if let Err(e) = self.flush(&mut g) {
drop(g);
self.emit(
"STORAGE_ERROR",
json!({ "kind": format!("{:?}", e.kind()) }),
);
self.announce_lapsed(lapsed);
return;
}
drop(g);
self.announce_lapsed(lapsed);
for (s, frame, id, epoch) in sends {
self.emit(
"TASK_LEASED",
json!({ "taskId": id, "deviceId": s.device(), "epoch": epoch }),
);
s.send(frame);
if !self.link_is_current(&s) {
if let Some(cur) = self
.ready_links()
.into_iter()
.find(|c| c.device() == s.device())
{
self.resume_tasks(&cur);
}
}
}
}
pub(super) fn resume_tasks(&self, s: &Arc<LinkSession>) {
let now = self.clock.now_secs();
let mut frames = Vec::new();
let lapsed;
{
let mut g = self.lock();
lapsed = self.sweep_locked(&mut g, now);
for t in g.state.tasks.values_mut() {
if t.status == TaskStatus::Leased && t.owner.as_deref() == Some(s.device()) {
t.expires = now + t.lease_ttl;
if !t.acked {
frames.push(task_frame(t));
}
}
}
let _ = self.flush(&mut g);
}
self.announce_lapsed(lapsed);
frames.sort_by_key(|f| f["seq"].as_u64().unwrap_or(0));
for f in frames {
s.send(f);
}
}
pub(super) fn touch_leases(&self, device: &str) -> bool {
let now = self.clock.now_secs();
let mut g = self.lock();
if g.state.devices.get(device).is_none_or(|d| d.revoked) {
return false;
}
for t in g.state.tasks.values_mut() {
if t.status == TaskStatus::Leased && t.owner.as_deref() == Some(device) {
t.expires = now + t.lease_ttl;
}
}
true
}
pub(super) fn ack_task(
&self,
s: &Arc<LinkSession>,
id: &str,
epoch: u64,
) -> Result<(), &'static str> {
if !self.link_is_current(s) {
return Err("superseded");
}
let mut g = self.lock();
match g.state.tasks.get_mut(id) {
Some(t)
if t.status == TaskStatus::Leased
&& t.owner.as_deref() == Some(s.device())
&& t.epoch == epoch =>
{
t.acked = true;
Ok(())
}
Some(_) => Err("lease_lost"),
None => Err("unknown_task"),
}
}
pub(super) fn complete_task(
&self,
s: &Arc<LinkSession>,
id: &str,
epoch: u64,
ok: bool,
outcome: Value,
) -> (&'static str, Option<&'static str>) {
let now = self.clock.now_secs();
if !self.link_is_current(s) {
self.emit(
"TASK_LEASE_LOST",
json!({ "taskId": id, "deviceId": s.device(), "epoch": epoch,
"reason": "superseded_connection" }),
);
return ("refused", Some("superseded"));
}
let mut g = self.lock();
let lapsed = self.sweep_locked(&mut g, now);
let mut prev: Option<Task> = None;
let verdict: Result<&'static str, &'static str> = match g.state.tasks.get_mut(id) {
None => Err("unknown_task"),
Some(t) => {
let mine = t.owner.as_deref() == Some(s.device()) && t.epoch == epoch;
match t.status {
TaskStatus::Leased if mine => {
prev = Some(t.clone());
t.status = if ok {
TaskStatus::Done
} else {
TaskStatus::Failed
};
t.outcome = Some(outcome);
t.expires = now;
t.acked = true;
Ok("accepted")
}
TaskStatus::Done | TaskStatus::Failed if mine => Ok("duplicate"),
_ => Err("lease_lost"),
}
}
};
let saved = match verdict {
Ok("accepted") => self.save(&mut g),
_ => self.flush(&mut g),
};
if saved.is_err() {
if let Some(p) = prev {
g.state.tasks.insert(p.id.clone(), p);
}
}
drop(g);
self.announce_lapsed(lapsed);
if let Err(e) = saved {
self.emit(
"STORAGE_ERROR",
json!({ "kind": format!("{:?}", e.kind()) }),
);
return ("refused", Some("storage_error"));
}
match verdict {
Ok("accepted") => {
self.emit(
if ok { "TASK_DONE" } else { "TASK_FAILED" },
json!({ "taskId": id, "deviceId": s.device(), "epoch": epoch }),
);
("accepted", None)
}
Ok(other) => (other, None),
Err(code) => {
if code == "lease_lost" {
self.emit(
"TASK_LEASE_LOST",
json!({ "taskId": id, "deviceId": s.device(), "epoch": epoch,
"reason": "stale_result" }),
);
}
("refused", Some(code))
}
}
}
}