use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::{Map, Value};
use supercode_harness::{ClaudeCronJob, ClaudeRuntimeManifest, ClaudeWakeup};
const JOB_KEY_ORDER: &[&str] = &[
"id",
"schedule",
"prompt",
"skills",
"model",
"workdir",
"context_from",
"deliver",
"failure_deliver",
"attach_to_session",
"origin",
"repeat",
"enabled",
"next_run_at",
"last_run_at",
"last_status",
"created_at",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportOutcome {
pub path: PathBuf,
pub added: Vec<String>,
pub replaced: Vec<String>,
pub untouched: usize,
}
pub fn import_claude_session(
root: &Path,
session_id: &str,
manifest: &ClaudeRuntimeManifest,
now_unix: i64,
) -> Result<ImportOutcome> {
let mut compiled = Vec::new();
for cron in &manifest.active_crons {
compiled.push(compile_cron(session_id, cron, manifest, now_unix)?);
}
for wakeup in &manifest.pending_wakeups {
compiled.push(compile_wakeup(session_id, wakeup, manifest, now_unix)?);
}
merge_into_store(root, compiled)
}
fn job_id(session_id: &str, native_id: &str) -> String {
format!("{session_id}-{native_id}")
}
fn compile_cron(
session_id: &str,
cron: &ClaudeCronJob,
manifest: &ClaudeRuntimeManifest,
now_unix: i64,
) -> Result<Map<String, Value>> {
let schedule = CronSchedule::parse(&cron.schedule)
.with_context(|| format!("Claude cron `{}` has an unusable schedule", cron.id))?;
let next = schedule
.next_after(now_unix)
.with_context(|| format!("Claude cron `{}` has no next UTC minute", cron.id))?;
let mut sched = Map::new();
sched.insert("kind".into(), "cron".into());
sched.insert("expr".into(), cron.schedule.clone().into());
sched.insert("tz".into(), "UTC".into());
let mut job = Map::new();
job.insert("id".into(), job_id(session_id, &cron.id).into());
job.insert("schedule".into(), Value::Object(sched));
job.insert("prompt".into(), cron.prompt.clone().into());
job.insert(
"repeat".into(),
if cron.recurring {
Value::Null
} else {
Value::from(1)
},
);
job.insert("next_run_at".into(), rfc3339(next).into());
job.insert("created_at".into(), value_or_null(cron.created_at.as_ref()));
Ok(finish_job(job, manifest))
}
fn compile_wakeup(
session_id: &str,
wakeup: &ClaudeWakeup,
manifest: &ClaudeRuntimeManifest,
now_unix: i64,
) -> Result<Map<String, Value>> {
let due = wakeup
.scheduled_for
.as_deref()
.and_then(rfc3339_to_unix)
.or_else(|| {
wakeup
.created_at
.as_deref()
.and_then(rfc3339_to_unix)
.and_then(|created| {
i64::try_from(wakeup.delay_seconds)
.ok()
.and_then(|delay| created.checked_add(delay))
})
})
.unwrap_or_else(|| {
now_unix.saturating_add(i64::try_from(wakeup.delay_seconds).unwrap_or(i64::MAX))
})
.max(now_unix);
let mut sched = Map::new();
sched.insert("kind".into(), "once".into());
sched.insert("run_at".into(), rfc3339(due).into());
let mut job = Map::new();
job.insert("id".into(), job_id(session_id, &wakeup.tool_use_id).into());
job.insert("schedule".into(), Value::Object(sched));
job.insert(
"prompt".into(),
value_or_null(wakeup.prompt.as_ref().or(wakeup.reason.as_ref())),
);
job.insert("repeat".into(), Value::Null);
job.insert("next_run_at".into(), rfc3339(due).into());
job.insert(
"created_at".into(),
value_or_null(wakeup.created_at.as_ref()),
);
Ok(finish_job(job, manifest))
}
fn finish_job(partial: Map<String, Value>, manifest: &ClaudeRuntimeManifest) -> Map<String, Value> {
let mut job = partial;
job.entry("skills".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
job.entry("model".to_string()).or_insert(Value::Null);
job.entry("workdir".to_string())
.or_insert_with(|| value_or_null(manifest.posture.cwd.as_ref()));
job.entry("context_from".to_string()).or_insert(Value::Null);
job.entry("deliver".to_string())
.or_insert_with(|| "local".into());
job.entry("failure_deliver".to_string())
.or_insert(Value::Null);
job.entry("attach_to_session".to_string())
.or_insert(Value::Null);
job.entry("origin".to_string()).or_insert(Value::Null);
job.entry("enabled".to_string())
.or_insert_with(|| Value::Bool(true));
job.entry("last_run_at".to_string()).or_insert(Value::Null);
job.entry("last_status".to_string()).or_insert(Value::Null);
let mut ordered = Map::new();
for key in JOB_KEY_ORDER {
if let Some(value) = job.remove(*key) {
ordered.insert((*key).to_string(), value);
}
}
for (key, value) in job {
ordered.insert(key, value);
}
ordered
}
fn merge_into_store(root: &Path, compiled: Vec<Map<String, Value>>) -> Result<ImportOutcome> {
let path = root.join("cron/jobs.json");
let mut existing: Vec<Value> = if path.exists() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
match serde_json::from_str::<Value>(&text)
.with_context(|| format!("{} is not JSON", path.display()))?
{
Value::Array(items) => items,
other => anyhow::bail!(
"{} holds {} where the orchestrator expects an array of jobs",
path.display(),
match other {
Value::Object(_) => "an object",
Value::Null => "null",
_ => "a scalar",
}
),
}
} else {
Vec::new()
};
let before = existing.len();
let mut added = Vec::new();
let mut replaced = Vec::new();
for job in compiled {
let id = job
.get("id")
.and_then(Value::as_str)
.expect("every compiled job has an id")
.to_string();
match existing
.iter()
.position(|item| item.get("id").and_then(Value::as_str) == Some(id.as_str()))
{
Some(index) => {
existing[index] = Value::Object(job);
replaced.push(id);
}
None => {
existing.push(Value::Object(job));
added.push(id);
}
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let text = format!("{}\n", serde_json::to_string_pretty(&existing)?);
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
Ok(ImportOutcome {
path,
untouched: before - replaced.len(),
added,
replaced,
})
}
fn value_or_null(text: Option<&String>) -> Value {
text.map_or(Value::Null, |text| Value::String(text.clone()))
}
#[derive(Debug, Clone)]
struct CronField {
allowed: Vec<bool>,
}
impl CronField {
fn parse(text: &str, min: u32, max: u32, dow: bool) -> Result<Self> {
if text.is_empty() {
anyhow::bail!("empty cron field");
}
let mut allowed = vec![false; (max - min + 1) as usize];
for item in text.split(',') {
if item.is_empty() {
anyhow::bail!("invalid empty item in cron field `{text}`");
}
let mut parts = item.split('/');
let base = parts.next().unwrap_or_default();
let step = parts
.next()
.map(str::parse::<u32>)
.transpose()
.map_err(|_| anyhow::anyhow!("invalid cron step in `{item}`"))?
.unwrap_or(1);
if parts.next().is_some() || step == 0 {
anyhow::bail!("invalid cron step in `{item}`");
}
let (start, end) = if base == "*" {
(min, max)
} else if let Some((start, end)) = base.split_once('-') {
(
parse_cron_num(start, min, max, dow)?,
parse_cron_num(end, min, max, dow)?,
)
} else {
let start = parse_cron_num(base, min, max, dow)?;
(start, if item.contains('/') { max } else { start })
};
if start > end {
anyhow::bail!("descending cron range `{base}` is unsupported");
}
let mut value = start;
while value <= end {
let normalized = if dow && value == 7 { 0 } else { value };
allowed[(normalized - min) as usize] = true;
let Some(next) = value.checked_add(step) else {
break;
};
value = next;
}
}
if !allowed.iter().any(|allowed| *allowed) {
anyhow::bail!("cron field `{text}` matches no values");
}
Ok(Self { allowed })
}
fn contains(&self, value: u32, min: u32) -> bool {
self.allowed
.get((value - min) as usize)
.copied()
.unwrap_or(false)
}
fn unrestricted(&self) -> bool {
self.allowed.iter().all(|allowed| *allowed)
}
}
fn parse_cron_num(text: &str, min: u32, max: u32, dow: bool) -> Result<u32> {
let value = text
.parse::<u32>()
.map_err(|_| anyhow::anyhow!("invalid cron number `{text}`"))?;
let upper = if dow { 7 } else { max };
if value < min || value > upper {
anyhow::bail!("cron number `{value}` is outside {min}..={upper}");
}
Ok(value)
}
#[derive(Debug, Clone)]
struct CronSchedule {
minute: CronField,
hour: CronField,
day_of_month: CronField,
month: CronField,
day_of_week: CronField,
}
impl CronSchedule {
fn parse(schedule: &str) -> Result<Self> {
let fields: Vec<&str> = schedule.split_whitespace().collect();
if fields.len() != 5 {
anyhow::bail!("invalid Claude cron `{schedule}`: expected exactly 5 fields");
}
Ok(Self {
minute: CronField::parse(fields[0], 0, 59, false)?,
hour: CronField::parse(fields[1], 0, 23, false)?,
day_of_month: CronField::parse(fields[2], 1, 31, false)?,
month: CronField::parse(fields[3], 1, 12, false)?,
day_of_week: CronField::parse(fields[4], 0, 6, true)?,
})
}
fn next_after(&self, after_unix: i64) -> Result<i64> {
let start_minute = after_unix
.div_euclid(60)
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("cron search overflows Unix time"))?;
const SEARCH_MINUTES: i64 = 8 * 366 * 24 * 60;
for delta in 0..SEARCH_MINUTES {
let unix = start_minute
.checked_add(delta)
.and_then(|minute| minute.checked_mul(60))
.ok_or_else(|| anyhow::anyhow!("cron search overflows Unix time"))?;
if self.matches(unix) {
return Ok(unix);
}
}
anyhow::bail!("cron expression has no matching UTC minute within eight years")
}
fn matches(&self, unix: i64) -> bool {
let days = unix.div_euclid(86_400);
let seconds = unix.rem_euclid(86_400);
let (_, month, day) = civil_from_days(days);
let hour = (seconds / 3600) as u32;
let minute = ((seconds % 3600) / 60) as u32;
let dow = (days + 4).rem_euclid(7) as u32;
let dom_match = self.day_of_month.contains(day, 1);
let dow_match = self.day_of_week.contains(dow, 0);
let day_match = match (
self.day_of_month.unrestricted(),
self.day_of_week.unrestricted(),
) {
(true, true) => true,
(true, false) => dow_match,
(false, true) => dom_match,
(false, false) => dom_match || dow_match,
};
self.minute.contains(minute, 0)
&& self.hour.contains(hour, 0)
&& self.month.contains(month, 1)
&& day_match
}
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let mut year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
year += (month <= 2) as i64;
(year, month as u32, day as u32)
}
fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
let year = year - i64::from(month <= 2);
let era = if year >= 0 { year } else { year - 399 }.div_euclid(400);
let yoe = year - era * 400;
let mp = if month > 2 { month - 3 } else { month + 9 } as i64;
let doy = (153 * mp + 2) / 5 + i64::from(day) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn rfc3339(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let seconds = unix.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
seconds / 3600,
(seconds % 3600) / 60,
seconds % 60
)
}
fn rfc3339_to_unix(text: &str) -> Option<i64> {
let bytes = text.as_bytes();
if bytes.len() < 19 || bytes[4] != b'-' || bytes[7] != b'-' {
return None;
}
if !matches!(bytes[10], b'T' | b't' | b' ') || bytes[13] != b':' || bytes[16] != b':' {
return None;
}
let tail = &text[19..];
let zone_ok = tail.is_empty()
|| tail.eq_ignore_ascii_case("z")
|| tail.strip_prefix('.').is_some_and(|rest| {
rest.trim_end_matches(|c: char| c == 'Z' || c == 'z').len() < rest.len()
|| rest.chars().all(|c| c.is_ascii_digit())
});
if !zone_ok {
return None;
}
let year: i64 = text[0..4].parse().ok()?;
let month: u32 = text[5..7].parse().ok()?;
let day: u32 = text[8..10].parse().ok()?;
let hour: i64 = text[11..13].parse().ok()?;
let minute: i64 = text[14..16].parse().ok()?;
let second: i64 = text[17..19].parse().ok()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
}