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)]
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)]
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 homes: HarnessHomes,
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct JobMutationOutcome {
177 pub harness: String,
179 pub verb: String,
181 pub ran: String,
183 pub id: String,
185 #[serde(skip_serializing_if = "Option::is_none")]
188 pub job: Option<ScheduledJob>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub deleted: Option<bool>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum JobControlError {
197 Unsupported(String),
199 Invalid(String),
201 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
219pub 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
240fn hermes_home(mutation: &JobMutation) -> PathBuf {
243 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
256fn 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
293pub 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 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 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
371fn 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
386fn 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 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
448fn 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
471fn 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 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 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
539fn 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
550fn 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 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
579fn 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
590fn 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
625fn 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 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
748fn 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 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 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 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
880fn 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 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 #[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 #[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 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 #[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}