Skip to main content

supercode_harness/
jobs_apply.rs

1//! `jobs.apply`: an author's declared jobs reach a harness through the
2//! harness's own verbs (`docs/architecture/content-spec-status.md`).
3//!
4//! A spec is a set of [`JobSpec`]s keyed by name. `apply` reads what the
5//! harness has in the one store the request addresses (the root home, or the
6//! named profile's), keyed by each job's native `name`, then runs the
7//! controlled-tier verbs in [`crate::jobs_control`]: `create` for a name the
8//! store lacks, `update` with only the declared fields that differ for a name it
9//! has. It writes no harness file; every outcome is a verb's own re-read row.
10//!
11//! What `apply` never does:
12//!
13//! * delete — a job the spec does not name (the agent's, an operator's, one with
14//!   no name) stays, and is reported as `unnamed`;
15//! * guess — a declared name two existing jobs share is refused;
16//! * override an operator — `enabled` is the value at creation; a job whose
17//!   state differs from the spec is reported as `drift`, never paused or
18//!   resumed;
19//! * declare a one-shot — a `once` schedule is an act, not a standing
20//!   declaration, and is refused.
21//!
22//! Declared values are normalized the way Hermes stores them (skills trimmed
23//! and de-duplicated in order, model and provider trimmed, `~` expanded and
24//! paths canonical in `workdir`), so a spec that already holds is `unchanged`
25//! on every re-apply. A value Hermes would store differently in a way that
26//! cannot be normalized is refused, never re-edited forever.
27
28use std::collections::BTreeMap;
29use std::path::PathBuf;
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::jobs::{list_jobs, JobsQuery, ScheduledJob};
35use crate::jobs_control::{
36    hermes_deliver, mutate, supports_job_control, JobControlError, JobDeliverSpec, JobMutation,
37    JobMutationOutcome, JobPayloadSpec, JobScheduleSpec, JobVerb,
38};
39use crate::{HarnessHomes, HarnessId};
40
41/// One declared job: what the author wants to exist, never what the harness
42/// has made of it.
43#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct JobSpec {
46    /// The author's key. On Hermes it is the job's `name`.
47    pub name: String,
48    /// When it fires: `interval` or `cron`.
49    pub schedule: JobScheduleSpec,
50    /// The turn a fire sends to the agent.
51    #[serde(default)]
52    pub prompt: Option<String>,
53    /// Skills the fire loads.
54    #[serde(default)]
55    pub skills: Option<Vec<String>>,
56    /// Where the fire's output goes.
57    #[serde(default)]
58    pub deliver: Option<JobDeliverSpec>,
59    /// Model the job is pinned to.
60    #[serde(default)]
61    pub model: Option<String>,
62    /// Provider serving that model.
63    #[serde(default)]
64    pub provider: Option<String>,
65    /// Directory the fire runs from.
66    #[serde(default)]
67    pub workdir: Option<String>,
68    /// Fire this many times, then stop (at least 1; omit to fire forever).
69    #[serde(default)]
70    pub repeat: Option<u32>,
71    /// Whether the job fires once created. `false` pauses it right after the
72    /// create; an existing job's state is the operator's.
73    #[serde(default)]
74    pub enabled: Option<bool>,
75}
76
77/// One `jobs.apply` request.
78#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct JobsApply {
81    /// Harness the spec is applied to.
82    pub harness: String,
83    /// Hermes profile / OpenClaw agent the jobs belong to. Absent means the
84    /// root home only, never every profile.
85    pub profile: Option<String>,
86    /// The declared jobs.
87    pub jobs: Vec<JobSpec>,
88    /// Report what would run without running it.
89    pub plan: bool,
90    /// Storage roots.
91    pub homes: HarnessHomes,
92}
93
94/// A job named in an apply report.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct AppliedJob {
97    /// The declared or existing name (the id, for a job with no name).
98    pub name: String,
99    /// The harness's id, when a job exists.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub id: Option<String>,
102    /// What was (or would be) done, or why not.
103    pub detail: String,
104}
105
106/// What one `jobs.apply` did, every outcome the harness's own answer.
107#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
108pub struct JobsApplyOutcome {
109    /// Harness the spec was applied to.
110    pub harness: String,
111    /// Jobs created, each with the row the harness reports after the last verb
112    /// that touched it (the pause, for a job declared `enabled: false`).
113    pub created: Vec<JobMutationOutcome>,
114    /// Jobs edited, each with the row the harness reports afterwards.
115    pub updated: Vec<JobMutationOutcome>,
116    /// Declared jobs that already match.
117    pub unchanged: Vec<AppliedJob>,
118    /// Existing jobs the spec does not name, left as they are.
119    pub unnamed: Vec<AppliedJob>,
120    /// Existing jobs whose operator-owned state differs from the spec.
121    pub drift: Vec<AppliedJob>,
122    /// Declared jobs `apply` would not or could not act on, and why.
123    pub refused: Vec<AppliedJob>,
124    /// With `plan`, the verbs that would run.
125    pub planned: Vec<AppliedJob>,
126}
127
128fn store_error(error: impl std::fmt::Display) -> JobControlError {
129    JobControlError::Failed(format!("the job store could not be read: {error}"))
130}
131
132/// Apply a declared job set through the harness's own verbs.
133pub fn apply(request: &JobsApply) -> Result<JobsApplyOutcome, JobControlError> {
134    if !supports_job_control(&request.harness) {
135        return Err(JobControlError::Unsupported(format!(
136            "`{}` has no mutable scheduled jobs, so a job spec cannot be applied to it",
137            request.harness
138        )));
139    }
140    let mut seen = BTreeMap::new();
141    for spec in &request.jobs {
142        if spec.name.trim().is_empty() {
143            return Err(JobControlError::Invalid(
144                "every declared job needs a name".into(),
145            ));
146        }
147        if seen.insert(spec.name.as_str(), ()).is_some() {
148            return Err(JobControlError::Invalid(format!(
149                "the spec declares `{}` twice",
150                spec.name
151            )));
152        }
153    }
154    // The addressed store, as each harness names it: the orchestrator's root
155    // folder IS its `default` profile; an OpenClaw job always belongs to an
156    // agent, so which one must be said rather than guessed.
157    let mut request = request.clone();
158    if request.harness == HarnessId::ORCHESTRATOR && request.profile.as_deref() == Some("default") {
159        request.profile = None;
160    }
161    if request.harness == HarnessId::OPENCLAW && request.profile.is_none() {
162        return Err(JobControlError::Invalid(
163            "an OpenClaw job belongs to an agent; name it with `profile`".into(),
164        ));
165    }
166    let request = &request;
167    let listing = list_jobs(&JobsQuery {
168        harness: Some(request.harness.clone()),
169        profile: request.profile.clone(),
170        homes: request.homes.clone(),
171        ..JobsQuery::default()
172    })
173    .map_err(store_error)?;
174    let mut by_name: BTreeMap<String, Vec<(ScheduledJob, Value)>> = BTreeMap::new();
175    let mut outcome = JobsApplyOutcome {
176        harness: request.harness.clone(),
177        ..JobsApplyOutcome::default()
178    };
179    // Only the addressed store: with no profile, the root home's jobs, never a
180    // profile's (whose verbs would run against another HERMES_HOME).
181    for job in listing
182        .jobs
183        .into_iter()
184        .filter(|job| job.profile == request.profile)
185    {
186        let (_, native) = crate::jobs::get_job(&request.harness, &job.id, &request.homes)
187            .map_err(store_error)?
188            .ok_or_else(|| store_error(format!("job `{}` vanished while reading", job.id)))?;
189        match native.get("name").and_then(Value::as_str) {
190            Some(name) => by_name
191                .entry(name.to_string())
192                .or_default()
193                .push((job, native)),
194            None => outcome.unnamed.push(AppliedJob {
195                name: job.id.clone(),
196                id: Some(job.id),
197                detail: "has no name, so no declaration can address it; left as it is".into(),
198            }),
199        }
200    }
201    for spec in &request.jobs {
202        let spec = match normalize(&request.harness, spec) {
203            Ok(spec) => spec,
204            Err(reason) => {
205                outcome.refused.push(AppliedJob {
206                    name: spec.name.clone(),
207                    id: None,
208                    detail: reason,
209                });
210                continue;
211            }
212        };
213        match by_name.remove(&spec.name).unwrap_or_default().as_slice() {
214            [] => create(request, &spec, &mut outcome),
215            [(job, native)] => update(request, &spec, job, native, &mut outcome),
216            several => outcome.refused.push(AppliedJob {
217                name: spec.name.clone(),
218                id: None,
219                detail: format!(
220                    "{} existing jobs share this name ({}); apply refuses rather than guess which one the spec means",
221                    several.len(),
222                    several
223                        .iter()
224                        .map(|(job, _)| job.id.as_str())
225                        .collect::<Vec<_>>()
226                        .join(", ")
227                ),
228            }),
229        }
230    }
231    for (name, jobs) in by_name {
232        for (job, _) in jobs {
233            outcome.unnamed.push(AppliedJob {
234                name: name.clone(),
235                id: Some(job.id),
236                detail: "not in the spec; left as it is".into(),
237            });
238        }
239    }
240    Ok(outcome)
241}
242
243/// The declaration as the harness would store it, or why it cannot be applied.
244/// Everything here is predictable, so `plan` refuses exactly what apply would.
245fn normalize(harness: &str, spec: &JobSpec) -> Result<JobSpec, String> {
246    let mut spec = spec.clone();
247    match spec.schedule.kind.as_str() {
248        "interval"
249            if harness == HarnessId::HERMES
250                && spec
251                    .schedule
252                    .minutes
253                    .is_some_and(|minutes| minutes.fract() != 0.0) =>
254        {
255            return Err("Hermes schedules whole minutes; `minutes` must be a whole number".into())
256        }
257        "interval" if spec.schedule.minutes.is_some_and(|minutes| minutes > 0.0) => {}
258        "interval" => return Err("an interval schedule needs `minutes` above zero".into()),
259        "cron"
260            if spec
261                .schedule
262                .expr
263                .as_deref()
264                .is_some_and(|e| !e.trim().is_empty()) => {}
265        "cron" => return Err("a cron schedule needs `expr`".into()),
266        "once" => return Err(
267            "a `once` schedule is an act, not a standing declaration; create it with `jobs create`"
268                .into(),
269        ),
270        other => {
271            return Err(format!(
272                "unknown schedule kind `{other}`; use interval or cron"
273            ))
274        }
275    }
276    if let Some(deliver) = &spec.deliver {
277        if deliver
278            .target
279            .as_deref()
280            .is_none_or(|t| t.trim().is_empty())
281        {
282            return Err("`deliver` needs a target; a chat id alone names no destination".into());
283        }
284    }
285    if spec.repeat == Some(0) {
286        return Err("`repeat` must be at least 1; omit it to fire forever".into());
287    }
288    let trim = |value: &mut Option<String>| {
289        *value = value
290            .as_deref()
291            .map(str::trim)
292            .filter(|v| !v.is_empty())
293            .map(String::from);
294    };
295    trim(&mut spec.model);
296    trim(&mut spec.provider);
297    if let Some(skills) = &spec.skills {
298        let mut kept: Vec<String> = Vec::new();
299        for skill in skills.iter().map(|s| s.trim()).filter(|s| !s.is_empty()) {
300            if !kept.iter().any(|k| k == skill) {
301                kept.push(skill.to_string());
302            }
303        }
304        spec.skills = Some(kept);
305    }
306    if harness != HarnessId::HERMES {
307        let hermes_only = [
308            (
309                "skills",
310                spec.skills.as_ref().is_some_and(|s| !s.is_empty()),
311            ),
312            ("workdir", spec.workdir.is_some()),
313            ("model", spec.model.is_some()),
314            ("provider", spec.provider.is_some()),
315            ("repeat", spec.repeat.is_some()),
316        ]
317        .into_iter()
318        .find_map(|(field, set)| set.then_some(field));
319        if let Some(field) = hermes_only {
320            return Err(format!(
321                "{harness} has no job verb that sets `{field}`, so supercode refuses rather than dropping it"
322            ));
323        }
324        spec.skills = None;
325    }
326    Ok(spec)
327}
328
329fn base_mutation(request: &JobsApply) -> JobMutation {
330    JobMutation {
331        harness: request.harness.clone(),
332        profile: request.profile.clone(),
333        homes: request.homes.clone(),
334        ..JobMutation::default()
335    }
336}
337
338fn create(request: &JobsApply, spec: &JobSpec, outcome: &mut JobsApplyOutcome) {
339    let mutation = JobMutation {
340        name: Some(spec.name.clone()),
341        schedule: Some(spec.schedule.clone()),
342        payload: spec.prompt.as_ref().map(|text| JobPayloadSpec {
343            kind: "prompt".into(),
344            text: Some(text.clone()),
345        }),
346        deliver: spec.deliver.clone(),
347        skills: spec.skills.clone().filter(|skills| !skills.is_empty()),
348        model: spec.model.clone(),
349        provider: spec.provider.clone(),
350        workdir: spec.workdir.clone(),
351        repeat: spec.repeat,
352        ..base_mutation(request)
353    };
354    let pause = spec.enabled == Some(false);
355    if request.plan {
356        outcome.planned.push(AppliedJob {
357            name: spec.name.clone(),
358            id: None,
359            detail: if pause {
360                "create, then pause"
361            } else {
362                "create"
363            }
364            .into(),
365        });
366        return;
367    }
368    let created = match mutate(JobVerb::Create, &mutation) {
369        Ok(created) => created,
370        Err(error) => {
371            outcome.refused.push(AppliedJob {
372                name: spec.name.clone(),
373                id: None,
374                detail: error.to_string(),
375            });
376            return;
377        }
378    };
379    if !pause {
380        outcome.created.push(created);
381        return;
382    }
383    let id = created.id.clone();
384    let pause = JobMutation {
385        id: Some(id.clone()),
386        ..base_mutation(request)
387    };
388    match mutate(JobVerb::Pause, &pause) {
389        // The pause's re-read row is the job as it now stands; the command
390        // shown is the create that made it and the pause that followed.
391        Ok(paused) => outcome.created.push(JobMutationOutcome {
392            verb: created.verb,
393            ran: format!("{}; {}", created.ran, paused.ran),
394            ..paused
395        }),
396        Err(error) => {
397            outcome.created.push(created);
398            outcome.refused.push(AppliedJob {
399                name: spec.name.clone(),
400                id: Some(id),
401                detail: format!("created, but the pause the spec declares failed: {error}"),
402            });
403        }
404    }
405}
406
407/// `~` expanded and the path canonical where it exists: how Hermes stores it.
408fn canonical_dir(path: &str) -> String {
409    let expanded = match path.strip_prefix("~") {
410        Some(rest) if rest.is_empty() || rest.starts_with('/') => std::env::var_os("HOME")
411            .map(|home| format!("{}{rest}", PathBuf::from(home).display()))
412            .unwrap_or_else(|| path.to_string()),
413        _ => path.to_string(),
414    };
415    std::fs::canonicalize(&expanded)
416        .map(|p| p.to_string_lossy().into_owned())
417        .unwrap_or(expanded)
418}
419
420fn update(
421    request: &JobsApply,
422    spec: &JobSpec,
423    job: &ScheduledJob,
424    native: &Value,
425    outcome: &mut JobsApplyOutcome,
426) {
427    let mut mutation = JobMutation {
428        id: Some(job.id.clone()),
429        ..base_mutation(request)
430    };
431    let mut changed = Vec::new();
432    if !same_schedule(&spec.schedule, job) {
433        mutation.schedule = Some(spec.schedule.clone());
434        changed.push("schedule");
435    }
436    if let Some(prompt) = &spec.prompt {
437        // The prompt lives in its own field; a script job's payload text is
438        // the script, not the prompt.
439        let have = native.get("prompt").and_then(Value::as_str).or_else(|| {
440            (job.payload.kind == "prompt")
441                .then_some(job.payload.text.as_deref())
442                .flatten()
443        });
444        if have != Some(prompt.as_str()) {
445            mutation.payload = Some(JobPayloadSpec {
446                kind: "prompt".into(),
447                text: Some(prompt.clone()),
448            });
449            changed.push("prompt");
450        }
451    }
452    if let Some(deliver) = &spec.deliver {
453        // Compare the destination as the harness spells it (`telegram:123`).
454        let declared = if request.harness == HarnessId::HERMES {
455            hermes_deliver(deliver)
456        } else {
457            deliver.target.clone()
458        };
459        if declared != job.deliver.target {
460            mutation.deliver = Some(deliver.clone());
461            changed.push("deliver");
462        }
463    }
464    if let Some(skills) = &spec.skills {
465        let have: Vec<String> = native
466            .get("skills")
467            .and_then(Value::as_array)
468            .map(|skills| {
469                skills
470                    .iter()
471                    .filter_map(|s| s.as_str().map(String::from))
472                    .collect()
473            })
474            .unwrap_or_default();
475        if &have != skills {
476            mutation.skills = Some(skills.clone());
477            changed.push("skills");
478        }
479    }
480    if let Some(workdir) = &spec.workdir {
481        let have = native
482            .get("workdir")
483            .and_then(Value::as_str)
484            .map(canonical_dir);
485        if have.as_deref() != Some(canonical_dir(workdir).as_str()) {
486            mutation.workdir = Some(workdir.clone());
487            changed.push("workdir");
488        }
489    }
490    for (field, declared, slot) in [
491        ("model", &spec.model, &mut mutation.model),
492        ("provider", &spec.provider, &mut mutation.provider),
493    ] {
494        if let Some(declared) = declared {
495            if native.get(field).and_then(Value::as_str).map(str::trim) != Some(declared.as_str()) {
496                *slot = Some(declared.clone());
497                changed.push(field);
498            }
499        }
500    }
501    if let Some(repeat) = spec.repeat {
502        let have = native
503            .get("repeat")
504            .and_then(|repeat| repeat.get("times").or(Some(repeat)))
505            .and_then(Value::as_u64);
506        if have != Some(u64::from(repeat)) {
507            mutation.repeat = Some(repeat);
508            changed.push("repeat");
509        }
510    }
511    if let Some(enabled) = spec.enabled {
512        if enabled != job.enabled {
513            outcome.drift.push(AppliedJob {
514                name: spec.name.clone(),
515                id: Some(job.id.clone()),
516                detail: format!(
517                    "the spec declares enabled={enabled}; the job is {} (an operator's state stands)",
518                    job.state
519                ),
520            });
521        }
522    }
523    if changed.is_empty() {
524        outcome.unchanged.push(AppliedJob {
525            name: spec.name.clone(),
526            id: Some(job.id.clone()),
527            detail: "matches the spec".into(),
528        });
529        return;
530    }
531    if request.plan {
532        outcome.planned.push(AppliedJob {
533            name: spec.name.clone(),
534            id: Some(job.id.clone()),
535            detail: format!("update {}", changed.join(", ")),
536        });
537        return;
538    }
539    match mutate(JobVerb::Update, &mutation) {
540        Ok(updated) => outcome.updated.push(updated),
541        Err(error) => outcome.refused.push(AppliedJob {
542            name: spec.name.clone(),
543            id: Some(job.id.clone()),
544            detail: error.to_string(),
545        }),
546    }
547}
548
549/// Whether the harness's parsed schedule is the declared one.
550fn same_schedule(spec: &JobScheduleSpec, job: &ScheduledJob) -> bool {
551    let have = &job.schedule;
552    if spec.kind != have.kind {
553        return false;
554    }
555    match spec.kind.as_str() {
556        "interval" => spec.minutes == have.minutes,
557        "cron" => spec.expr.as_deref().map(str::trim) == have.expr.as_deref().map(str::trim),
558        _ => false,
559    }
560}