1use 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
55pub const CONTROLLED_JOB_HARNESSES: &[&str] = &[
59 HarnessId::HERMES,
60 HarnessId::OPENCLAW,
61 HarnessId::ORCHESTRATOR,
62];
63
64pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum JobVerb {
74 Create,
76 Update,
78 Pause,
80 Resume,
82 Run,
84 Delete,
86}
87
88impl JobVerb {
89 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 const fn needs_id(self) -> bool {
103 !matches!(self, Self::Create)
104 }
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct JobScheduleSpec {
111 pub kind: String,
113 pub minutes: Option<f64>,
115 pub expr: Option<String>,
117 pub run_at: Option<String>,
119}
120
121#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(default)]
124pub struct JobPayloadSpec {
125 pub kind: String,
127 pub text: Option<String>,
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(default, deny_unknown_fields)]
134pub struct JobDeliverSpec {
135 pub target: Option<String>,
138 pub chat_id: Option<String>,
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
144pub struct JobMutation {
145 pub harness: String,
147 #[serde(default)]
149 pub id: Option<String>,
150 #[serde(default)]
152 pub name: Option<String>,
153 #[serde(default)]
155 pub schedule: Option<JobScheduleSpec>,
156 #[serde(default)]
158 pub payload: Option<JobPayloadSpec>,
159 #[serde(default)]
161 pub session_target: Option<String>,
162 #[serde(default)]
164 pub deliver: Option<JobDeliverSpec>,
165 #[serde(default)]
167 pub profile: Option<String>,
168 #[serde(default)]
171 pub skills: Option<Vec<String>>,
172 #[serde(default)]
174 pub workdir: Option<String>,
175 #[serde(default)]
177 pub model: Option<String>,
178 #[serde(default)]
180 pub provider: Option<String>,
181 #[serde(default)]
183 pub repeat: Option<u32>,
184 #[serde(default)]
187 pub homes: HarnessHomes,
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct JobMutationOutcome {
193 pub harness: String,
195 pub verb: String,
197 pub ran: String,
199 pub id: String,
201 #[serde(skip_serializing_if = "Option::is_none")]
204 pub job: Option<ScheduledJob>,
205 #[serde(skip_serializing_if = "Option::is_none")]
207 pub deleted: Option<bool>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum JobControlError {
213 Unsupported(String),
215 Invalid(String),
217 Failed(String),
219}
220
221impl std::fmt::Display for JobControlError {
222 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 match self {
224 Self::Unsupported(message) | Self::Invalid(message) | Self::Failed(message) => {
225 formatter.write_str(message)
226 }
227 }
228 }
229}
230
231impl std::error::Error for JobControlError {}
232
233type Result<T> = std::result::Result<T, JobControlError>;
234
235pub fn supports_job_control(harness: &str) -> bool {
237 CONTROLLED_JOB_HARNESSES.contains(&harness)
238}
239
240pub fn harness_program(harness: &str) -> Result<String> {
241 crate::harness_command::harness_program(harness).map_err(|detail| {
242 JobControlError::Unsupported(detail.unwrap_or_else(|| unsupported_harness(harness)))
243 })
244}
245
246fn unsupported_harness(harness: &str) -> String {
247 if harness == HarnessId::CLAUDE_CODE {
248 return CLAUDE_CODE_REFUSAL.to_string();
249 }
250 format!(
251 "`{harness}` has no mutable scheduled jobs; mutating job verbs are supported for: {}",
252 CONTROLLED_JOB_HARNESSES.join(", ")
253 )
254}
255
256pub(crate) fn hermes_home(mutation: &JobMutation) -> PathBuf {
259 let root = mutation
262 .homes
263 .hermes
264 .parent()
265 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
266 match mutation.profile.as_deref() {
267 Some(profile) => root.join("profiles").join(profile),
268 None => root,
269 }
270}
271
272fn openclaw_connection(homes: &HarnessHomes) -> Result<crate::ResolvedRuntimeConnection> {
282 let registry = crate::harness_support_registry();
283 let descriptor = registry
284 .harnesses
285 .iter()
286 .find(|descriptor| descriptor.id.as_str() == HarnessId::OPENCLAW)
287 .ok_or_else(|| {
288 JobControlError::Unsupported("the registry has no openclaw descriptor".into())
289 })?;
290 let connect = descriptor.runtime.connect_launch.as_ref().ok_or_else(|| {
291 JobControlError::Unsupported(
292 "openclaw has no registered connect-mode launch, so its gateway cannot be located"
293 .into(),
294 )
295 })?;
296 let mut connect = connect.clone();
297 connect.config_path = homes
298 .openclaw
299 .join("openclaw.json")
300 .to_string_lossy()
301 .into_owned();
302 connect.resolve(Path::new("/")).map_err(|error| {
303 JobControlError::Unsupported(format!(
304 "openclaw's gateway endpoint could not be resolved from its own config: {error}"
305 ))
306 })
307}
308
309pub fn mutate(verb: JobVerb, mutation: &JobMutation) -> Result<JobMutationOutcome> {
312 if !supports_job_control(&mutation.harness) {
313 return Err(JobControlError::Unsupported(unsupported_harness(
314 &mutation.harness,
315 )));
316 }
317 if verb.needs_id() && mutation.id.as_deref().unwrap_or("").trim().is_empty() {
318 return Err(JobControlError::Invalid(format!(
319 "`jobs.{}` needs the job id to act on",
320 verb.as_str()
321 )));
322 }
323 if matches!(verb, JobVerb::Create) && mutation.schedule.is_none() {
324 return Err(JobControlError::Invalid(
325 "`jobs.create` needs a schedule (interval, cron, or once)".into(),
326 ));
327 }
328 if mutation.harness != HarnessId::HERMES {
329 if let Some(field) = hermes_only_field(mutation) {
330 return Err(JobControlError::Unsupported(format!(
331 "{} has no job verb that sets `{field}`, so supercode refuses rather than dropping it",
332 mutation.harness
333 )));
334 }
335 }
336 if mutation.harness == HarnessId::ORCHESTRATOR {
339 return orchestrator_mutate(verb, mutation);
340 }
341 let command = match mutation.harness.as_str() {
342 HarnessId::HERMES => hermes_command(verb, mutation)?,
343 HarnessId::OPENCLAW => openclaw_command(verb, mutation)?,
344 other => return Err(JobControlError::Unsupported(unsupported_harness(other))),
345 };
346 let ran = command.narrate();
347 let before = matches!(verb, JobVerb::Create).then(|| known_ids(mutation));
348 let stdout = command.run().map_err(JobControlError::Failed)?;
349 if mutation.harness == HarnessId::HERMES {
353 if let Some(said) = hermes_failure(&stdout) {
354 return Err(JobControlError::Failed(format!(
355 "`{ran}` exited 0 but hermes said: {said}"
356 )));
357 }
358 }
359 let id = match (verb, before) {
360 (JobVerb::Create, Some(before)) => created_id(mutation, &before, &stdout, &ran)?,
361 _ => mutation.id.clone().unwrap_or_default(),
362 };
363 let read = crate::jobs::get_job(&mutation.harness, &id, &mutation.homes).map_err(|error| {
365 JobControlError::Failed(format!(
366 "`{ran}` succeeded but the job store could not be re-read: {error}"
367 ))
368 })?;
369 match verb {
370 JobVerb::Delete => {
371 if read.is_some() {
372 return Err(JobControlError::Failed(format!(
373 "`{ran}` reported success but `{id}` is still in {}'s job store",
374 mutation.harness
375 )));
376 }
377 Ok(JobMutationOutcome {
378 harness: mutation.harness.clone(),
379 verb: verb.as_str().to_string(),
380 ran,
381 id,
382 job: None,
383 deleted: Some(true),
384 })
385 }
386 _ => {
387 let (job, _) = read.ok_or_else(|| {
388 JobControlError::Failed(format!(
389 "`{ran}` reported success but `{}` has no job `{id}` afterwards",
390 mutation.harness
391 ))
392 })?;
393 Ok(JobMutationOutcome {
394 harness: mutation.harness.clone(),
395 verb: verb.as_str().to_string(),
396 ran,
397 id,
398 job: Some(job),
399 deleted: None,
400 })
401 }
402 }
403}
404
405fn orchestrator_profile(mutation: &JobMutation) -> &str {
412 mutation
413 .profile
414 .as_deref()
415 .map(str::trim)
416 .filter(|profile| !profile.is_empty())
417 .unwrap_or("default")
418}
419
420fn orchestrator_args(verb: JobVerb, mutation: &JobMutation) -> Result<Value> {
425 let mut args = serde_json::Map::new();
426 if let Some(id) = mutation.id.as_deref().filter(|id| !id.trim().is_empty()) {
427 args.insert("id".into(), Value::String(id.trim().to_string()));
428 }
429 if matches!(verb, JobVerb::Create | JobVerb::Update) {
430 if mutation.session_target.is_some() {
431 return Err(JobControlError::Unsupported(
432 "an orchestrator cron fire opens its own binding on the job's origin surface \
433 (`docs/ORCHESTRATOR-IR.md` §4.3); the model has no session-target field, so \
434 supercode refuses rather than dropping it"
435 .into(),
436 ));
437 }
438 if let Some(name) = &mutation.name {
439 args.insert("name".into(), Value::String(name.clone()));
440 }
441 if let Some(schedule) = &mutation.schedule {
442 args.insert("schedule".into(), orchestrator_schedule(schedule)?);
443 }
444 if let Some(payload) = &mutation.payload {
445 match payload_kind(payload) {
446 "prompt" => {
447 args.insert(
448 "prompt".into(),
449 Value::String(payload_text(payload)?.to_string()),
450 );
451 }
452 other => {
453 return Err(JobControlError::Unsupported(format!(
454 "an orchestrator job carries a `prompt` — the fire opens a worker session \
455 and sends it (§4.3); there is no `{other}` payload, so supercode refuses \
456 rather than inventing one"
457 )))
458 }
459 }
460 }
461 if let Some(deliver) = &mutation.deliver {
462 if let Some(target) = hermes_deliver(deliver) {
463 args.insert("deliver".into(), Value::String(target));
466 }
467 }
468 } else if mutation.name.is_some()
469 || mutation.schedule.is_some()
470 || mutation.payload.is_some()
471 || mutation.deliver.is_some()
472 || mutation.session_target.is_some()
473 {
474 return Err(JobControlError::Invalid(format!(
475 "`jobs.{}` changes no fields; pass definition fields to `jobs.update`",
476 verb.as_str()
477 )));
478 }
479 Ok(Value::Object(args))
480}
481
482fn orchestrator_schedule(schedule: &JobScheduleSpec) -> Result<Value> {
484 match schedule.kind.as_str() {
485 "interval" => schedule
486 .minutes
487 .map(|minutes| serde_json::json!({"kind": "interval", "minutes": minutes}))
488 .ok_or_else(|| JobControlError::Invalid("an interval schedule needs `minutes`".into())),
489 "cron" => schedule
490 .expr
491 .as_deref()
492 .map(|expr| serde_json::json!({"kind": "cron", "expr": expr}))
493 .ok_or_else(|| JobControlError::Invalid("a cron schedule needs `expr`".into())),
494 "once" => schedule
495 .run_at
496 .as_deref()
497 .map(|run_at| serde_json::json!({"kind": "once", "run_at": run_at}))
498 .ok_or_else(|| JobControlError::Invalid("a once schedule needs `run_at`".into())),
499 other => Err(JobControlError::Invalid(format!(
500 "unknown schedule kind `{other}`; use interval, cron, or once"
501 ))),
502 }
503}
504
505fn orchestrator_mutate(verb: JobVerb, mutation: &JobMutation) -> Result<JobMutationOutcome> {
508 let args = orchestrator_args(verb, mutation)?;
509 let root = mutation.homes.orchestrator.clone();
510 let profile = orchestrator_profile(mutation);
511 let op = format!("jobs.{}", verb.as_str());
512 let answer = crate::orchestrator_door::call(&root, &op, &args, profile).map_err(|error| {
513 match error {
514 crate::orchestrator_door::DoorError::Refused(message) => {
517 JobControlError::Failed(message)
518 }
519 crate::orchestrator_door::DoorError::Failed(message) => {
520 JobControlError::Failed(message)
521 }
522 }
523 })?;
524 let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
525 let id = answer
526 .result
527 .pointer("/job_id")
528 .and_then(Value::as_str)
529 .map(str::to_string)
530 .or_else(|| mutation.id.clone())
531 .ok_or_else(|| JobControlError::Failed(format!("`{ran}` succeeded but named no job id")))?;
532 let read = crate::jobs::get_job(&mutation.harness, &id, &mutation.homes).map_err(|error| {
535 JobControlError::Failed(format!(
536 "`{ran}` succeeded but the job store could not be re-read: {error}"
537 ))
538 })?;
539 match verb {
540 JobVerb::Delete => {
541 if read.is_some() {
542 return Err(JobControlError::Failed(format!(
543 "`{ran}` reported success but `{id}` is still in the orchestrator's job store"
544 )));
545 }
546 Ok(JobMutationOutcome {
547 harness: mutation.harness.clone(),
548 verb: verb.as_str().to_string(),
549 ran,
550 id,
551 job: None,
552 deleted: Some(true),
553 })
554 }
555 _ => {
556 let (job, _) = read.ok_or_else(|| {
557 JobControlError::Failed(format!(
558 "`{ran}` reported success but the orchestrator has no job `{id}` afterwards"
559 ))
560 })?;
561 Ok(JobMutationOutcome {
562 harness: mutation.harness.clone(),
563 verb: verb.as_str().to_string(),
564 ran,
565 id,
566 job: Some(job),
567 deleted: None,
568 })
569 }
570 }
571}
572
573fn known_ids(mutation: &JobMutation) -> BTreeSet<String> {
575 crate::jobs::list_jobs(&crate::jobs::JobsQuery {
576 harness: Some(mutation.harness.clone()),
577 homes: mutation.homes.clone(),
578 ..crate::jobs::JobsQuery::default()
579 })
580 .map(|listing| listing.jobs.into_iter().map(|job| job.id).collect())
581 .unwrap_or_default()
582}
583
584fn created_id(
588 mutation: &JobMutation,
589 before: &BTreeSet<String>,
590 stdout: &str,
591 ran: &str,
592) -> Result<String> {
593 let after = known_ids(mutation);
594 let mut fresh: Vec<String> = after.difference(before).cloned().collect();
595 if fresh.len() == 1 {
596 return Ok(fresh.remove(0));
597 }
598 if let Some(named) = fresh.iter().find(|id| stdout.contains(id.as_str())) {
599 return Ok(named.clone());
600 }
601 if let Some(id) = stdout_id(stdout).filter(|id| after.contains(id)) {
604 return Ok(id);
605 }
606 let said = stdout.trim();
609 Err(JobControlError::Failed(format!(
610 "`{ran}` reported success but {} gained {} job(s), so the new job cannot be identified{}",
611 mutation.harness,
612 fresh.len(),
613 if said.is_empty() {
614 String::new()
615 } else {
616 format!("; {} said: {said}", mutation.harness)
617 }
618 )))
619}
620
621fn stdout_id(stdout: &str) -> Option<String> {
623 let value: Value = serde_json::from_str(stdout.trim()).ok()?;
624 for pointer in ["/id", "/job/id", "/jobId", "/job_id", "/result/id"] {
625 if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
626 return Some(id.to_string());
627 }
628 }
629 None
630}
631
632fn hermes_schedule(schedule: &JobScheduleSpec) -> Result<String> {
640 match schedule.kind.as_str() {
641 "interval" => schedule
642 .minutes
643 .map(|minutes| format!("every {}m", trim_float(minutes)))
644 .ok_or_else(|| JobControlError::Invalid("an interval schedule needs `minutes`".into())),
645 "cron" => schedule
646 .expr
647 .clone()
648 .ok_or_else(|| JobControlError::Invalid("a cron schedule needs `expr`".into())),
649 "once" => schedule
650 .run_at
651 .clone()
652 .ok_or_else(|| JobControlError::Invalid("a once schedule needs `run_at`".into())),
653 other => Err(JobControlError::Invalid(format!(
654 "unknown schedule kind `{other}`; use interval, cron, or once"
655 ))),
656 }
657}
658
659fn trim_float(value: f64) -> String {
660 if value.fract().abs() < f64::EPSILON {
661 format!("{}", value as i64)
662 } else {
663 format!("{value}")
664 }
665}
666
667pub(crate) fn hermes_deliver(deliver: &JobDeliverSpec) -> Option<String> {
670 let target = deliver.target.as_deref()?.trim().to_string();
671 match deliver.chat_id.as_deref() {
672 Some(chat) if !target.contains(':') && !chat.trim().is_empty() => {
673 Some(format!("{target}:{}", chat.trim()))
674 }
675 _ => Some(target),
676 }
677}
678
679fn hermes_only_field(mutation: &JobMutation) -> Option<&'static str> {
682 [
683 ("skills", mutation.skills.is_some()),
684 ("workdir", mutation.workdir.is_some()),
685 ("model", mutation.model.is_some()),
686 ("provider", mutation.provider.is_some()),
687 ("repeat", mutation.repeat.is_some()),
688 ]
689 .into_iter()
690 .find_map(|(field, set)| set.then_some(field))
691}
692
693fn hermes_definition_args(verb: JobVerb, mutation: &JobMutation, command: &mut HarnessCommand) {
696 if let Some(skills) = &mutation.skills {
697 if skills.is_empty() && matches!(verb, JobVerb::Update) {
698 command.arg("--clear-skills");
699 }
700 for skill in skills {
701 command.args(["--skill", skill]);
702 }
703 }
704 if let Some(workdir) = &mutation.workdir {
705 command.args(["--workdir", workdir]);
706 }
707 if let Some(model) = &mutation.model {
708 command.args(["--model", model]);
709 }
710 if let Some(provider) = &mutation.provider {
711 command.args(["--provider", provider]);
712 }
713 if let Some(repeat) = mutation.repeat {
714 command.args(["--repeat", &repeat.to_string()]);
715 }
716}
717
718fn hermes_failure(stdout: &str) -> Option<&str> {
720 stdout
721 .lines()
722 .map(str::trim)
723 .find(|line| line.starts_with("Failed to ") || line.starts_with("Job not found"))
724}
725
726fn hermes_command(verb: JobVerb, mutation: &JobMutation) -> Result<HarnessCommand> {
727 if let Some(deliver) = &mutation.deliver {
728 if deliver
729 .target
730 .as_deref()
731 .is_none_or(|t| t.trim().is_empty())
732 && deliver.chat_id.is_some()
733 {
734 return Err(JobControlError::Invalid(
735 "a delivery needs a target; a chat id alone names no destination, so supercode \
736 refuses rather than dropping it"
737 .into(),
738 ));
739 }
740 }
741 if mutation.session_target.is_some() {
742 return Err(JobControlError::Unsupported(
743 "hermes cron fires always open their own `platform=cron` session; hermes has no \
744 session-target verb, so supercode refuses rather than dropping the field"
745 .into(),
746 ));
747 }
748 let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
749 command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
750 command.args(["cron"]);
751 let id = mutation.id.clone().unwrap_or_default();
752 match verb {
753 JobVerb::Create => {
754 command.arg("create");
755 if let Some(name) = &mutation.name {
756 command.args(["--name", name]);
757 }
758 if let Some(deliver) = mutation.deliver.as_ref().and_then(hermes_deliver) {
759 command.args(["--deliver", &deliver]);
760 }
761 if let Some(payload) = &mutation.payload {
762 if payload_kind(payload) == "script" {
763 command.args(["--script", payload_text(payload)?]);
764 }
765 }
766 hermes_definition_args(verb, mutation, &mut command);
767 let schedule = hermes_schedule(
769 mutation
770 .schedule
771 .as_ref()
772 .expect("create validates a schedule"),
773 )?;
774 command.arg(schedule);
775 if let Some(payload) = &mutation.payload {
776 match payload_kind(payload) {
777 "prompt" => {
778 command.arg(payload_text(payload)?);
779 }
780 "script" => {}
781 other => return Err(hermes_payload_refusal(other)),
782 }
783 }
784 }
785 JobVerb::Update => {
786 command.args(["edit", &id]);
787 if let Some(schedule) = &mutation.schedule {
788 command.args(["--schedule", &hermes_schedule(schedule)?]);
789 }
790 if let Some(name) = &mutation.name {
791 command.args(["--name", name]);
792 }
793 if let Some(deliver) = mutation.deliver.as_ref().and_then(hermes_deliver) {
794 command.args(["--deliver", &deliver]);
795 }
796 hermes_definition_args(verb, mutation, &mut command);
797 if let Some(payload) = &mutation.payload {
798 match payload_kind(payload) {
799 "prompt" => {
800 command.args(["--prompt", payload_text(payload)?]);
801 }
802 "script" => {
803 command.args(["--script", payload_text(payload)?]);
804 }
805 other => return Err(hermes_payload_refusal(other)),
806 }
807 }
808 }
809 JobVerb::Pause => {
810 command.args(["pause", &id]);
811 }
812 JobVerb::Resume => {
813 command.args(["resume", &id]);
814 }
815 JobVerb::Run => {
816 command.args(["run", &id]);
817 }
818 JobVerb::Delete => {
819 command.args(["remove", &id]);
820 }
821 }
822 Ok(command)
823}
824
825fn hermes_payload_refusal(kind: &str) -> JobControlError {
826 JobControlError::Unsupported(format!(
827 "hermes cron carries a `prompt` or a `--script` payload; it has no verb for a `{kind}` \
828 payload"
829 ))
830}
831
832fn payload_kind(payload: &JobPayloadSpec) -> &str {
833 if payload.kind.trim().is_empty() {
834 "prompt"
835 } else {
836 payload.kind.trim()
837 }
838}
839
840fn payload_text(payload: &JobPayloadSpec) -> Result<&str> {
841 payload
842 .text
843 .as_deref()
844 .filter(|text| !text.trim().is_empty())
845 .ok_or_else(|| {
846 JobControlError::Invalid(format!(
847 "a `{}` payload needs its text",
848 payload_kind(payload)
849 ))
850 })
851}
852
853fn openclaw_command(verb: JobVerb, mutation: &JobMutation) -> Result<HarnessCommand> {
858 let connection = openclaw_connection(&mutation.homes)?;
859 let mut command = HarnessCommand::new(harness_program(HarnessId::OPENCLAW)?);
860 command.env(
864 "OPENCLAW_STATE_DIR",
865 mutation.homes.openclaw.to_string_lossy(),
866 );
867 command.env(
868 "OPENCLAW_CONFIG_PATH",
869 mutation
870 .homes
871 .openclaw
872 .join("openclaw.json")
873 .to_string_lossy(),
874 );
875 command.arg("cron");
876 let id = mutation.id.clone().unwrap_or_default();
877 match verb {
878 JobVerb::Create => {
879 command.arg("add");
880 }
881 JobVerb::Update => {
882 command.args(["edit", &id]);
883 }
884 JobVerb::Pause => {
886 command.args(["disable", &id]);
887 }
888 JobVerb::Resume => {
889 command.args(["enable", &id]);
890 }
891 JobVerb::Run => {
892 command.args(["run", &id]);
893 }
894 JobVerb::Delete => {
895 command.args(["rm", &id]);
896 }
897 }
898 command.args(["--url", &connection.address]);
899 if let Some(token) = &connection.auth {
900 command.arg("--token");
901 command.secret(token.secret());
902 }
903 if matches!(verb, JobVerb::Create | JobVerb::Update) {
904 if let Some(name) = &mutation.name {
905 command.args(["--name", name]);
906 }
907 if let Some(schedule) = &mutation.schedule {
908 match schedule.kind.as_str() {
909 "interval" => {
910 let minutes = schedule.minutes.ok_or_else(|| {
911 JobControlError::Invalid("an interval schedule needs `minutes`".into())
912 })?;
913 command.args(["--every", &format!("{}m", trim_float(minutes))]);
914 }
915 "cron" => {
916 let expr = schedule.expr.as_deref().ok_or_else(|| {
917 JobControlError::Invalid("a cron schedule needs `expr`".into())
918 })?;
919 command.args(["--cron", expr]);
920 }
921 "once" => {
922 let run_at = schedule.run_at.as_deref().ok_or_else(|| {
923 JobControlError::Invalid("a once schedule needs `run_at`".into())
924 })?;
925 command.args(["--at", run_at]);
926 }
927 other => {
928 return Err(JobControlError::Invalid(format!(
929 "unknown schedule kind `{other}`; use interval, cron, or once"
930 )))
931 }
932 }
933 }
934 if let Some(payload) = &mutation.payload {
935 match payload_kind(payload) {
936 "prompt" => {
937 command.args(["--message", payload_text(payload)?]);
938 }
939 "system_event" => {
940 command.args(["--system-event", payload_text(payload)?]);
941 }
942 "command" => {
943 command.args(["--command", payload_text(payload)?]);
944 }
945 other => {
946 return Err(JobControlError::Unsupported(format!(
947 "openclaw cron carries `message`, `system-event` or `command` payloads; \
948 it has no verb for a `{other}` payload"
949 )))
950 }
951 }
952 }
953 if let Some(target) = &mutation.session_target {
954 command.args(["--session", target]);
955 }
956 if let Some(profile) = &mutation.profile {
957 command.args(["--agent", profile]);
958 }
959 if let Some(deliver) = &mutation.deliver {
960 openclaw_deliver(deliver, &mut command)?;
961 }
962 if matches!(verb, JobVerb::Create) {
968 command.arg("--json");
969 }
970 } else if mutation.profile.is_some()
971 || mutation.session_target.is_some()
972 || mutation.deliver.is_some()
973 || mutation.name.is_some()
974 || mutation.schedule.is_some()
975 || mutation.payload.is_some()
976 {
977 return Err(JobControlError::Invalid(format!(
978 "`jobs.{}` changes no fields; pass definition fields to `jobs.update`",
979 verb.as_str()
980 )));
981 }
982 Ok(command)
983}
984
985fn openclaw_deliver(deliver: &JobDeliverSpec, command: &mut HarnessCommand) -> Result<()> {
988 let Some(target) = deliver.target.as_deref().map(str::trim) else {
989 if let Some(chat) = deliver.chat_id.as_deref() {
990 command.args(["--to", chat]);
991 }
992 return Ok(());
993 };
994 match target {
995 "announce" => {
996 command.arg("--announce");
997 if let Some(chat) = deliver.chat_id.as_deref() {
998 command.args(["--to", chat]);
999 }
1000 }
1001 "webhook" => {
1002 let url = deliver.chat_id.as_deref().ok_or_else(|| {
1003 JobControlError::Invalid(
1004 "an openclaw `webhook` delivery needs the URL in `chat_id`".into(),
1005 )
1006 })?;
1007 command.args(["--webhook", url]);
1008 }
1009 "none" => {
1010 command.arg("--no-deliver");
1011 }
1012 other => {
1013 return Err(JobControlError::Unsupported(format!(
1014 "openclaw delivers `announce`, `webhook`, or `none`; it has no `{other}` delivery \
1015 mode"
1016 )))
1017 }
1018 }
1019 Ok(())
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025
1026 fn homes(root: &Path) -> HarnessHomes {
1027 HarnessHomes {
1028 hermes: root.join("hermes_home/state.db"),
1029 openclaw: root.join("openclaw_home"),
1030 ..HarnessHomes::default()
1031 }
1032 }
1033
1034 #[test]
1035 fn the_program_comes_from_the_registry_launch() {
1036 assert_eq!(harness_program(HarnessId::HERMES).unwrap(), "hermes");
1039 assert_eq!(harness_program(HarnessId::OPENCLAW).unwrap(), "openclaw");
1040 }
1041
1042 #[test]
1043 fn claude_code_refuses_every_mutating_verb() {
1044 let error = mutate(
1045 JobVerb::Pause,
1046 &JobMutation {
1047 harness: HarnessId::CLAUDE_CODE.into(),
1048 id: Some("release-watch".into()),
1049 ..JobMutation::default()
1050 },
1051 )
1052 .unwrap_err();
1053 assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1054 assert!(error.to_string().contains("CronCreate"), "{error}");
1055 }
1056
1057 #[test]
1058 fn a_harness_without_jobs_refuses() {
1059 let error = mutate(
1060 JobVerb::Delete,
1061 &JobMutation {
1062 harness: "codex".into(),
1063 id: Some("x".into()),
1064 ..JobMutation::default()
1065 },
1066 )
1067 .unwrap_err();
1068 assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1069 }
1070
1071 #[test]
1072 fn hermes_translates_the_uniform_row_onto_its_own_verb() {
1073 let root = PathBuf::from("/tmp/orch18-unit");
1074 let command = hermes_command(
1075 JobVerb::Create,
1076 &JobMutation {
1077 harness: HarnessId::HERMES.into(),
1078 name: Some("health".into()),
1079 schedule: Some(JobScheduleSpec {
1080 kind: "interval".into(),
1081 minutes: Some(10.0),
1082 ..JobScheduleSpec::default()
1083 }),
1084 payload: Some(JobPayloadSpec {
1085 kind: "prompt".into(),
1086 text: Some("nightly health check".into()),
1087 }),
1088 deliver: Some(JobDeliverSpec {
1089 target: Some("local".into()),
1090 chat_id: None,
1091 }),
1092 homes: homes(&root),
1093 ..JobMutation::default()
1094 },
1095 )
1096 .unwrap();
1097 assert_eq!(
1098 command.narrate(),
1099 "hermes cron create --name health --deliver local 'every 10m' 'nightly health check'"
1100 );
1101 assert_eq!(
1102 command.env,
1103 vec![(
1104 "HERMES_HOME".to_string(),
1105 root.join("hermes_home").to_string_lossy().into_owned()
1106 )]
1107 );
1108 }
1109
1110 #[test]
1111 fn a_hermes_profile_is_its_own_home() {
1112 let root = PathBuf::from("/tmp/orch18-unit");
1113 let command = hermes_command(
1114 JobVerb::Pause,
1115 &JobMutation {
1116 harness: HarnessId::HERMES.into(),
1117 id: Some("abc".into()),
1118 profile: Some("ops".into()),
1119 homes: homes(&root),
1120 ..JobMutation::default()
1121 },
1122 )
1123 .unwrap();
1124 assert_eq!(command.narrate(), "hermes cron pause abc");
1125 assert_eq!(
1126 command.env[0].1,
1127 root.join("hermes_home/profiles/ops")
1128 .to_string_lossy()
1129 .into_owned()
1130 );
1131 }
1132
1133 #[test]
1134 fn hermes_refuses_a_field_it_has_no_verb_for() {
1135 let error = hermes_command(
1136 JobVerb::Update,
1137 &JobMutation {
1138 harness: HarnessId::HERMES.into(),
1139 id: Some("abc".into()),
1140 session_target: Some("isolated".into()),
1141 ..JobMutation::default()
1142 },
1143 )
1144 .unwrap_err();
1145 assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1146 }
1147
1148 #[test]
1152 fn the_orchestrator_translates_the_uniform_row_onto_its_own_operator_args() {
1153 let args = orchestrator_args(
1154 JobVerb::Create,
1155 &JobMutation {
1156 harness: HarnessId::ORCHESTRATOR.into(),
1157 name: Some("health".into()),
1158 schedule: Some(JobScheduleSpec {
1159 kind: "interval".into(),
1160 minutes: Some(10.0),
1161 ..JobScheduleSpec::default()
1162 }),
1163 payload: Some(JobPayloadSpec {
1164 kind: "prompt".into(),
1165 text: Some("nightly health check".into()),
1166 }),
1167 deliver: Some(JobDeliverSpec {
1168 target: Some("loopback".into()),
1169 chat_id: Some("ops-room".into()),
1170 }),
1171 ..JobMutation::default()
1172 },
1173 )
1174 .unwrap();
1175 assert_eq!(
1176 args,
1177 serde_json::json!({
1178 "name": "health",
1179 "schedule": {"kind": "interval", "minutes": 10.0},
1180 "prompt": "nightly health check",
1181 "deliver": "loopback:ops-room",
1182 })
1183 );
1184 }
1185
1186 #[test]
1189 fn the_orchestrator_refuses_a_field_its_model_does_not_have() {
1190 for (mutation, needle) in [
1191 (
1192 JobMutation {
1193 harness: HarnessId::ORCHESTRATOR.into(),
1194 session_target: Some("isolated".into()),
1195 ..JobMutation::default()
1196 },
1197 "no session-target field",
1198 ),
1199 (
1200 JobMutation {
1201 harness: HarnessId::ORCHESTRATOR.into(),
1202 payload: Some(JobPayloadSpec {
1203 kind: "command".into(),
1204 text: Some("ls".into()),
1205 }),
1206 ..JobMutation::default()
1207 },
1208 "there is no `command` payload",
1209 ),
1210 ] {
1211 let error = orchestrator_args(JobVerb::Create, &mutation).unwrap_err();
1212 assert!(matches!(error, JobControlError::Unsupported(_)), "{error}");
1213 assert!(error.to_string().contains(needle), "{error}");
1214 }
1215 let error = orchestrator_args(
1217 JobVerb::Pause,
1218 &JobMutation {
1219 harness: HarnessId::ORCHESTRATOR.into(),
1220 id: Some("job_x".into()),
1221 name: Some("renamed".into()),
1222 ..JobMutation::default()
1223 },
1224 )
1225 .unwrap_err();
1226 assert!(matches!(error, JobControlError::Invalid(_)), "{error}");
1227 }
1228
1229 #[test]
1232 fn the_orchestrators_unnamed_profile_is_the_root_folder() {
1233 assert_eq!(
1234 orchestrator_profile(&JobMutation {
1235 harness: HarnessId::ORCHESTRATOR.into(),
1236 ..JobMutation::default()
1237 }),
1238 "default"
1239 );
1240 assert_eq!(
1241 orchestrator_profile(&JobMutation {
1242 harness: HarnessId::ORCHESTRATOR.into(),
1243 profile: Some(" coder ".into()),
1244 ..JobMutation::default()
1245 }),
1246 "coder"
1247 );
1248 }
1249
1250 #[test]
1251 fn openclaw_carries_the_gateway_endpoint_and_never_prints_the_token() {
1252 let root = std::env::temp_dir().join(format!(
1253 "supercode-orch18-unit-{}-{}",
1254 std::process::id(),
1255 std::time::SystemTime::now()
1256 .duration_since(std::time::UNIX_EPOCH)
1257 .unwrap()
1258 .as_nanos()
1259 ));
1260 let state = root.join("openclaw_home");
1261 std::fs::create_dir_all(&state).unwrap();
1262 std::fs::write(
1263 state.join("openclaw.json"),
1264 r#"{"gateway": {"port": 18999, "auth": {"token": "super-secret-token"}}}"#,
1265 )
1266 .unwrap();
1267 let command = openclaw_command(
1268 JobVerb::Create,
1269 &JobMutation {
1270 harness: HarnessId::OPENCLAW.into(),
1271 name: Some("digest".into()),
1272 schedule: Some(JobScheduleSpec {
1273 kind: "cron".into(),
1274 expr: Some("0 9 * * 1".into()),
1275 ..JobScheduleSpec::default()
1276 }),
1277 payload: Some(JobPayloadSpec {
1278 kind: "system_event".into(),
1279 text: Some("weekly digest".into()),
1280 }),
1281 session_target: Some("main".into()),
1282 homes: homes(&root),
1283 ..JobMutation::default()
1284 },
1285 )
1286 .unwrap();
1287 assert_eq!(
1288 command.narrate(),
1289 "openclaw cron add --url ws://127.0.0.1:18999 --token <redacted> --name digest --cron \
1290 '0 9 * * 1' --system-event 'weekly digest' --session main --json"
1291 );
1292 assert_eq!(command.secrets, vec!["super-secret-token".to_string()]);
1293 assert!(
1294 !command.narrate().contains("super-secret-token"),
1295 "the credential must never be narrated"
1296 );
1297 std::fs::remove_dir_all(&root).ok();
1298 }
1299}