use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::{Map, Value};
use supercode::{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)
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_with(
crons: Vec<ClaudeCronJob>,
wakeups: Vec<ClaudeWakeup>,
) -> ClaudeRuntimeManifest {
let mut manifest: ClaudeRuntimeManifest = serde_json::from_value(serde_json::json!({
"schema_version": 1,
"posture": {
"permission_mode": null, "last_prompt_leaf_uuid": null, "last_prompt": null,
"timestamp": null, "entrypoint": "cli", "user_type": "external",
"version": "2.1.197", "cwd": "/workspace/project"
},
"active_crons": [],
"pending_wakeups": [],
"queue": {"enqueued": 0, "dequeued": 0, "removed": 0, "pending": []},
"background_children": [],
"reported_pending_background_children": 0,
"residue": []
}))
.unwrap();
manifest.active_crons = crons;
manifest.pending_wakeups = wakeups;
manifest
}
fn cron(id: &str, schedule: &str, recurring: bool) -> ClaudeCronJob {
ClaudeCronJob {
id: id.into(),
tool_use_id: format!("toolu_{id}"),
schedule: schedule.into(),
recurring,
durable_requested: false,
prompt: "Check the release branch.".into(),
created_at: Some("2026-07-14T10:00:02.000Z".into()),
expires_after_seconds: None,
creation_result: String::new(),
}
}
fn wakeup(id: &str, scheduled_for: Option<&str>) -> ClaudeWakeup {
ClaudeWakeup {
tool_use_id: id.into(),
delay_seconds: 300,
reason: Some("recheck".into()),
prompt: Some("Re-read the release branch.".into()),
created_at: Some("2026-07-14T10:00:04.000Z".into()),
scheduled_for: scheduled_for.map(str::to_string),
creation_result: String::new(),
}
}
fn temp(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"orc11-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
const NOW: i64 = 1_788_000_000;
#[test]
fn rfc3339_round_trips_through_both_directions() {
for unix in [0, 1_788_000_000, 1_784_023_202, -86_400] {
assert_eq!(rfc3339_to_unix(&rfc3339(unix)), Some(unix), "{unix}");
}
assert_eq!(
rfc3339_to_unix("2026-07-14T10:00:02.000Z"),
Some(1_784_023_202)
);
assert_eq!(rfc3339_to_unix("2026-07-14T10:00:02"), Some(1_784_023_202));
assert_eq!(rfc3339_to_unix("2026-07-14T10:00:02+02:00"), None);
assert_eq!(rfc3339_to_unix("not a timestamp"), None);
}
#[test]
fn the_next_utc_minute_is_strictly_after_now_and_matches_the_expression() {
let at = CronSchedule::parse("*/10 * * * *")
.unwrap()
.next_after(1_788_000_000)
.unwrap();
assert!(at > 1_788_000_000);
assert_eq!(at % 600, 0);
assert!(CronSchedule::parse("*/10 * * * *").unwrap().matches(at));
let monthly = CronSchedule::parse("0 3 1 * *")
.unwrap()
.next_after(NOW)
.unwrap();
assert!(
rfc3339(monthly).ends_with("-01T03:00:00Z"),
"{}",
rfc3339(monthly)
);
assert!(CronSchedule::parse("61 * * * *").is_err());
assert!(CronSchedule::parse("* * * *").is_err());
}
#[test]
fn a_cron_and_a_wakeup_compile_to_the_orchestrators_own_key_order() {
let root = temp("keyorder");
let manifest = manifest_with(
vec![cron("release-watch", "*/10 * * * *", true)],
vec![wakeup("toolu_wake", Some("2026-07-14T10:05:04.000Z"))],
);
let outcome = import_claude_session(&root, "sess-1", &manifest, NOW).unwrap();
assert_eq!(
outcome.added,
vec!["sess-1-release-watch", "sess-1-toolu_wake"]
);
assert!(outcome.replaced.is_empty());
let text = std::fs::read_to_string(&outcome.path).unwrap();
assert!(text.ends_with("]\n"), "the orchestrator's trailing newline");
let jobs: Vec<Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2);
let keys: Vec<&str> = jobs[0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(keys, JOB_KEY_ORDER);
assert_eq!(jobs[0]["schedule"]["kind"], "cron");
assert_eq!(jobs[0]["schedule"]["expr"], "*/10 * * * *");
assert_eq!(jobs[0]["schedule"]["tz"], "UTC");
assert_eq!(jobs[0]["prompt"], "Check the release branch.");
assert_eq!(jobs[0]["workdir"], "/workspace/project");
assert_eq!(jobs[0]["deliver"], "local");
assert_eq!(jobs[0]["enabled"], true);
assert_eq!(jobs[0]["repeat"], Value::Null);
assert_eq!(jobs[0]["created_at"], "2026-07-14T10:00:02.000Z");
let next = jobs[0]["next_run_at"].as_str().unwrap();
assert!(rfc3339_to_unix(next).unwrap() > NOW, "{next}");
assert_eq!(jobs[1]["schedule"]["kind"], "once");
assert_eq!(jobs[1]["schedule"]["run_at"], rfc3339(NOW));
assert_eq!(jobs[1]["next_run_at"], rfc3339(NOW));
assert_eq!(jobs[1]["prompt"], "Re-read the release branch.");
}
#[test]
fn a_non_recurring_cron_carries_one_remaining_run() {
let root = temp("once-cron");
let manifest = manifest_with(vec![cron("one-shot", "5 4 * * *", false)], vec![]);
import_claude_session(&root, "sess-2", &manifest, NOW).unwrap();
let jobs: Vec<Value> =
serde_json::from_str(&std::fs::read_to_string(root.join("cron/jobs.json")).unwrap())
.unwrap();
assert_eq!(jobs[0]["repeat"], 1);
}
#[test]
fn a_wakeup_without_a_recorded_instant_falls_back_to_creation_plus_delay() {
let root = temp("wake-fallback");
let mut w = wakeup("toolu_bare", None);
w.created_at = Some(rfc3339(NOW + 1_000));
let manifest = manifest_with(vec![], vec![w]);
import_claude_session(&root, "sess-3", &manifest, NOW).unwrap();
let jobs: Vec<Value> =
serde_json::from_str(&std::fs::read_to_string(root.join("cron/jobs.json")).unwrap())
.unwrap();
assert_eq!(jobs[0]["schedule"]["run_at"], rfc3339(NOW + 1_300));
}
#[test]
fn other_jobs_keep_their_bytes_and_a_reimport_replaces_only_its_own() {
let root = temp("merge");
std::fs::create_dir_all(root.join("cron")).unwrap();
let foreign = "[\n {\n \"id\": \"digest-15m\",\n \"schedule\": {\n \"kind\": \"interval\",\n \"minutes\": 15\n },\n \"prompt\": \"digest\",\n \"script\": \"/opt/digest.sh\",\n \"enabled\": true\n }\n]\n";
std::fs::write(root.join("cron/jobs.json"), foreign).unwrap();
let manifest = manifest_with(vec![cron("release-watch", "*/10 * * * *", true)], vec![]);
let first = import_claude_session(&root, "sess-1", &manifest, NOW).unwrap();
assert_eq!(first.added, vec!["sess-1-release-watch"]);
assert_eq!(first.untouched, 1);
let jobs: Vec<Value> =
serde_json::from_str(&std::fs::read_to_string(root.join("cron/jobs.json")).unwrap())
.unwrap();
assert_eq!(jobs.len(), 2);
assert_eq!(
serde_json::to_string_pretty(&jobs[0]).unwrap(),
serde_json::to_string_pretty(&serde_json::from_str::<Vec<Value>>(foreign).unwrap()[0])
.unwrap()
);
let second = import_claude_session(&root, "sess-1", &manifest, NOW + 60).unwrap();
assert!(second.added.is_empty());
assert_eq!(second.replaced, vec!["sess-1-release-watch"]);
assert_eq!(second.untouched, 1);
let jobs: Vec<Value> =
serde_json::from_str(&std::fs::read_to_string(root.join("cron/jobs.json")).unwrap())
.unwrap();
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0]["id"], "digest-15m");
assert_eq!(jobs[1]["id"], "sess-1-release-watch");
let third = import_claude_session(&root, "sess-9", &manifest, NOW).unwrap();
assert_eq!(third.added, vec!["sess-9-release-watch"]);
assert_eq!(third.untouched, 2);
}
#[test]
fn a_jobs_file_that_is_not_an_array_is_refused_by_name() {
let root = temp("bad-store");
std::fs::create_dir_all(root.join("cron")).unwrap();
std::fs::write(root.join("cron/jobs.json"), "{\"jobs\": []}").unwrap();
let manifest = manifest_with(vec![cron("c", "* * * * *", true)], vec![]);
let error = import_claude_session(&root, "s", &manifest, NOW)
.unwrap_err()
.to_string();
assert!(error.contains("cron/jobs.json"), "{error}");
assert!(error.contains("an object"), "{error}");
}
}