car-ffi-common 0.26.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::{OsScheduleSpec, Task, TaskStore};
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())
}