car-ffi-common 0.33.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! JSON wrappers for car-scheduler functions.

use car_scheduler::os_schedule::LABEL_PREFIX;
use car_scheduler::{CommandSpec, OsScheduleSpec, Task, TaskStatus, TaskStore, TaskTrigger};
use serde_json::json;

/// The default on-disk task store (`~/.car/tasks/`) — the authoritative record
/// of which tasks exist, which reconciliation reaps OS schedules against.
fn task_store() -> TaskStore {
    TaskStore::new(&TaskStore::default_path())
}

/// Parse a task + args and build its [`OsScheduleSpec`]. Returns the parsed task
/// too so the install path can persist it.
fn os_spec(
    task_json: &str,
    program: &str,
    args_json: &str,
) -> Result<(Task, OsScheduleSpec), String> {
    let task: Task =
        serde_json::from_str(task_json).map_err(|e| format!("invalid task JSON: {e}"))?;
    let args: Vec<String> = serde_json::from_str(args_json)
        .map_err(|e| format!("invalid args JSON (expected a string array): {e}"))?;
    let spec = OsScheduleSpec::from_task(&task, program, args).map_err(|e| e.to_string())?;
    Ok((task, spec))
}

/// Render — without installing — what an OS-level schedule for `task` would look
/// like on both backends. Returns `{ label, launchd_plist, launchd_error,
/// crontab_line, crontab_error }` (each rendering is `null` when that backend
/// can't express the schedule, with the reason in the matching `*_error`).
/// Stateless preview; performs no I/O.
pub fn render_os_schedule(
    task_json: &str,
    program: &str,
    args_json: &str,
) -> Result<String, String> {
    let (_task, spec) = os_spec(task_json, program, args_json)?;
    let (plist, plist_err) = match spec.render_launchd_plist() {
        Ok(s) => (Some(s), None),
        Err(e) => (None, Some(e.to_string())),
    };
    let (cron, cron_err) = match spec.render_crontab_line() {
        Ok(s) => (Some(s), None),
        Err(e) => (None, Some(e.to_string())),
    };
    Ok(json!({
        "label": spec.label,
        "launchd_plist": plist,
        "launchd_error": plist_err,
        "crontab_line": cron,
        "crontab_error": cron_err,
    })
    .to_string())
}

/// Install a durable OS-level schedule for `task` so it fires even when the CAR
/// daemon is down (launchd on macOS, crontab on Linux). `program` + `args` are
/// the command the OS runs to execute this task once. Idempotent — replaces any
/// prior schedule for the same task. Returns the [`InstalledSchedule`] JSON.
///
/// The task is first persisted to `~/.car/tasks/` so the store stays the
/// authoritative set of scheduled tasks: a later [`reconcile_os_schedules`]
/// reaps any installed schedule whose task is no longer there. Persisting must
/// succeed before installing, or the schedule would be a reconcile orphan.
pub fn install_os_schedule(
    task_json: &str,
    program: &str,
    args_json: &str,
) -> Result<String, String> {
    let (task, spec) = os_spec(task_json, program, args_json)?;
    task_store()
        .save(&task)
        .map_err(|e| format!("could not persist task for reconcile tracking: {e}"))?;
    let installed = spec.install().map_err(|e| e.to_string())?;
    serde_json::to_string(&installed).map_err(|e| e.to_string())
}

/// Remove the OS-level schedule for a task. Accepts either the full label
/// (`ai.parslee.car.task.<id>`) or a bare task id. Returns
/// `{ label, removed: bool }`; `removed` is false when nothing was installed.
///
/// Schedule-only by design: the task definition stays in `~/.car/tasks/` (it's
/// still a valid task to run in-process), so this is not the inverse of
/// `install`'s persist. Delete the task separately to drop it from the store.
pub fn uninstall_os_schedule(label_or_id: &str) -> Result<String, String> {
    let label = if label_or_id.starts_with(LABEL_PREFIX) {
        label_or_id.to_string()
    } else {
        format!("{LABEL_PREFIX}{label_or_id}")
    };
    let removed = car_scheduler::uninstall(&label).map_err(|e| e.to_string())?;
    Ok(json!({ "label": label, "removed": removed }).to_string())
}

/// List the labels of all CAR-managed OS-level schedules installed on this host.
/// Returns a JSON string array.
pub fn list_os_schedules() -> Result<String, String> {
    let labels = car_scheduler::list_installed().map_err(|e| e.to_string())?;
    serde_json::to_string(&labels).map_err(|e| e.to_string())
}

