supercode-harness 0.4.41

The optional native Supercode agent and tool harness
Documentation
//! A job's notepad: the durable key-value state a scheduled job keeps between
//! its runs (`docs/architecture/content-spec-status.md`: status, written by the
//! agent or an operator through the harness's own verb, never by `apply`).
//!
//! * **Read** — Hermes's own store, `cron/notepad.db` (`cron_notepad(job_id,
//!   key, value, updated_at)`), opened `SQLITE_OPEN_READ_ONLY` like every
//!   other observed-tier reader. A profile is a full HERMES_HOME with its own.
//! * **Write** — `hermes cron notepad <job> set|delete <key> [value]`, then the
//!   row re-read from that store. Hermes exits 0 whatever happened and prints a
//!   sentence, so the store, not the exit code or the sentence, is the answer.
//!
//! Hermes is the only harness with a job notepad at the pin. OpenClaw has
//! none; the orchestrator's folder has none. Both refuse.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::harness_command::HarnessCommand;
use crate::jobs_control::{harness_program, hermes_home, JobControlError, JobMutation};
use crate::{HarnessHomes, HarnessId};

/// One notepad request.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct JobNotepadRequest {
    /// Harness that owns the job.
    pub harness: String,
    /// The job's id.
    pub id: String,
    /// One key; absent reads every key.
    pub key: Option<String>,
    /// The value, for `set`.
    pub value: Option<String>,
    /// Hermes profile the job belongs to.
    pub profile: Option<String>,
    /// Storage roots.
    pub homes: HarnessHomes,
}

/// One notepad entry as the harness's store holds it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobNotepadEntry {
    /// The key.
    pub key: String,
    /// The stored value.
    pub value: String,
    /// When it was last written, as the harness records it.
    pub updated_at: String,
}

/// What a notepad call answered.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JobNotepad {
    /// Harness that owns the job.
    pub harness: String,
    /// The job's id.
    pub id: String,
    /// The entries read (one for a keyed read or a `set`; none for a `delete`
    /// or a key the job does not have).
    pub entries: Vec<JobNotepadEntry>,
    /// The harness command that ran, for `set` and `delete`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ran: Option<String>,
}

fn refuse_other_harnesses(request: &JobNotepadRequest) -> Result<(), JobControlError> {
    if request.id.trim().is_empty() {
        return Err(JobControlError::Invalid(
            "a notepad call needs the job id".into(),
        ));
    }
    if request.harness == HarnessId::HERMES {
        return Ok(());
    }
    Err(JobControlError::Unsupported(format!(
        "`{}` keeps no job notepad; the notepad is supported for: {}",
        request.harness,
        HarnessId::HERMES
    )))
}

fn home(request: &JobNotepadRequest) -> PathBuf {
    hermes_home(&JobMutation {
        harness: request.harness.clone(),
        profile: request.profile.clone(),
        homes: request.homes.clone(),
        ..JobMutation::default()
    })
}

/// The job must be in the store this request addresses (the root home, or
/// the named profile's): Hermes writes a notepad row for any id it is given,
/// into whichever HERMES_HOME it runs in.
fn require_job(request: &JobNotepadRequest) -> Result<(), JobControlError> {
    let found =
        crate::jobs::get_job(&request.harness, &request.id, &request.homes).map_err(|error| {
            JobControlError::Failed(format!("the job store could not be read: {error}"))
        })?;
    match found {
        Some((job, _)) if job.profile == request.profile => Ok(()),
        Some((job, _)) => Err(JobControlError::Invalid(format!(
            "job `{}` belongs to {}; name that profile with `profile`",
            request.id,
            job.profile
                .map(|p| format!("profile `{p}`"))
                .unwrap_or_else(|| "the root home".into())
        ))),
        None => Err(JobControlError::Invalid(format!(
            "{} has no job `{}`",
            request.harness, request.id
        ))),
    }
}

/// Read a job's notepad (every key, or one) from the harness's own store.
pub fn read(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
    refuse_other_harnesses(request)?;
    require_job(request)?;
    let store = home(request).join("cron/notepad.db");
    let entries = if store.exists() {
        read_store(&store, &request.id, request.key.as_deref())?
    } else {
        Vec::new()
    };
    Ok(JobNotepad {
        harness: request.harness.clone(),
        id: request.id.clone(),
        entries,
        ran: None,
    })
}

fn read_store(
    store: &std::path::Path,
    id: &str,
    key: Option<&str>,
) -> Result<Vec<JobNotepadEntry>, JobControlError> {
    let unreadable =
        |error: rusqlite::Error| JobControlError::Failed(format!("{}: {error}", store.display()));
    let connection = rusqlite::Connection::open_with_flags(
        store,
        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
    )
    .map_err(unreadable)?;
    let mut statement = connection
        .prepare(
            "SELECT key, value, updated_at FROM cron_notepad \
             WHERE job_id = ?1 AND (?2 IS NULL OR key = ?2) ORDER BY key",
        )
        .map_err(unreadable)?;
    let rows = statement
        .query_map(rusqlite::params![id, key], |row| {
            Ok(JobNotepadEntry {
                key: row.get(0)?,
                value: row.get(1)?,
                updated_at: row.get(2)?,
            })
        })
        .map_err(unreadable)?;
    rows.collect::<Result<Vec<_>, _>>().map_err(unreadable)
}

/// Write one key through `hermes cron notepad <job> set`, then re-read it.
pub fn set(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
    refuse_other_harnesses(request)?;
    let (Some(key), Some(value)) = (request.key.as_deref(), request.value.as_deref()) else {
        return Err(JobControlError::Invalid(
            "`notepad set` needs a key and a value".into(),
        ));
    };
    // `--` so a key or value that starts with `-` is never read as a flag.
    let ran = run(request, &["set", "--", key, value])?;
    let read = read(request)?;
    match read.entries.as_slice() {
        [entry] if entry.value == value => Ok(JobNotepad {
            ran: Some(ran),
            ..read
        }),
        _ => Err(JobControlError::Failed(format!(
            "`{ran}` exited 0 but the store does not hold `{key}` = the value sent afterwards"
        ))),
    }
}

/// Remove one key through `hermes cron notepad <job> delete`, then re-read it.
pub fn delete(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
    refuse_other_harnesses(request)?;
    let Some(key) = request.key.as_deref() else {
        return Err(JobControlError::Invalid(
            "`notepad delete` needs a key".into(),
        ));
    };
    let ran = run(request, &["delete", "--", key])?;
    let read = read(request)?;
    if read.entries.is_empty() {
        Ok(JobNotepad {
            ran: Some(ran),
            ..read
        })
    } else {
        Err(JobControlError::Failed(format!(
            "`{ran}` exited 0 but the store still holds `{key}`"
        )))
    }
}

fn run(request: &JobNotepadRequest, action: &[&str]) -> Result<String, JobControlError> {
    require_job(request)?;
    let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
    command.env("HERMES_HOME", home(request).to_string_lossy());
    command.args(["cron", "notepad", request.id.as_str()]);
    command.args(action.iter().copied());
    let ran = command.narrate();
    command.run().map_err(JobControlError::Failed)?;
    Ok(ran)
}