Skip to main content

supercode_harness/
jobs_control.rs

1//! Controlled-tier scheduled jobs (Domain 11, concept 6) — the FIRST
2//! controlled-tier noun, and the shape the rest of wave 2 copies.
3//!
4//! Charter (`docs/plans/orchestration-domain-11-2026-09-02.md` §0.4):
5//! **supercode never runs a cron engine.** Every mutation here is the
6//! harness's OWN verb, executed as a subprocess, with supercode acting as the
7//! uniform client:
8//!
9//! * **Hermes** — `hermes cron create | edit | pause | resume | run | remove`
10//!   with `HERMES_HOME` in the environment (a profile IS a HERMES_HOME:
11//!   upstream `hermes_cli/profiles.py` spawns profile work with
12//!   `HERMES_HOME=<home>/profiles/<name>`).
13//! * **OpenClaw** — `openclaw cron add | edit | disable | enable | run | rm`.
14//!   Every one of these goes through the Gateway websocket, so the endpoint
15//!   and credential are resolved from OPENCLAW's OWN config
16//!   (`<state dir>/openclaw.json`, pointers `/gateway/remote/url`,
17//!   `/gateway/port`, `/gateway/auth/token`) through the very same
18//!   [`crate::RuntimeConnectLaunch`] the connect descriptor uses — never from
19//!   supercode's own config, and never from an inherited environment variable.
20//! * **The orchestrator** — its own package (ORC-13). The write door is the
21//!   daemon's local socket while it is up and `node bin/orchestrator.mjs
22//!   <op> …` when it is down, both landing in the SAME `applyOperator` →
23//!   reducer → `save()` path inside `sdk/orchestrator`, which owns the
24//!   folder's byte-stability and its residue rules
25//!   (`docs/ORCHESTRATOR-IR.md` §4.6, §6). supercode writes no file of that
26//!   folder itself; [`crate::orchestrator_door`] is the uniform client.
27//! * **Claude Code** — refused. Its jobs are session-scoped runtime state
28//!   created by the model inside a session (`CronCreate`); the harness
29//!   publishes no verb a client can call.
30//!
31//! Three rules the whole tier inherits:
32//!
33//! 1. **The harness's answer is the answer.** After the verb exits 0 the row
34//!    is re-read through the ORCH-7 loader ([`crate::jobs`]) and returned. A
35//!    non-zero exit surfaces the harness's own stderr as the error — never a
36//!    silent success, never a supercode-invented row.
37//! 2. **The command is narrated.** Every outcome carries `ran`: the exact
38//!    argv that was executed, with any credential rendered as `<redacted>`.
39//!    Tokens are never printed, logged, or stored.
40//! 3. **A field the harness has no verb for is refused**
41//!    ([`JobControlError::Unsupported`] → `UnsupportedAction`), never dropped.
42
43use std::collections::BTreeSet;
44use std::path::{Path, PathBuf};
45
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48
49pub(crate) use crate::harness_command::shell_quote;
50pub(crate) use crate::harness_command::HarnessCommand;
51use crate::{HarnessHomes, HarnessId, ScheduledJob};
52
53pub use crate::harness_command::{HERMES_BIN_ENV, OPENCLAW_BIN_ENV};
54
55/// Harnesses whose scheduled jobs supercode can MUTATE through their own CLI
56/// verb. Strictly narrower than [`crate::jobs::JOB_HARNESSES`]: Claude Code is
57/// readable but not controllable.
58pub const CONTROLLED_JOB_HARNESSES: &[&str] = &[
59    HarnessId::HERMES,
60    HarnessId::OPENCLAW,
61    HarnessId::ORCHESTRATOR,
62];
63
64/// Why Claude Code refuses every mutating job verb.
65pub const CLAUDE_CODE_REFUSAL: &str =
66    "claude-code scheduled jobs are session-scoped runtime state: they are created by the model \
67     inside a session (`CronCreate`) and restored on resume. Claude Code publishes no harness verb \
68     a client can call, so supercode refuses rather than inventing one";
69
70/// One uniform mutating verb.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum JobVerb {
74    /// Create a new scheduled job.
75    Create,
76    /// Patch an existing job's fields.
77    Update,
78    /// Stop the scheduler from firing a job.
79    Pause,
80    /// Let the scheduler fire a job again.
81    Resume,
82    /// Fire a job now, out of schedule.
83    Run,
84    /// Remove a job.
85    Delete,
86}
87
88impl JobVerb {
89    /// Uniform spelling used in the RPC method and in outcomes.
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::Create => "create",
93            Self::Update => "update",
94            Self::Pause => "pause",
95            Self::Resume => "resume",
96            Self::Run => "run",
97            Self::Delete => "delete",
98        }
99    }
100
101    /// Whether the verb needs an existing job id.
102    const fn needs_id(self) -> bool {
103        !matches!(self, Self::Create)
104    }
105}
106
107/// Uniform firing rule for a create/update.
108#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109#[serde(default)]
110pub struct JobScheduleSpec {
111    /// `interval` | `cron` | `once`.
112    pub kind: String,
113    /// Interval length, for `kind = "interval"`.
114    pub minutes: Option<f64>,
115    /// Cron expression, for `kind = "cron"`.
116    pub expr: Option<String>,
117    /// Absolute instant, for `kind = "once"`.
118    pub run_at: Option<String>,
119}
120
121/// Uniform payload for a create/update.
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(default)]
124pub struct JobPayloadSpec {
125    /// `prompt` | `system_event` | `command` | `script`.
126    pub kind: String,
127    /// The prompt, event, command line, or script the fire carries.
128    pub text: Option<String>,
129}
130
131/// Uniform delivery for a create/update.
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(default)]
134pub struct JobDeliverSpec {
135    /// Hermes `deliver` grammar (`origin` | `local` | `<platform>`), or an
136    /// OpenClaw delivery mode (`announce` | `webhook` | `none`).
137    pub target: Option<String>,
138    /// Chat / destination the delivery is addressed to.
139    pub chat_id: Option<String>,
140}
141
142/// One mutating request, in the uniform Domain 11 vocabulary.
143#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
144pub struct JobMutation {
145    /// Harness that owns the job.
146    pub harness: String,
147    /// Job id, for every verb but `create`.
148    #[serde(default)]
149    pub id: Option<String>,
150    /// Human-friendly job name.
151    #[serde(default)]
152    pub name: Option<String>,
153    /// When the job fires.
154    #[serde(default)]
155    pub schedule: Option<JobScheduleSpec>,
156    /// What fires.
157    #[serde(default)]
158    pub payload: Option<JobPayloadSpec>,
159    /// OpenClaw `sessionTarget` (`main` | `isolated`).
160    #[serde(default)]
161    pub session_target: Option<String>,
162    /// Where the fire's output goes.
163    #[serde(default)]
164    pub deliver: Option<JobDeliverSpec>,
165    /// Hermes profile name / OpenClaw agent id.
166    #[serde(default)]
167    pub profile: Option<String>,
168    /// Storage roots, so an isolated home is addressed the same way the
169    /// read side addresses it.
170    #[serde(default)]
171    pub homes: HarnessHomes,
172}
173
174/// What one mutation did, with the harness's own row read back afterwards.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct JobMutationOutcome {
177    /// Harness that ran the verb.
178    pub harness: String,
179    /// Uniform verb that was asked for.
180    pub verb: String,
181    /// The exact harness command that ran, credentials redacted.
182    pub ran: String,
183    /// Affected job id.
184    pub id: String,
185    /// The job as the harness's own store reports it AFTER the verb.
186    /// Absent for `delete`.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub job: Option<ScheduledJob>,
189    /// `true` on a successful `delete`.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub deleted: Option<bool>,
192}
193
194/// Why a mutation could not be performed.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum JobControlError {
197    /// The harness has no verb for what was asked (refused, never faked).
198    Unsupported(String),
199    /// The request itself is incoherent.
200    Invalid(String),
201    /// The harness verb ran and failed; the message carries its stderr.
202    Failed(String),
203}
204
205impl std::fmt::Display for JobControlError {
206    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            Self::Unsupported(message) | Self::Invalid(message) | Self::Failed(message) => {
209                formatter.write_str(message)
210            }
211        }
212    }
213}
214
215impl std::error::Error for JobControlError {}
216
217type Result<T> = std::result::Result<T, JobControlError>;
218
219/// Whether `harness` can have its scheduled jobs mutated at all.
220pub fn supports_job_control(harness: &str) -> bool {
221    CONTROLLED_JOB_HARNESSES.contains(&harness)
222}
223
224pub fn harness_program(harness: &str) -> Result<String> {
225    crate::harness_command::harness_program(harness).map_err(|detail| {
226        JobControlError::Unsupported(detail.unwrap_or_else(|| unsupported_harness(harness)))
227    })
228}
229
230fn unsupported_harness(harness: &str) -> String {
231    if harness == HarnessId::CLAUDE_CODE {
232        return CLAUDE_CODE_REFUSAL.to_string();
233    }
234    format!(
235        "`{harness}` has no mutable scheduled jobs; mutating job verbs are supported for: {}",
236        CONTROLLED_JOB_HARNESSES.join(", ")
237    )
238}
239
240/// `HERMES_HOME` for this request: the profile's own home when one is named
241/// (upstream treats a profile as a full HERMES_HOME), else the install root.
242fn hermes_home(mutation: &JobMutation) -> PathBuf {
243    // `HarnessHomes::hermes` addresses `state.db`; HERMES_HOME is its parent,
244    // the same derivation the read side uses.
245    let root = mutation
246        .homes
247        .hermes
248        .parent()
249        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
250    match mutation.profile.as_deref() {
251        Some(profile) => root.join("profiles").join(profile),
252        None => root,
253    }
254}
255
256/// Resolve OpenClaw's gateway endpoint and credential from OPENCLAW's own
257/// config, through the registry's connect descriptor.
258///
259/// The pointers are never re-spelled here: the descriptor
260/// (`/gateway/remote/url` → `/gateway/port` → the documented default, auth at
261/// `/gateway/auth/token`) is taken from the compiled registry and only its
262/// `config_path` is re-anchored onto the state dir the caller addressed, so an
263/// isolated home resolves ITS token and the default home resolves the real
264/// one. supercode's own config is never consulted.
265fn openclaw_connection(homes: &HarnessHomes) -> Result<crate::ResolvedRuntimeConnection> {
266    let registry = crate::harness_support_registry();
267    let descriptor = registry
268        .harnesses
269        .iter()
270        .find(|descriptor| descriptor.id.as_str() == HarnessId::OPENCLAW)
271        .ok_or_else(|| {
272            JobControlError::Unsupported("the registry has no openclaw descriptor".into())
273        })?;
274    let connect = descriptor.runtime.connect_launch.as_ref().ok_or_else(|| {
275        JobControlError::Unsupported(
276            "openclaw has no registered connect-mode launch, so its gateway cannot be located"
277                .into(),
278        )
279    })?;
280    let mut connect = connect.clone();
281    connect.config_path = homes
282        .openclaw
283        .join("openclaw.json")
284        .to_string_lossy()
285        .into_owned();
286    connect.resolve(Path::new("/")).map_err(|error| {
287        JobControlError::Unsupported(format!(
288            "openclaw's gateway endpoint could not be resolved from its own config: {error}"
289        ))
290    })
291}
292
293/// Perform one mutation: translate to the harness's own verb, run it, then
294/// re-read the row through the ORCH-7 loader.
295pub fn mutate(verb: JobVerb, mutation: &JobMutation) -> Result<JobMutationOutcome> {
296    if !supports_job_control(&mutation.harness) {
297        return Err(JobControlError::Unsupported(unsupported_harness(
298            &mutation.harness,
299        )));
300    }
301    if verb.needs_id() && mutation.id.as_deref().unwrap_or("").trim().is_empty() {
302        return Err(JobControlError::Invalid(format!(
303            "`jobs.{}` needs the job id to act on",
304            verb.as_str()
305        )));
306    }
307    if matches!(verb, JobVerb::Create) && mutation.schedule.is_none() {
308        return Err(JobControlError::Invalid(
309            "`jobs.create` needs a schedule (interval, cron, or once)".into(),
310        ));
311    }
312    // ORC-13: the orchestrator's verb is not a CLI subprocess but its own
313    // package's operator door, so it branches before the command table.
314    if mutation.harness == HarnessId::ORCHESTRATOR {
315        return orchestrator_mutate(verb, mutation);
316    }
317    let command = match mutation.harness.as_str() {
318        HarnessId::HERMES => hermes_command(verb, mutation)?,
319        HarnessId::OPENCLAW => openclaw_command(verb, mutation)?,
320        other => return Err(JobControlError::Unsupported(unsupported_harness(other))),
321    };
322    let ran = command.narrate();
323    let before = matches!(verb, JobVerb::Create).then(|| known_ids(mutation));
324    let stdout = command.run().map_err(JobControlError::Failed)?;
325    let id = match (verb, before) {
326        (JobVerb::Create, Some(before)) => created_id(mutation, &before, &stdout, &ran)?,
327        _ => mutation.id.clone().unwrap_or_default(),
328    };
329    // The harness's own store is the answer: re-read, never echo the request.
330    let read = crate::jobs::get_job(&mutation.harness, &id, &mutation.homes).map_err(|error| {
331        JobControlError::Failed(format!(
332            "`{ran}` succeeded but the job store could not be re-read: {error}"
333        ))
334    })?;
335    match verb {
336        JobVerb::Delete => {
337            if read.is_some() {
338                return Err(JobControlError::Failed(format!(
339                    "`{ran}` reported success but `{id}` is still in {}'s job store",
340                    mutation.harness
341                )));
342            }
343            Ok(JobMutationOutcome {
344                harness: mutation.harness.clone(),
345                verb: verb.as_str().to_string(),
346                ran,
347                id,
348                job: None,
349                deleted: Some(true),
350            })
351        }
352        _ => {
353            let (job, _) = read.ok_or_else(|| {
354                JobControlError::Failed(format!(
355                    "`{ran}` reported success but `{}` has no job `{id}` afterwards",
356                    mutation.harness
357                ))
358            })?;
359            Ok(JobMutationOutcome {
360                harness: mutation.harness.clone(),
361                verb: verb.as_str().to_string(),
362                ran,
363                id,
364                job: Some(job),
365                deleted: None,
366            })
367        }
368    }
369}
370
371// ---------------------------------------------------------------------------
372// The orchestrator — its own package's operator door (ORC-13)
373// ---------------------------------------------------------------------------
374
375/// The orchestrator profile this mutation acts in: `--profile`, else the
376/// root folder, which IS the `default` profile (`docs/ORCHESTRATOR-IR.md` §6).
377fn orchestrator_profile(mutation: &JobMutation) -> &str {
378    mutation
379        .profile
380        .as_deref()
381        .map(str::trim)
382        .filter(|profile| !profile.is_empty())
383        .unwrap_or("default")
384}
385
386/// The uniform row translated onto the orchestrator's OWN job vocabulary
387/// (`docs/ORCHESTRATOR-IR.md` §2.6) — the same words its MCP tools and its
388/// chat commands use. A field the model has no home for is refused by name,
389/// never dropped.
390fn orchestrator_args(verb: JobVerb, mutation: &JobMutation) -> Result<Value> {
391    let mut args = serde_json::Map::new();
392    if let Some(id) = mutation.id.as_deref().filter(|id| !id.trim().is_empty()) {
393        args.insert("id".into(), Value::String(id.trim().to_string()));
394    }
395    if matches!(verb, JobVerb::Create | JobVerb::Update) {
396        if mutation.session_target.is_some() {
397            return Err(JobControlError::Unsupported(
398                "an orchestrator cron fire opens its own binding on the job's origin surface \
399                 (`docs/ORCHESTRATOR-IR.md` §4.3); the model has no session-target field, so \
400                 supercode refuses rather than dropping it"
401                    .into(),
402            ));
403        }
404        if let Some(name) = &mutation.name {
405            args.insert("name".into(), Value::String(name.clone()));
406        }
407        if let Some(schedule) = &mutation.schedule {
408            args.insert("schedule".into(), orchestrator_schedule(schedule)?);
409        }
410        if let Some(payload) = &mutation.payload {
411            match payload_kind(payload) {
412                "prompt" => {
413                    args.insert(
414                        "prompt".into(),
415                        Value::String(payload_text(payload)?.to_string()),
416                    );
417                }
418                other => {
419                    return Err(JobControlError::Unsupported(format!(
420                        "an orchestrator job carries a `prompt` — the fire opens a worker session \
421                         and sends it (§4.3); there is no `{other}` payload, so supercode refuses \
422                         rather than inventing one"
423                    )))
424                }
425            }
426        }
427        if let Some(deliver) = &mutation.deliver {
428            if let Some(target) = hermes_deliver(deliver) {
429                // The orchestrator's `deliver` grammar IS Hermes's
430                // (`origin | local | home | <platform>[:<chat_id>]`, §2.6).
431                args.insert("deliver".into(), Value::String(target));
432            }
433        }
434    } else if mutation.name.is_some()
435        || mutation.schedule.is_some()
436        || mutation.payload.is_some()
437        || mutation.deliver.is_some()
438        || mutation.session_target.is_some()
439    {
440        return Err(JobControlError::Invalid(format!(
441            "`jobs.{}` changes no fields; pass definition fields to `jobs.update`",
442            verb.as_str()
443        )));
444    }
445    Ok(Value::Object(args))
446}
447
448/// The uniform schedule in the orchestrator's typed form (§2.6).
449fn orchestrator_schedule(schedule: &JobScheduleSpec) -> Result<Value> {
450    match schedule.kind.as_str() {
451        "interval" => schedule
452            .minutes
453            .map(|minutes| serde_json::json!({"kind": "interval", "minutes": minutes}))
454            .ok_or_else(|| JobControlError::Invalid("an interval schedule needs `minutes`".into())),
455        "cron" => schedule
456            .expr
457            .as_deref()
458            .map(|expr| serde_json::json!({"kind": "cron", "expr": expr}))
459            .ok_or_else(|| JobControlError::Invalid("a cron schedule needs `expr`".into())),
460        "once" => schedule
461            .run_at
462            .as_deref()
463            .map(|run_at| serde_json::json!({"kind": "once", "run_at": run_at}))
464            .ok_or_else(|| JobControlError::Invalid("a once schedule needs `run_at`".into())),
465        other => Err(JobControlError::Invalid(format!(
466            "unknown schedule kind `{other}`; use interval, cron, or once"
467        ))),
468    }
469}
470
471/// One orchestrator job mutation: through the package's door, then re-read
472/// through the ORC-7 loader like every other harness's row.
473fn orchestrator_mutate(verb: JobVerb, mutation: &JobMutation) -> Result<JobMutationOutcome> {
474    let args = orchestrator_args(verb, mutation)?;
475    let root = mutation.homes.orchestrator.clone();
476    let profile = orchestrator_profile(mutation);
477    let op = format!("jobs.{}", verb.as_str());
478    let answer = crate::orchestrator_door::call(&root, &op, &args, profile).map_err(|error| {
479        match error {
480            // The package refused: its sentence is the answer, in the same
481            // shape a harness's stderr takes for the other two.
482            crate::orchestrator_door::DoorError::Refused(message) => {
483                JobControlError::Failed(message)
484            }
485            crate::orchestrator_door::DoorError::Failed(message) => {
486                JobControlError::Failed(message)
487            }
488        }
489    })?;
490    let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
491    let id = answer
492        .result
493        .pointer("/job_id")
494        .and_then(Value::as_str)
495        .map(str::to_string)
496        .or_else(|| mutation.id.clone())
497        .ok_or_else(|| JobControlError::Failed(format!("`{ran}` succeeded but named no job id")))?;
498    // The FOLDER is the answer, re-read through the same loader `jobs list`
499    // uses — never the door's echo of what it wrote.
500    let read = crate::jobs::get_job(&mutation.harness, &id, &mutation.homes).map_err(|error| {
501        JobControlError::Failed(format!(
502            "`{ran}` succeeded but the job store could not be re-read: {error}"
503        ))
504    })?;
505    match verb {
506        JobVerb::Delete => {
507            if read.is_some() {
508                return Err(JobControlError::Failed(format!(
509                    "`{ran}` reported success but `{id}` is still in the orchestrator's job store"
510                )));
511            }
512            Ok(JobMutationOutcome {
513                harness: mutation.harness.clone(),
514                verb: verb.as_str().to_string(),
515                ran,
516                id,
517                job: None,
518                deleted: Some(true),
519            })
520        }
521        _ => {
522            let (job, _) = read.ok_or_else(|| {
523                JobControlError::Failed(format!(
524                    "`{ran}` reported success but the orchestrator has no job `{id}` afterwards"
525                ))
526            })?;
527            Ok(JobMutationOutcome {
528                harness: mutation.harness.clone(),
529                verb: verb.as_str().to_string(),
530                ran,
531                id,
532                job: Some(job),
533                deleted: None,
534            })
535        }
536    }
537}
538
539/// Every job id the harness's store holds right now.
540fn known_ids(mutation: &JobMutation) -> BTreeSet<String> {
541    crate::jobs::list_jobs(&crate::jobs::JobsQuery {
542        harness: Some(mutation.harness.clone()),
543        homes: mutation.homes.clone(),
544        ..crate::jobs::JobsQuery::default()
545    })
546    .map(|listing| listing.jobs.into_iter().map(|job| job.id).collect())
547    .unwrap_or_default()
548}
549
550/// Identify the job the create verb just made: the id the harness's own store
551/// gained. When several appeared (a concurrent writer), the harness's stdout
552/// decides between them.
553fn created_id(
554    mutation: &JobMutation,
555    before: &BTreeSet<String>,
556    stdout: &str,
557    ran: &str,
558) -> Result<String> {
559    let after = known_ids(mutation);
560    let mut fresh: Vec<String> = after.difference(before).cloned().collect();
561    if fresh.len() == 1 {
562        return Ok(fresh.remove(0));
563    }
564    if let Some(named) = fresh.iter().find(|id| stdout.contains(id.as_str())) {
565        return Ok(named.clone());
566    }
567    // Last resort: an id the harness printed that the store now holds (a
568    // store that reuses an existing id, e.g. an idempotent declaration key).
569    if let Some(id) = stdout_id(stdout).filter(|id| after.contains(id)) {
570        return Ok(id);
571    }
572    Err(JobControlError::Failed(format!(
573        "`{ran}` reported success but {} gained {} job(s), so the new job cannot be identified",
574        mutation.harness,
575        fresh.len()
576    )))
577}
578
579/// An `id` field from a harness's JSON stdout, when it prints one.
580fn stdout_id(stdout: &str) -> Option<String> {
581    let value: Value = serde_json::from_str(stdout.trim()).ok()?;
582    for pointer in ["/id", "/job/id", "/jobId", "/job_id", "/result/id"] {
583        if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
584            return Some(id.to_string());
585        }
586    }
587    None
588}
589
590// ---------------------------------------------------------------------------
591// Hermes — `hermes cron …` over HERMES_HOME
592// ---------------------------------------------------------------------------
593
594/// Hermes's schedule argument: one positional string its own parser reads
595/// (`cron/jobs.py::parse_schedule` — `every 10m`, a cron expression, or an
596/// ISO instant for a one-shot).
597fn hermes_schedule(schedule: &JobScheduleSpec) -> Result<String> {
598    match schedule.kind.as_str() {
599        "interval" => schedule
600            .minutes
601            .map(|minutes| format!("every {}m", trim_float(minutes)))
602            .ok_or_else(|| JobControlError::Invalid("an interval schedule needs `minutes`".into())),
603        "cron" => schedule
604            .expr
605            .clone()
606            .ok_or_else(|| JobControlError::Invalid("a cron schedule needs `expr`".into())),
607        "once" => schedule
608            .run_at
609            .clone()
610            .ok_or_else(|| JobControlError::Invalid("a once schedule needs `run_at`".into())),
611        other => Err(JobControlError::Invalid(format!(
612            "unknown schedule kind `{other}`; use interval, cron, or once"
613        ))),
614    }
615}
616
617fn trim_float(value: f64) -> String {
618    if value.fract().abs() < f64::EPSILON {
619        format!("{}", value as i64)
620    } else {
621        format!("{value}")
622    }
623}
624
625/// Hermes's delivery argument, in hermes's own grammar
626/// (`origin | local | <platform> | <platform>:<chat_id>`).
627fn hermes_deliver(deliver: &JobDeliverSpec) -> Option<String> {
628    let target = deliver.target.as_deref()?.trim().to_string();
629    match deliver.chat_id.as_deref() {
630        Some(chat) if !target.contains(':') && !chat.trim().is_empty() => {
631            Some(format!("{target}:{}", chat.trim()))
632        }
633        _ => Some(target),
634    }
635}
636
637fn hermes_command(verb: JobVerb, mutation: &JobMutation) -> Result<HarnessCommand> {
638    if mutation.session_target.is_some() {
639        return Err(JobControlError::Unsupported(
640            "hermes cron fires always open their own `platform=cron` session; hermes has no \
641             session-target verb, so supercode refuses rather than dropping the field"
642                .into(),
643        ));
644    }
645    let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
646    command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
647    command.args(["cron"]);
648    let id = mutation.id.clone().unwrap_or_default();
649    match verb {
650        JobVerb::Create => {
651            command.arg("create");
652            if let Some(name) = &mutation.name {
653                command.args(["--name", name]);
654            }
655            if let Some(deliver) = mutation.deliver.as_ref().and_then(hermes_deliver) {
656                command.args(["--deliver", &deliver]);
657            }
658            if let Some(payload) = &mutation.payload {
659                if payload_kind(payload) == "script" {
660                    command.args(["--script", payload_text(payload)?]);
661                }
662            }
663            // The schedule is positional and must precede the prompt.
664            let schedule = hermes_schedule(
665                mutation
666                    .schedule
667                    .as_ref()
668                    .expect("create validates a schedule"),
669            )?;
670            command.arg(schedule);
671            if let Some(payload) = &mutation.payload {
672                match payload_kind(payload) {
673                    "prompt" => {
674                        command.arg(payload_text(payload)?);
675                    }
676                    "script" => {}
677                    other => return Err(hermes_payload_refusal(other)),
678                }
679            }
680        }
681        JobVerb::Update => {
682            command.args(["edit", &id]);
683            if let Some(schedule) = &mutation.schedule {
684                command.args(["--schedule", &hermes_schedule(schedule)?]);
685            }
686            if let Some(name) = &mutation.name {
687                command.args(["--name", name]);
688            }
689            if let Some(deliver) = mutation.deliver.as_ref().and_then(hermes_deliver) {
690                command.args(["--deliver", &deliver]);
691            }
692            if let Some(payload) = &mutation.payload {
693                match payload_kind(payload) {
694                    "prompt" => {
695                        command.args(["--prompt", payload_text(payload)?]);
696                    }
697                    "script" => {
698                        command.args(["--script", payload_text(payload)?]);
699                    }
700                    other => return Err(hermes_payload_refusal(other)),
701                }
702            }
703        }
704        JobVerb::Pause => {
705            command.args(["pause", &id]);
706        }
707        JobVerb::Resume => {
708            command.args(["resume", &id]);
709        }
710        JobVerb::Run => {
711            command.args(["run", &id]);
712        }
713        JobVerb::Delete => {
714            command.args(["remove", &id]);
715        }
716    }
717    Ok(command)
718}
719
720fn hermes_payload_refusal(kind: &str) -> JobControlError {
721    JobControlError::Unsupported(format!(
722        "hermes cron carries a `prompt` or a `--script` payload; it has no verb for a `{kind}` \
723         payload"
724    ))
725}
726
727fn payload_kind(payload: &JobPayloadSpec) -> &str {
728    if payload.kind.trim().is_empty() {
729        "prompt"
730    } else {
731        payload.kind.trim()
732    }
733}
734
735fn payload_text(payload: &JobPayloadSpec) -> Result<&str> {
736    payload
737        .text
738        .as_deref()
739        .filter(|text| !text.trim().is_empty())
740        .ok_or_else(|| {
741            JobControlError::Invalid(format!(
742                "a `{}` payload needs its text",
743                payload_kind(payload)
744            ))
745        })
746}
747
748// ---------------------------------------------------------------------------
749// OpenClaw — `openclaw cron …` through the Gateway
750// ---------------------------------------------------------------------------
751
752fn openclaw_command(verb: JobVerb, mutation: &JobMutation) -> Result<HarnessCommand> {
753    let connection = openclaw_connection(&mutation.homes)?;
754    let mut command = HarnessCommand::new(harness_program(HarnessId::OPENCLAW)?);
755    // Point the spawned CLI at the SAME state the read side addresses, using
756    // openclaw's own environment contract (`OPENCLAW_STATE_DIR` names the
757    // state dir; `OPENCLAW_CONFIG_PATH` names the config file inside it).
758    command.env(
759        "OPENCLAW_STATE_DIR",
760        mutation.homes.openclaw.to_string_lossy(),
761    );
762    command.env(
763        "OPENCLAW_CONFIG_PATH",
764        mutation
765            .homes
766            .openclaw
767            .join("openclaw.json")
768            .to_string_lossy(),
769    );
770    command.arg("cron");
771    let id = mutation.id.clone().unwrap_or_default();
772    match verb {
773        JobVerb::Create => {
774            command.arg("add");
775        }
776        JobVerb::Update => {
777            command.args(["edit", &id]);
778        }
779        // OpenClaw spells pause/resume `disable`/`enable`.
780        JobVerb::Pause => {
781            command.args(["disable", &id]);
782        }
783        JobVerb::Resume => {
784            command.args(["enable", &id]);
785        }
786        JobVerb::Run => {
787            command.args(["run", &id]);
788        }
789        JobVerb::Delete => {
790            command.args(["rm", &id]);
791        }
792    }
793    command.args(["--url", &connection.address]);
794    if let Some(token) = &connection.auth {
795        command.arg("--token");
796        command.secret(token.secret());
797    }
798    if matches!(verb, JobVerb::Create | JobVerb::Update) {
799        if let Some(name) = &mutation.name {
800            command.args(["--name", name]);
801        }
802        if let Some(schedule) = &mutation.schedule {
803            match schedule.kind.as_str() {
804                "interval" => {
805                    let minutes = schedule.minutes.ok_or_else(|| {
806                        JobControlError::Invalid("an interval schedule needs `minutes`".into())
807                    })?;
808                    command.args(["--every", &format!("{}m", trim_float(minutes))]);
809                }
810                "cron" => {
811                    let expr = schedule.expr.as_deref().ok_or_else(|| {
812                        JobControlError::Invalid("a cron schedule needs `expr`".into())
813                    })?;
814                    command.args(["--cron", expr]);
815                }
816                "once" => {
817                    let run_at = schedule.run_at.as_deref().ok_or_else(|| {
818                        JobControlError::Invalid("a once schedule needs `run_at`".into())
819                    })?;
820                    command.args(["--at", run_at]);
821                }
822                other => {
823                    return Err(JobControlError::Invalid(format!(
824                        "unknown schedule kind `{other}`; use interval, cron, or once"
825                    )))
826                }
827            }
828        }
829        if let Some(payload) = &mutation.payload {
830            match payload_kind(payload) {
831                "prompt" => {
832                    command.args(["--message", payload_text(payload)?]);
833                }
834                "system_event" => {
835                    command.args(["--system-event", payload_text(payload)?]);
836                }
837                "command" => {
838                    command.args(["--command", payload_text(payload)?]);
839                }
840                other => {
841                    return Err(JobControlError::Unsupported(format!(
842                        "openclaw cron carries `message`, `system-event` or `command` payloads; \
843                         it has no verb for a `{other}` payload"
844                    )))
845                }
846            }
847        }
848        if let Some(target) = &mutation.session_target {
849            command.args(["--session", target]);
850        }
851        if let Some(profile) = &mutation.profile {
852            command.args(["--agent", profile]);
853        }
854        if let Some(deliver) = &mutation.deliver {
855            openclaw_deliver(deliver, &mut command)?;
856        }
857        // Measured against the pin (receipt orch18-openclaw-jobs-receipt):
858        // `cron add|rm|list` accept `--json`, `cron edit` REJECTS it
859        // ("OpenClaw does not recognize option \"--json\""). The flag is only
860        // an id hint for create anyway — the answer always comes from the
861        // re-read.
862        if matches!(verb, JobVerb::Create) {
863            command.arg("--json");
864        }
865    } else if mutation.profile.is_some()
866        || mutation.session_target.is_some()
867        || mutation.deliver.is_some()
868        || mutation.name.is_some()
869        || mutation.schedule.is_some()
870        || mutation.payload.is_some()
871    {
872        return Err(JobControlError::Invalid(format!(
873            "`jobs.{}` changes no fields; pass definition fields to `jobs.update`",
874            verb.as_str()
875        )));
876    }
877    Ok(command)
878}
879
880/// OpenClaw's delivery flags. `target` is the delivery MODE the observed row
881/// reports (`announce` | `webhook` | `none`); `chat_id` is the destination.
882fn openclaw_deliver(deliver: &JobDeliverSpec, command: &mut HarnessCommand) -> Result<()> {
883    let Some(target) = deliver.target.as_deref().map(str::trim) else {
884        if let Some(chat) = deliver.chat_id.as_deref() {
885            command.args(["--to", chat]);
886        }
887        return Ok(());
888    };
889    match target {
890        "announce" => {
891            command.arg("--announce");
892            if let Some(chat) = deliver.chat_id.as_deref() {
893                command.args(["--to", chat]);
894            }
895        }
896        "webhook" => {
897            let url = deliver.chat_id.as_deref().ok_or_else(|| {
898                JobControlError::Invalid(
899                    "an openclaw `webhook` delivery needs the URL in `chat_id`".into(),
900                )
901            })?;
902            command.args(["--webhook", url]);
903        }
904        "none" => {
905            command.arg("--no-deliver");
906        }
907        other => {
908            return Err(JobControlError::Unsupported(format!(
909                "openclaw delivers `announce`, `webhook`, or `none`; it has no `{other}` delivery \
910                 mode"
911            )))
912        }
913    }
914    Ok(())
915}
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920
921    fn homes(root: &Path) -> HarnessHomes {
922        HarnessHomes {
923            hermes: root.join("hermes_home/state.db"),
924            openclaw: root.join("openclaw_home"),
925            ..HarnessHomes::default()
926        }
927    }
928
929    #[test]
930    fn the_program_comes_from_the_registry_launch() {
931        // Guard: the registry's hermes launch is the ACP BRIDGE (`hermes-acp`);
932        // the cron verb lives on the base CLI.
933        assert_eq!(harness_program(HarnessId::HERMES).unwrap(), "hermes");
934        assert_eq!(harness_program(HarnessId::OPENCLAW).unwrap(), "openclaw");
935    }
936
937    #[test]
938    fn claude_code_refuses_every_mutating_verb() {
939        let error = mutate(
940            JobVerb::Pause,
941            &JobMutation {
942                harness: HarnessId::CLAUDE_CODE.into(),
943                id: Some("release-watch".into()),
944                ..JobMutation::default()
945            },
946        )
947        .unwrap_err();
948        assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
949        assert!(error.to_string().contains("CronCreate"), "{error}");
950    }
951
952    #[test]
953    fn a_harness_without_jobs_refuses() {
954        let error = mutate(
955            JobVerb::Delete,
956            &JobMutation {
957                harness: "codex".into(),
958                id: Some("x".into()),
959                ..JobMutation::default()
960            },
961        )
962        .unwrap_err();
963        assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
964    }
965
966    #[test]
967    fn hermes_translates_the_uniform_row_onto_its_own_verb() {
968        let root = PathBuf::from("/tmp/orch18-unit");
969        let command = hermes_command(
970            JobVerb::Create,
971            &JobMutation {
972                harness: HarnessId::HERMES.into(),
973                name: Some("health".into()),
974                schedule: Some(JobScheduleSpec {
975                    kind: "interval".into(),
976                    minutes: Some(10.0),
977                    ..JobScheduleSpec::default()
978                }),
979                payload: Some(JobPayloadSpec {
980                    kind: "prompt".into(),
981                    text: Some("nightly health check".into()),
982                }),
983                deliver: Some(JobDeliverSpec {
984                    target: Some("local".into()),
985                    chat_id: None,
986                }),
987                homes: homes(&root),
988                ..JobMutation::default()
989            },
990        )
991        .unwrap();
992        assert_eq!(
993            command.narrate(),
994            "hermes cron create --name health --deliver local 'every 10m' 'nightly health check'"
995        );
996        assert_eq!(
997            command.env,
998            vec![(
999                "HERMES_HOME".to_string(),
1000                root.join("hermes_home").to_string_lossy().into_owned()
1001            )]
1002        );
1003    }
1004
1005    #[test]
1006    fn a_hermes_profile_is_its_own_home() {
1007        let root = PathBuf::from("/tmp/orch18-unit");
1008        let command = hermes_command(
1009            JobVerb::Pause,
1010            &JobMutation {
1011                harness: HarnessId::HERMES.into(),
1012                id: Some("abc".into()),
1013                profile: Some("ops".into()),
1014                homes: homes(&root),
1015                ..JobMutation::default()
1016            },
1017        )
1018        .unwrap();
1019        assert_eq!(command.narrate(), "hermes cron pause abc");
1020        assert_eq!(
1021            command.env[0].1,
1022            root.join("hermes_home/profiles/ops")
1023                .to_string_lossy()
1024                .into_owned()
1025        );
1026    }
1027
1028    #[test]
1029    fn hermes_refuses_a_field_it_has_no_verb_for() {
1030        let error = hermes_command(
1031            JobVerb::Update,
1032            &JobMutation {
1033                harness: HarnessId::HERMES.into(),
1034                id: Some("abc".into()),
1035                session_target: Some("isolated".into()),
1036                ..JobMutation::default()
1037            },
1038        )
1039        .unwrap_err();
1040        assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1041    }
1042
1043    /// ORC-13: the uniform row translated onto the ORCHESTRATOR's own job
1044    /// vocabulary (`docs/ORCHESTRATOR-IR.md` §2.6) — the typed schedule, the
1045    /// prompt, and Hermes's own `deliver` grammar.
1046    #[test]
1047    fn the_orchestrator_translates_the_uniform_row_onto_its_own_operator_args() {
1048        let args = orchestrator_args(
1049            JobVerb::Create,
1050            &JobMutation {
1051                harness: HarnessId::ORCHESTRATOR.into(),
1052                name: Some("health".into()),
1053                schedule: Some(JobScheduleSpec {
1054                    kind: "interval".into(),
1055                    minutes: Some(10.0),
1056                    ..JobScheduleSpec::default()
1057                }),
1058                payload: Some(JobPayloadSpec {
1059                    kind: "prompt".into(),
1060                    text: Some("nightly health check".into()),
1061                }),
1062                deliver: Some(JobDeliverSpec {
1063                    target: Some("loopback".into()),
1064                    chat_id: Some("ops-room".into()),
1065                }),
1066                ..JobMutation::default()
1067            },
1068        )
1069        .unwrap();
1070        assert_eq!(
1071            args,
1072            serde_json::json!({
1073                "name": "health",
1074                "schedule": {"kind": "interval", "minutes": 10.0},
1075                "prompt": "nightly health check",
1076                "deliver": "loopback:ops-room",
1077            })
1078        );
1079    }
1080
1081    /// A field the orchestrator's model has no home for is REFUSED, never
1082    /// dropped — the same rule the other two harnesses inherit.
1083    #[test]
1084    fn the_orchestrator_refuses_a_field_its_model_does_not_have() {
1085        for (mutation, needle) in [
1086            (
1087                JobMutation {
1088                    harness: HarnessId::ORCHESTRATOR.into(),
1089                    session_target: Some("isolated".into()),
1090                    ..JobMutation::default()
1091                },
1092                "no session-target field",
1093            ),
1094            (
1095                JobMutation {
1096                    harness: HarnessId::ORCHESTRATOR.into(),
1097                    payload: Some(JobPayloadSpec {
1098                        kind: "command".into(),
1099                        text: Some("ls".into()),
1100                    }),
1101                    ..JobMutation::default()
1102                },
1103                "there is no `command` payload",
1104            ),
1105        ] {
1106            let error = orchestrator_args(JobVerb::Create, &mutation).unwrap_err();
1107            assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1108            assert!(error.to_string().contains(needle), "{error}");
1109        }
1110        // A verb that sets no fields refuses definition fields outright.
1111        let error = orchestrator_args(
1112            JobVerb::Pause,
1113            &JobMutation {
1114                harness: HarnessId::ORCHESTRATOR.into(),
1115                id: Some("job_x".into()),
1116                name: Some("renamed".into()),
1117                ..JobMutation::default()
1118            },
1119        )
1120        .unwrap_err();
1121        assert!(matches!(error, JobControlError::Invalid(_)), "{error}");
1122    }
1123
1124    /// The root folder IS the `default` profile (§6), so an unnamed profile
1125    /// addresses it rather than defaulting to nothing.
1126    #[test]
1127    fn the_orchestrators_unnamed_profile_is_the_root_folder() {
1128        assert_eq!(
1129            orchestrator_profile(&JobMutation {
1130                harness: HarnessId::ORCHESTRATOR.into(),
1131                ..JobMutation::default()
1132            }),
1133            "default"
1134        );
1135        assert_eq!(
1136            orchestrator_profile(&JobMutation {
1137                harness: HarnessId::ORCHESTRATOR.into(),
1138                profile: Some("  coder ".into()),
1139                ..JobMutation::default()
1140            }),
1141            "coder"
1142        );
1143    }
1144
1145    #[test]
1146    fn openclaw_carries_the_gateway_endpoint_and_never_prints_the_token() {
1147        let root = std::env::temp_dir().join(format!(
1148            "supercode-orch18-unit-{}-{}",
1149            std::process::id(),
1150            std::time::SystemTime::now()
1151                .duration_since(std::time::UNIX_EPOCH)
1152                .unwrap()
1153                .as_nanos()
1154        ));
1155        let state = root.join("openclaw_home");
1156        std::fs::create_dir_all(&state).unwrap();
1157        std::fs::write(
1158            state.join("openclaw.json"),
1159            r#"{"gateway": {"port": 18999, "auth": {"token": "super-secret-token"}}}"#,
1160        )
1161        .unwrap();
1162        let command = openclaw_command(
1163            JobVerb::Create,
1164            &JobMutation {
1165                harness: HarnessId::OPENCLAW.into(),
1166                name: Some("digest".into()),
1167                schedule: Some(JobScheduleSpec {
1168                    kind: "cron".into(),
1169                    expr: Some("0 9 * * 1".into()),
1170                    ..JobScheduleSpec::default()
1171                }),
1172                payload: Some(JobPayloadSpec {
1173                    kind: "system_event".into(),
1174                    text: Some("weekly digest".into()),
1175                }),
1176                session_target: Some("main".into()),
1177                homes: homes(&root),
1178                ..JobMutation::default()
1179            },
1180        )
1181        .unwrap();
1182        assert_eq!(
1183            command.narrate(),
1184            "openclaw cron add --url ws://127.0.0.1:18999 --token <redacted> --name digest --cron \
1185             '0 9 * * 1' --system-event 'weekly digest' --session main --json"
1186        );
1187        assert_eq!(command.secrets, vec!["super-secret-token".to_string()]);
1188        assert!(
1189            !command.narrate().contains("super-secret-token"),
1190            "the credential must never be narrated"
1191        );
1192        std::fs::remove_dir_all(&root).ok();
1193    }
1194}