/// Reap orphaned OS-level schedules: uninstall every CAR-managed launchd/cron
/// entry whose task is no longer in `~/.car/tasks/` or whose trigger is no
/// longer `interval`/`cron`. Returns the [`ReconcileReport`](car_scheduler::ReconcileReport)
/// JSON (`{ removed, kept, errors }`). Best-effort per label; safe to run
/// repeatedly. Intended for daemon boot and an explicit `scheduler.os_reconcile`.
pub fn reconcile_os_schedules() -> Result<String, String> {
    // Use the fallible lister: a transient store-read failure must NOT look like
    // "zero tasks", or reconcile would reap every schedule. On error we refuse
    // to reap and surface it (the boot path logs it and keeps all schedules).
    let tasks = task_store().try_list().map_err(|e| {
        format!("could not read task store at ~/.car/tasks (refusing to reap schedules): {e}")
    })?;
    let report = car_scheduler::reconcile_with_tasks(&tasks).map_err(|e| e.to_string())?;
    serde_json::to_string(&report).map_err(|e| e.to_string())
}

/// Create a task definition from parameters. Returns task JSON.
pub fn create_task(
    name: &str,
    prompt: &str,
    trigger: Option<&str>,
    schedule: Option<&str>,
    system_prompt: Option<&str>,
) -> Result<String, String> {
    let mut task = car_scheduler::Task::new(name, prompt);
    if let Some(t) = trigger {
        let trigger_type = match t {
            "once" => car_scheduler::TaskTrigger::Once,
            "cron" => car_scheduler::TaskTrigger::Cron,
            "interval" => car_scheduler::TaskTrigger::Interval,
            "file_watch" => car_scheduler::TaskTrigger::FileWatch,
            _ => car_scheduler::TaskTrigger::Manual,
        };
        task = task.with_trigger(trigger_type, schedule.unwrap_or(""));
    }
    if let Some(sp) = system_prompt {
        task = task.with_system_prompt(sp);
    }
    serde_json::to_string(&task).map_err(|e| e.to_string())
}

/// Schedule a **deterministic command** on a cadence, hiding the OS backend
/// (#72). `spec_json` is `{ name, program, args?, cadence: { interval_secs? | cron? },
/// durable?, working_dir?, env?, timeout_secs? }` — the cadence is expressed
/// ONCE and CAR picks the backend:
/// - `durable: true` (default) → install to the OS scheduler (launchd/cron) so it
///   fires even when car-server is down; `*/N` cron is normalized so the same
///   input works on macOS + Linux.
/// - `durable: false` → the in-daemon timer fires it while car-server runs
///   (`backend: "daemon"`; missed when the daemon is down; interval-only).
///
/// Returns `{ id, durable, backend: "launchd"|"cron"|"daemon", task }`. The task
/// is persisted to `~/.car/tasks/` first (authoritative for reconcile); a failed
/// OS install rolls the persist back so a never-firing task isn't left behind.
pub fn schedule_task(spec_json: &str) -> Result<String, String> {
    #[derive(serde::Deserialize)]
    struct Cadence {
        interval_secs: Option<u64>,
        cron: Option<String>,
    }
    fn default_true() -> bool {
        true
    }
    #[derive(serde::Deserialize)]
    struct ScheduleReq {
        name: String,
        program: String,
        #[serde(default)]
        args: Vec<String>,
        cadence: Cadence,
        /// Fire even when car-server is down (OS scheduler). Default true — the
        /// robust path. `false` uses the in-daemon timer (fires only while
        /// car-server runs), which supports `interval_secs` only.
        #[serde(default = "default_true")]
        durable: bool,
        #[serde(default)]
        working_dir: Option<String>,
        #[serde(default)]
        env: std::collections::BTreeMap<String, String>,
        #[serde(default)]
        timeout_secs: Option<u64>,
    }
    let req: ScheduleReq =
        serde_json::from_str(spec_json).map_err(|e| format!("invalid schedule JSON: {e}"))?;

    let (trigger, schedule) = match (req.cadence.interval_secs, &req.cadence.cron) {
        (Some(secs), None) => (TaskTrigger::Interval, secs.to_string()),
        (None, Some(cron)) => (TaskTrigger::Cron, cron.clone()),
        (None, None) => {
            return Err("cadence must specify interval_secs or cron".into())
        }
        (Some(_), Some(_)) => {
            return Err("cadence must specify only one of interval_secs or cron".into())
        }
    };
    // A cron cadence fires through the OS scheduler (there's no in-daemon cron
    // evaluator); daemon-native timers are interval-only. Guide the caller
    // rather than silently ignoring `durable: false`.
    if trigger == TaskTrigger::Cron && !req.durable {
        return Err(
            "a cron cadence is scheduled via the OS backend (durable); set durable:true, \
             or use interval_secs for a daemon-native timer"
                .into(),
        );
    }

    let command = CommandSpec {
        program: req.program.clone(),
        args: req.args.clone(),
        working_dir: req.working_dir,
        env: req.env,
        timeout_secs: req.timeout_secs,
    };
    let mut task = Task::command(&req.name, command).with_trigger(trigger, &schedule);
    task.status = TaskStatus::Scheduled;

    // Persist first so the store is authoritative for reconcile.
    let store = task_store();
    store
        .save(&task)
        .map_err(|e| format!("could not persist scheduled task: {e}"))?;

    // Backend selection: durable → OS scheduler (running the command directly);
    // else the in-daemon timer. On an OS-install failure, roll the persist back
    // so a never-firing task isn't left in the store (a cron task the poller
    // skips and reconcile keeps).
    let (durable, backend) = if req.durable {
        let install = OsScheduleSpec::from_task(&task, &req.program, req.args.clone())
            .map_err(|e| e.to_string())
            .and_then(|spec| spec.install().map_err(|e| e.to_string()));
        match install {
            Ok(installed) => (true, installed.backend),
            Err(e) => {
                let _ = store.delete(&task.id);
                return Err(e);
            }
        }
    } else {
        (false, "daemon".to_string())
    };

    Ok(json!({
        "id": task.id,
        "durable": durable,
        "backend": backend,
        "task": task,
    })
    .to_string())
}

