1use 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct JobSpec {
46 pub name: String,
48 pub schedule: JobScheduleSpec,
50 #[serde(default)]
52 pub prompt: Option<String>,
53 #[serde(default)]
55 pub skills: Option<Vec<String>>,
56 #[serde(default)]
58 pub deliver: Option<JobDeliverSpec>,
59 #[serde(default)]
61 pub model: Option<String>,
62 #[serde(default)]
64 pub provider: Option<String>,
65 #[serde(default)]
67 pub workdir: Option<String>,
68 #[serde(default)]
70 pub repeat: Option<u32>,
71 #[serde(default)]
74 pub enabled: Option<bool>,
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct JobsApply {
81 pub harness: String,
83 pub profile: Option<String>,
86 pub jobs: Vec<JobSpec>,
88 pub plan: bool,
90 pub homes: HarnessHomes,
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct AppliedJob {
97 pub name: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub id: Option<String>,
102 pub detail: String,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
108pub struct JobsApplyOutcome {
109 pub harness: String,
111 pub created: Vec<JobMutationOutcome>,
114 pub updated: Vec<JobMutationOutcome>,
116 pub unchanged: Vec<AppliedJob>,
118 pub unnamed: Vec<AppliedJob>,
120 pub drift: Vec<AppliedJob>,
122 pub refused: Vec<AppliedJob>,
124 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
132pub 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 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 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
243fn 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 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
407fn 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 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 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
549fn 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}