Skip to main content

supercode_harness/
jobs_notepad.rs

1//! A job's notepad: the durable key-value state a scheduled job keeps between
2//! its runs (`docs/architecture/content-spec-status.md`: status, written by the
3//! agent or an operator through the harness's own verb, never by `apply`).
4//!
5//! * **Read** — Hermes's own store, `cron/notepad.db` (`cron_notepad(job_id,
6//!   key, value, updated_at)`), opened `SQLITE_OPEN_READ_ONLY` like every
7//!   other observed-tier reader. A profile is a full HERMES_HOME with its own.
8//! * **Write** — `hermes cron notepad <job> set|delete <key> [value]`, then the
9//!   row re-read from that store. Hermes exits 0 whatever happened and prints a
10//!   sentence, so the store, not the exit code or the sentence, is the answer.
11//!
12//! Hermes is the only harness with a job notepad at the pin. OpenClaw has
13//! none; the orchestrator's folder has none. Both refuse.
14
15use std::path::PathBuf;
16
17use serde::{Deserialize, Serialize};
18
19use crate::harness_command::HarnessCommand;
20use crate::jobs_control::{harness_program, hermes_home, JobControlError, JobMutation};
21use crate::{HarnessHomes, HarnessId};
22
23/// One notepad request.
24#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
25#[serde(default)]
26pub struct JobNotepadRequest {
27    /// Harness that owns the job.
28    pub harness: String,
29    /// The job's id.
30    pub id: String,
31    /// One key; absent reads every key.
32    pub key: Option<String>,
33    /// The value, for `set`.
34    pub value: Option<String>,
35    /// Hermes profile the job belongs to.
36    pub profile: Option<String>,
37    /// Storage roots.
38    pub homes: HarnessHomes,
39}
40
41/// One notepad entry as the harness's store holds it.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct JobNotepadEntry {
44    /// The key.
45    pub key: String,
46    /// The stored value.
47    pub value: String,
48    /// When it was last written, as the harness records it.
49    pub updated_at: String,
50}
51
52/// What a notepad call answered.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct JobNotepad {
55    /// Harness that owns the job.
56    pub harness: String,
57    /// The job's id.
58    pub id: String,
59    /// The entries read (one for a keyed read or a `set`; none for a `delete`
60    /// or a key the job does not have).
61    pub entries: Vec<JobNotepadEntry>,
62    /// The harness command that ran, for `set` and `delete`.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub ran: Option<String>,
65}
66
67fn refuse_other_harnesses(request: &JobNotepadRequest) -> Result<(), JobControlError> {
68    if request.id.trim().is_empty() {
69        return Err(JobControlError::Invalid(
70            "a notepad call needs the job id".into(),
71        ));
72    }
73    if request.harness == HarnessId::HERMES {
74        return Ok(());
75    }
76    Err(JobControlError::Unsupported(format!(
77        "`{}` keeps no job notepad; the notepad is supported for: {}",
78        request.harness,
79        HarnessId::HERMES
80    )))
81}
82
83fn home(request: &JobNotepadRequest) -> PathBuf {
84    hermes_home(&JobMutation {
85        harness: request.harness.clone(),
86        profile: request.profile.clone(),
87        homes: request.homes.clone(),
88        ..JobMutation::default()
89    })
90}
91
92/// The job must be in the store this request addresses (the root home, or
93/// the named profile's): Hermes writes a notepad row for any id it is given,
94/// into whichever HERMES_HOME it runs in.
95fn require_job(request: &JobNotepadRequest) -> Result<(), JobControlError> {
96    let found =
97        crate::jobs::get_job(&request.harness, &request.id, &request.homes).map_err(|error| {
98            JobControlError::Failed(format!("the job store could not be read: {error}"))
99        })?;
100    match found {
101        Some((job, _)) if job.profile == request.profile => Ok(()),
102        Some((job, _)) => Err(JobControlError::Invalid(format!(
103            "job `{}` belongs to {}; name that profile with `profile`",
104            request.id,
105            job.profile
106                .map(|p| format!("profile `{p}`"))
107                .unwrap_or_else(|| "the root home".into())
108        ))),
109        None => Err(JobControlError::Invalid(format!(
110            "{} has no job `{}`",
111            request.harness, request.id
112        ))),
113    }
114}
115
116/// Read a job's notepad (every key, or one) from the harness's own store.
117pub fn read(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
118    refuse_other_harnesses(request)?;
119    require_job(request)?;
120    let store = home(request).join("cron/notepad.db");
121    let entries = if store.exists() {
122        read_store(&store, &request.id, request.key.as_deref())?
123    } else {
124        Vec::new()
125    };
126    Ok(JobNotepad {
127        harness: request.harness.clone(),
128        id: request.id.clone(),
129        entries,
130        ran: None,
131    })
132}
133
134fn read_store(
135    store: &std::path::Path,
136    id: &str,
137    key: Option<&str>,
138) -> Result<Vec<JobNotepadEntry>, JobControlError> {
139    let unreadable =
140        |error: rusqlite::Error| JobControlError::Failed(format!("{}: {error}", store.display()));
141    let connection = rusqlite::Connection::open_with_flags(
142        store,
143        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
144    )
145    .map_err(unreadable)?;
146    let mut statement = connection
147        .prepare(
148            "SELECT key, value, updated_at FROM cron_notepad \
149             WHERE job_id = ?1 AND (?2 IS NULL OR key = ?2) ORDER BY key",
150        )
151        .map_err(unreadable)?;
152    let rows = statement
153        .query_map(rusqlite::params![id, key], |row| {
154            Ok(JobNotepadEntry {
155                key: row.get(0)?,
156                value: row.get(1)?,
157                updated_at: row.get(2)?,
158            })
159        })
160        .map_err(unreadable)?;
161    rows.collect::<Result<Vec<_>, _>>().map_err(unreadable)
162}
163
164/// Write one key through `hermes cron notepad <job> set`, then re-read it.
165pub fn set(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
166    refuse_other_harnesses(request)?;
167    let (Some(key), Some(value)) = (request.key.as_deref(), request.value.as_deref()) else {
168        return Err(JobControlError::Invalid(
169            "`notepad set` needs a key and a value".into(),
170        ));
171    };
172    // `--` so a key or value that starts with `-` is never read as a flag.
173    let ran = run(request, &["set", "--", key, value])?;
174    let read = read(request)?;
175    match read.entries.as_slice() {
176        [entry] if entry.value == value => Ok(JobNotepad {
177            ran: Some(ran),
178            ..read
179        }),
180        _ => Err(JobControlError::Failed(format!(
181            "`{ran}` exited 0 but the store does not hold `{key}` = the value sent afterwards"
182        ))),
183    }
184}
185
186/// Remove one key through `hermes cron notepad <job> delete`, then re-read it.
187pub fn delete(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
188    refuse_other_harnesses(request)?;
189    let Some(key) = request.key.as_deref() else {
190        return Err(JobControlError::Invalid(
191            "`notepad delete` needs a key".into(),
192        ));
193    };
194    let ran = run(request, &["delete", "--", key])?;
195    let read = read(request)?;
196    if read.entries.is_empty() {
197        Ok(JobNotepad {
198            ran: Some(ran),
199            ..read
200        })
201    } else {
202        Err(JobControlError::Failed(format!(
203            "`{ran}` exited 0 but the store still holds `{key}`"
204        )))
205    }
206}
207
208fn run(request: &JobNotepadRequest, action: &[&str]) -> Result<String, JobControlError> {
209    require_job(request)?;
210    let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
211    command.env("HERMES_HOME", home(request).to_string_lossy());
212    command.args(["cron", "notepad", request.id.as_str()]);
213    command.args(action.iter().copied());
214    let ran = command.narrate();
215    command.run().map_err(JobControlError::Failed)?;
216    Ok(ran)
217}