/// List the deterministic (command) scheduled tasks in `~/.car/tasks/`, each with
/// its resolved backend. Returns a JSON array of
/// `{ id, name, program, args, trigger, schedule, durable, backend, enabled }`.
/// Backend is derived from whether an OS schedule is installed for the task.
pub fn list_scheduled_tasks() -> Result<String, String> {
    let installed: std::collections::HashSet<String> = car_scheduler::list_installed()
        .unwrap_or_default()
        .into_iter()
        .collect();
    let out: Vec<_> = task_store()
        .list()
        .into_iter()
        .filter(|t| t.is_command())
        .map(|t| {
            let label = format!("{LABEL_PREFIX}{}", t.id);
            let durable = installed.contains(&label);
            let cmd = t.command.as_ref();
            json!({
                "id": t.id,
                "name": t.name,
                "program": cmd.map(|c| c.program.clone()),
                "args": cmd.map(|c| c.args.clone()),
                "trigger": t.trigger,
                "schedule": t.schedule,
                "durable": durable,
                "backend": if durable { "os" } else { "daemon" },
                "enabled": t.enabled,
            })
        })
        .collect();
    serde_json::to_string(&out).map_err(|e| e.to_string())
}

/// Unschedule a deterministic task: remove any OS schedule AND delete the task
/// from `~/.car/tasks/` (so the daemon timer drops it too). Accepts a bare task
/// id or the full label. Returns `{ id, os_removed, deleted }`.
pub fn unschedule_task(id_or_label: &str) -> Result<String, String> {
    let id = id_or_label
        .strip_prefix(LABEL_PREFIX)
        .unwrap_or(id_or_label)
        .to_string();
    let label = format!("{LABEL_PREFIX}{id}");
    let os_removed = car_scheduler::uninstall(&label).map_err(|e| e.to_string())?;
    let deleted = task_store().delete(&id);
    Ok(json!({ "id": id, "os_removed": os_removed, "deleted": deleted }).to_string())
}

/// Analyze a static execution DAG (arXiv 2604.11378 "From Agent Loops to
/// Structured Graphs" — `docs/proposals/scheduler-graph-analysis.md`).
/// `graph_json` is a `ScheduleGraph` `{ units: [{ id, duration?, depends_on? }] }`.
/// Returns the `ScheduleAnalysis` JSON `{ has_cycle, critical_path: [..ids..],
/// makespan, max_parallelism, levels: [[..ids..]], serial }` — `serial` flags the
/// single-ready-unit Agent-Loop pathology; `has_cycle` flags an unbounded loop.
pub fn analyze_schedule(graph_json: &str) -> Result<String, String> {
    let graph: car_scheduler::graph_schedule::ScheduleGraph = crate::from_json("graph", graph_json)?;
    let analysis = car_scheduler::graph_schedule::analyze_schedule(&graph);
    crate::to_json(&analysis)
}