1use std::collections::BTreeMap;
6
7use serde_json::{Map, Value};
8
9use crate::ontology::{HarnessId, Residue, SecretRef, SurfaceKey};
10use crate::orchestration::{
11 ChannelConfig, EnvValue, Fire, FireStatus, Job, JobOrigin, Obligation, ObligationSource,
12 ObligationState, OutboundContent, PermissionDefault, PermissionPolicy, PermissionUnattended,
13 Posted, Repeat, Route, RouteMatch, Schedule, Target, WebhookSubscription, WorkerSpec,
14};
15use crate::Result;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct LoadError {
20 pub file: String,
22 pub key: Option<String>,
24 pub message: String,
26}
27
28impl std::fmt::Display for LoadError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match &self.key {
31 Some(key) => write!(f, "{} [{key}]: {}", self.file, self.message),
32 None => write!(f, "{}: {}", self.file, self.message),
33 }
34 }
35}
36
37impl From<LoadError> for crate::Error {
38 fn from(error: LoadError) -> Self {
39 crate::Error::Other(error.to_string())
40 }
41}
42
43pub(crate) fn load_error(file: &str, key: &str, message: impl Into<String>) -> crate::Error {
44 LoadError {
45 file: file.to_string(),
46 key: if key.is_empty() {
47 None
48 } else {
49 Some(key.to_string())
50 },
51 message: message.into(),
52 }
53 .into()
54}
55
56fn expect_keys(file: &str, key: &str, obj: &Value, allowed: &[&str]) -> Result<()> {
57 let Some(map) = obj.as_object() else {
58 return Err(load_error(file, key, "expected an object"));
59 };
60 for k in map.keys() {
61 if !allowed.contains(&k.as_str()) {
62 let path = if key.is_empty() {
63 k.clone()
64 } else {
65 format!("{key}.{k}")
66 };
67 return Err(load_error(file, &path, "unknown key"));
68 }
69 }
70 Ok(())
71}
72
73fn req_str(file: &str, key: &str, v: Option<&Value>) -> Result<String> {
74 match v {
75 Some(Value::String(s)) if !s.is_empty() => Ok(s.clone()),
76 _ => Err(load_error(file, key, "expected a non-empty string")),
77 }
78}
79
80fn opt_str(file: &str, key: &str, v: Option<&Value>) -> Result<Option<String>> {
81 match v {
82 None | Some(Value::Null) => Ok(None),
83 Some(Value::String(s)) => Ok(Some(s.clone())),
84 _ => Err(load_error(file, key, "expected a string")),
85 }
86}
87
88fn opt_bool(
89 file: &str,
90 key: &str,
91 v: Option<&Value>,
92 default: Option<bool>,
93) -> Result<Option<bool>> {
94 match v {
95 None | Some(Value::Null) => Ok(default),
96 Some(Value::Bool(b)) => Ok(Some(*b)),
97 _ => Err(load_error(file, key, "expected a boolean")),
98 }
99}
100
101fn residue_of(obj: &Map<String, Value>, mapped: &[&str]) -> Residue {
102 let mut out = Residue::default();
103 for (k, v) in obj {
104 if !mapped.contains(&k.as_str()) {
105 out.keep(k.clone(), v.clone());
106 }
107 }
108 out
109}
110
111fn row_residue_of(row: &Map<String, Value>, mapped: &[&str]) -> Residue {
113 let mut out = Residue::default();
114 for (k, v) in row {
115 if !mapped.contains(&k.as_str()) && !v.is_null() {
116 out.keep(k.clone(), v.clone());
117 }
118 }
119 out
120}
121
122fn text_of(v: &Value) -> Option<String> {
124 match v {
125 Value::String(s) => Some(s.clone()),
126 Value::Number(n) => Some(match n.as_f64() {
127 Some(f) if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e21 => format!("{}", f as i64),
128 _ => n.to_string(),
129 }),
130 _ => None,
131 }
132}
133
134fn number_of(v: &Value) -> Option<f64> {
135 match v {
136 Value::Number(n) => n.as_f64(),
137 Value::String(s) => s.parse().ok(),
138 _ => None,
139 }
140}
141
142pub const CHAT_TYPES: &[&str] = &["dm", "group", "channel", "thread"];
146
147pub fn surface_key_string(key: &SurfaceKey) -> String {
149 format!(
150 "{}|{}|{}|{}|{}",
151 key.platform.as_deref().unwrap_or(""),
152 key.kind.as_deref().unwrap_or(""),
153 key.chat_id.as_deref().unwrap_or(""),
154 key.thread_id.as_deref().unwrap_or(""),
155 key.participant_id.as_deref().unwrap_or("")
156 )
157}
158
159pub fn decode_surface_key(file: &str, key: &str, raw: &Value) -> Result<SurfaceKey> {
161 let map = raw
162 .as_object()
163 .ok_or_else(|| load_error(file, key, "expected an object"))?;
164 for k in map.keys() {
165 if ![
166 "platform",
167 "chat_type",
168 "kind",
169 "chat_id",
170 "thread_id",
171 "participant_id",
172 "key",
173 ]
174 .contains(&k.as_str())
175 {
176 return Err(load_error(file, &format!("{key}.{k}"), "unknown key"));
177 }
178 }
179 let chat_type = map
180 .get("chat_type")
181 .or_else(|| map.get("kind"))
182 .and_then(Value::as_str)
183 .map(str::to_string);
184 if !chat_type
185 .as_deref()
186 .is_some_and(|c| CHAT_TYPES.contains(&c))
187 {
188 return Err(load_error(
189 file,
190 key,
191 "expected platform, chat_type, chat_id",
192 ));
193 }
194 Ok(SurfaceKey {
195 key: None,
196 platform: Some(req_str(
197 file,
198 &format!("{key}.platform"),
199 map.get("platform"),
200 )?),
201 kind: chat_type,
202 chat_id: map.get("chat_id").and_then(text_of),
203 thread_id: map
204 .get("thread_id")
205 .and_then(text_of)
206 .filter(|s| !s.is_empty()),
207 participant_id: map
208 .get("participant_id")
209 .and_then(text_of)
210 .filter(|s| !s.is_empty()),
211 })
212}
213
214pub fn encode_surface_key(key: &SurfaceKey) -> Value {
216 let mut out = Map::new();
217 out.insert(
218 "platform".into(),
219 Value::String(key.platform.clone().unwrap_or_default()),
220 );
221 out.insert(
222 "kind".into(),
223 Value::String(key.kind.clone().unwrap_or_default()),
224 );
225 if let Some(c) = &key.chat_id {
226 out.insert("chat_id".into(), Value::String(c.clone()));
227 }
228 if let Some(t) = &key.thread_id {
229 out.insert("thread_id".into(), Value::String(t.clone()));
230 }
231 if let Some(p) = &key.participant_id {
232 out.insert("participant_id".into(), Value::String(p.clone()));
233 }
234 Value::Object(out)
235}
236
237pub fn parse_target(
241 file: &str,
242 key: &str,
243 word: Option<&Value>,
244 extra: Option<&Value>,
245) -> Result<Option<Target>> {
246 let word = match word {
247 None | Some(Value::Null) => return Ok(None),
248 Some(Value::String(s)) if !s.is_empty() => s.as_str(),
249 _ => return Err(load_error(file, key, "expected a delivery word")),
250 };
251 Ok(Some(match word {
252 "origin" => Target::Origin,
253 "local" => Target::Local,
254 "home" => Target::Home,
255 _ => {
256 let parts: Vec<&str> = word.split(':').collect();
257 let extra_get = |name: &str| extra.and_then(|e| e.get(name)).and_then(text_of);
258 let chat_id = parts
259 .get(1)
260 .map(|s| s.to_string())
261 .or_else(|| extra_get("chat_id"))
262 .filter(|s| !s.is_empty());
263 let thread_id = parts
264 .get(2)
265 .map(|s| s.to_string())
266 .or_else(|| extra_get("thread_id"))
267 .filter(|s| !s.is_empty());
268 Target::Explicit {
269 platform: parts[0].to_string(),
270 chat_id,
271 thread_id,
272 }
273 }
274 }))
275}
276
277pub fn decode_env_value(file: &str, key: &str, v: &Value) -> Result<EnvValue> {
281 match v {
282 Value::String(s) => Ok(match placeholder_ref(s) {
283 Some(name) => EnvValue::Secret(SecretRef::Dotenv(name.to_string())),
284 None => EnvValue::Literal(s.clone()),
285 }),
286 _ => Err(load_error(
287 file,
288 key,
289 "expected a string (a secret is written `${NAME}`)",
290 )),
291 }
292}
293
294pub fn decode_worker(file: &str, raw: Option<&Value>) -> Result<Option<WorkerSpec>> {
296 let Some(raw) = raw.filter(|v| !v.is_null()) else {
297 return Ok(None);
298 };
299 expect_keys(
300 file,
301 "worker",
302 raw,
303 &["harness", "model", "preset", "cwd", "env", "permission"],
304 )?;
305 let cwd = match raw.get("cwd") {
306 None => ".".to_string(),
307 v => req_str(file, "worker.cwd", v)?,
308 };
309 if cwd.starts_with('/') || cwd.split('/').any(|p| p == "..") {
310 return Err(load_error(
311 file,
312 "worker.cwd",
313 "must be a relative path inside the profile",
314 ));
315 }
316 let mut env = BTreeMap::new();
317 if let Some(e) = raw.get("env") {
318 let map = e
319 .as_object()
320 .ok_or_else(|| load_error(file, "worker.env", "expected a map"))?;
321 for (k, v) in map {
322 env.insert(
323 k.clone(),
324 decode_env_value(file, &format!("worker.env.{k}"), v)?,
325 );
326 }
327 }
328 let mut permission = PermissionPolicy::default();
329 if let Some(p) = raw.get("permission") {
330 expect_keys(
331 file,
332 "worker.permission",
333 p,
334 &["timeout_seconds", "default", "unattended"],
335 )?;
336 if let Some(t) = p.get("timeout_seconds") {
337 permission.timeout_seconds = t.as_u64().filter(|v| *v >= 1).ok_or_else(|| {
338 load_error(
339 file,
340 "worker.permission.timeout_seconds",
341 "expected an integer >= 1",
342 )
343 })? as u32;
344 }
345 if let Some(d) = p.get("default") {
346 permission.default = match d.as_str() {
347 Some("deny") => PermissionDefault::Deny,
348 Some("allow") => PermissionDefault::Allow,
349 _ => {
350 return Err(load_error(
351 file,
352 "worker.permission.default",
353 "expected deny|allow",
354 ))
355 }
356 };
357 }
358 if let Some(u) = p.get("unattended") {
359 permission.unattended = match u.as_str() {
360 Some("deny") => PermissionUnattended::Deny,
361 Some("approve") => PermissionUnattended::Approve,
362 _ => {
363 return Err(load_error(
364 file,
365 "worker.permission.unattended",
366 "expected deny|approve",
367 ))
368 }
369 };
370 }
371 }
372 Ok(Some(WorkerSpec {
373 harness: HarnessId::new(req_str(file, "worker.harness", raw.get("harness"))?),
374 model: opt_str(file, "worker.model", raw.get("model"))?,
375 preset: opt_str(file, "worker.preset", raw.get("preset"))?,
376 cwd,
377 env,
378 permission,
379 }))
380}
381
382const JOB_MAPPED: &[&str] = &[
385 "id",
386 "schedule",
387 "prompt",
388 "workdir",
389 "model",
390 "skills",
391 "context_from",
392 "deliver",
393 "failure_deliver",
394 "origin",
395 "attach_to_session",
396 "repeat",
397 "enabled",
398 "next_run_at",
399 "last_run_at",
400 "last_status",
401 "created_at",
402];
403
404pub const HERMES_JOB_ORDER: &[&str] = &[
406 "id",
407 "schedule",
408 "prompt",
409 "skills",
410 "script",
411 "no_agent",
412 "model",
413 "provider",
414 "workdir",
415 "enabled_toolsets",
416 "context_from",
417 "deliver",
418 "failure_deliver",
419 "attach_to_session",
420 "origin",
421 "repeat",
422 "enabled",
423 "next_run_at",
424 "last_run_at",
425 "last_status",
426 "created_at",
427 "fire_claim",
428];
429
430pub fn decode_context_from(
432 file: &str,
433 key: &str,
434 v: Option<&Value>,
435) -> Result<Option<Vec<String>>> {
436 let items: Vec<String> = match v {
437 None | Some(Value::Null) => return Ok(None),
438 Some(Value::String(s)) => vec![s.clone()],
439 Some(Value::Array(a)) => a
440 .iter()
441 .map(|x| match x {
442 Value::String(s) => s.clone(),
443 other => other.to_string(),
444 })
445 .collect(),
446 _ => {
447 return Err(load_error(
448 file,
449 key,
450 "expected a job id, a list of job ids, or \"self\"",
451 ))
452 }
453 };
454 let refs: Vec<String> = items
455 .into_iter()
456 .map(|s| s.trim().to_string())
457 .filter(|s| !s.is_empty())
458 .collect();
459 Ok(if refs.is_empty() { None } else { Some(refs) })
460}
461
462pub fn decode_repeat(file: &str, key: &str, raw: Option<&Value>) -> Result<Option<Repeat>> {
464 Ok(match raw {
465 None | Some(Value::Null) => None,
466 Some(Value::Bool(true)) => Some(Repeat {
467 times: None,
468 completed: 0,
469 }),
470 Some(Value::Bool(false)) => Some(Repeat {
471 times: Some(1),
472 completed: 0,
473 }),
474 Some(Value::Number(n)) => Some(Repeat {
475 times: n.as_f64().filter(|f| *f > 0.0).map(|f| f.floor() as u32),
476 completed: 0,
477 }),
478 Some(Value::Object(map)) => {
479 let times = match map.get("times") {
480 None | Some(Value::Null) => None,
481 Some(Value::Number(n)) if n.as_u64().is_some_and(|v| v >= 1) => {
482 Some(n.as_u64().unwrap() as u32)
483 }
484 _ => {
485 return Err(load_error(
486 file,
487 &format!("{key}.times"),
488 "expected null or an integer >= 1",
489 ))
490 }
491 };
492 let completed = match map.get("completed") {
493 None => 0,
494 Some(Value::Number(n)) if n.as_u64().is_some() => n.as_u64().unwrap() as u32,
495 _ => {
496 return Err(load_error(
497 file,
498 &format!("{key}.completed"),
499 "expected an integer >= 0",
500 ))
501 }
502 };
503 Some(Repeat { times, completed })
504 }
505 _ => {
506 return Err(load_error(
507 file,
508 key,
509 "expected null, {times, completed}, a number or a boolean",
510 ))
511 }
512 })
513}
514
515pub fn decode_job(file: &str, raw: &Value) -> Result<Job> {
517 let map = raw
518 .as_object()
519 .ok_or_else(|| load_error(file, "", "expected a job object"))?;
520 let id = req_str(file, "id", map.get("id"))?;
521 let k = |s: &str| format!("{id}.{s}");
522 let sched = map
523 .get("schedule")
524 .and_then(Value::as_object)
525 .ok_or_else(|| load_error(file, &k("schedule"), "expected an object"))?;
526 let schedule = match sched.get("kind").and_then(Value::as_str) {
527 Some("once") => Schedule::Once {
528 run_at: req_str(file, &k("schedule.run_at"), sched.get("run_at"))?,
529 },
530 Some("interval") => {
531 let minutes = sched
532 .get("minutes")
533 .and_then(Value::as_f64)
534 .filter(|m| *m > 0.0)
535 .ok_or_else(|| {
536 load_error(file, &k("schedule.minutes"), "expected a positive number")
537 })?;
538 Schedule::Interval { minutes }
539 }
540 Some("cron") => Schedule::Cron {
541 expr: req_str(file, &k("schedule.expr"), sched.get("expr"))?,
542 tz: opt_str(file, &k("schedule.tz"), sched.get("tz"))?,
543 },
544 _ => {
545 return Err(load_error(
546 file,
547 &k("schedule.kind"),
548 "expected once|interval|cron",
549 ))
550 }
551 };
552 let schedule_residue = residue_of(sched, &["kind", "run_at", "minutes", "expr", "tz"]);
553 let mut residue = residue_of(map, JOB_MAPPED);
554 if !schedule_residue.is_empty() {
555 residue.keep(
556 "__schedule",
557 Value::Object(schedule_residue.0.into_iter().collect()),
558 );
559 }
560 let origin = match map.get("origin") {
561 Some(Value::Object(o)) => {
562 let r = residue_of(o, &["platform", "chat_id", "thread_id"]);
563 if !r.is_empty() {
564 residue.keep("__origin", Value::Object(r.0.into_iter().collect()));
565 }
566 Some(JobOrigin {
567 platform: req_str(file, &k("origin.platform"), o.get("platform"))?,
568 chat_type: None,
569 chat_id: opt_str(file, &k("origin.chat_id"), o.get("chat_id"))?,
570 thread_id: opt_str(file, &k("origin.thread_id"), o.get("thread_id"))?,
571 })
572 }
573 _ => None,
574 };
575 Ok(Job {
576 schedule,
577 prompt: opt_str(file, &k("prompt"), map.get("prompt"))?,
578 workdir: opt_str(file, &k("workdir"), map.get("workdir"))?,
579 model: opt_str(file, &k("model"), map.get("model"))?,
580 skills: map
581 .get("skills")
582 .and_then(Value::as_array)
583 .map(|a| {
584 a.iter()
585 .map(|v| {
586 v.as_str()
587 .map(str::to_string)
588 .unwrap_or_else(|| v.to_string())
589 })
590 .collect()
591 })
592 .unwrap_or_default(),
593 context_from: decode_context_from(file, &k("context_from"), map.get("context_from"))?,
594 deliver: parse_target(file, &k("deliver"), map.get("deliver"), None)?
595 .unwrap_or(Target::Local),
596 failure_deliver: parse_target(
597 file,
598 &k("failure_deliver"),
599 map.get("failure_deliver"),
600 None,
601 )?,
602 origin,
603 attach_to_session: opt_bool(
604 file,
605 &k("attach_to_session"),
606 map.get("attach_to_session"),
607 None,
608 )?,
609 repeat: decode_repeat(file, &k("repeat"), map.get("repeat"))?,
610 enabled: opt_bool(file, &k("enabled"), map.get("enabled"), Some(true))?.unwrap_or(true),
611 next_run_at: opt_str(file, &k("next_run_at"), map.get("next_run_at"))?,
612 last_run_at: opt_str(file, &k("last_run_at"), map.get("last_run_at"))?,
613 last_status: opt_str(file, &k("last_status"), map.get("last_status"))?,
614 created_at: opt_str(file, &k("created_at"), map.get("created_at"))?,
615 residue,
616 id,
617 })
618}
619
620pub fn encode_job(job: &Job) -> Vec<(String, Value)> {
623 let mut sched = Map::new();
624 sched.insert("kind".into(), Value::String(job.schedule.kind().into()));
625 match &job.schedule {
626 Schedule::Once { run_at } => {
627 sched.insert("run_at".into(), Value::String(run_at.clone()));
628 }
629 Schedule::Interval { minutes } => {
630 sched.insert("minutes".into(), serde_json::json!(*minutes));
631 }
632 Schedule::Cron { expr, tz } => {
633 sched.insert("expr".into(), Value::String(expr.clone()));
634 if let Some(tz) = tz {
635 sched.insert("tz".into(), Value::String(tz.clone()));
636 }
637 }
638 }
639 if let Some(Value::Object(extra)) = job.residue.0.get("__schedule") {
640 for (k, v) in extra {
641 sched.insert(k.clone(), v.clone());
642 }
643 }
644 let origin = job
645 .origin
646 .as_ref()
647 .map(|o| {
648 let mut m = Map::new();
649 m.insert("platform".into(), Value::String(o.platform.clone()));
650 m.insert(
651 "chat_id".into(),
652 o.chat_id.clone().map(Value::String).unwrap_or(Value::Null),
653 );
654 m.insert(
655 "thread_id".into(),
656 o.thread_id
657 .clone()
658 .map(Value::String)
659 .unwrap_or(Value::Null),
660 );
661 if let Some(Value::Object(extra)) = job.residue.0.get("__origin") {
662 for (k, v) in extra {
663 m.insert(k.clone(), v.clone());
664 }
665 }
666 Value::Object(m)
667 })
668 .unwrap_or(Value::Null);
669 let opt = |s: &Option<String>| s.clone().map(Value::String).unwrap_or(Value::Null);
670 let mut mapped: Vec<(String, Value)> = vec![
671 ("id".into(), Value::String(job.id.clone())),
672 ("schedule".into(), Value::Object(sched)),
673 ("prompt".into(), opt(&job.prompt)),
674 (
675 "skills".into(),
676 Value::Array(
677 job.skills
678 .iter()
679 .map(|s| Value::String(s.clone()))
680 .collect(),
681 ),
682 ),
683 ("model".into(), opt(&job.model)),
684 ("workdir".into(), opt(&job.workdir)),
685 (
686 "context_from".into(),
687 job.context_from
688 .as_ref()
689 .map(|l| Value::Array(l.iter().map(|s| Value::String(s.clone())).collect()))
690 .unwrap_or(Value::Null),
691 ),
692 ("deliver".into(), Value::String(job.deliver.render())),
693 (
694 "failure_deliver".into(),
695 job.failure_deliver
696 .as_ref()
697 .map(|t| Value::String(t.render()))
698 .unwrap_or(Value::Null),
699 ),
700 (
701 "attach_to_session".into(),
702 job.attach_to_session
703 .map(Value::Bool)
704 .unwrap_or(Value::Null),
705 ),
706 ("origin".into(), origin),
707 (
708 "repeat".into(),
709 job.repeat
710 .as_ref()
711 .map(|r| serde_json::json!({"times": r.times, "completed": r.completed}))
712 .unwrap_or(Value::Null),
713 ),
714 ("enabled".into(), Value::Bool(job.enabled)),
715 ("next_run_at".into(), opt(&job.next_run_at)),
716 ("last_run_at".into(), opt(&job.last_run_at)),
717 ("last_status".into(), opt(&job.last_status)),
718 ("created_at".into(), opt(&job.created_at)),
719 ];
720 let mut residue: Vec<(String, Value)> = job
721 .residue
722 .0
723 .iter()
724 .filter(|(k, _)| k.as_str() != "__schedule" && k.as_str() != "__origin")
730 .map(|(k, v)| (k.clone(), v.clone()))
731 .collect();
732 let mut out: Vec<(String, Value)> = Vec::new();
733 for key in HERMES_JOB_ORDER {
734 if let Some(pos) = mapped.iter().position(|(k, _)| k == key) {
735 out.push(mapped.remove(pos));
736 } else if let Some(pos) = residue.iter().position(|(k, _)| k == key) {
737 out.push(residue.remove(pos));
738 }
739 }
740 out.extend(mapped);
741 out.extend(residue);
742 out
743}
744
745pub fn decode_fire_row(file: &str, row: &Map<String, Value>) -> Result<Fire> {
747 let id = req_str(file, "id", row.get("id"))?;
748 let status_word = row.get("status").and_then(Value::as_str).unwrap_or("");
749 let status = FireStatus::from_hermes_word(status_word).ok_or_else(|| {
750 load_error(
751 file,
752 &format!("{id}.status"),
753 format!(
754 "unknown fire status {}",
755 serde_json::to_string(status_word).unwrap()
756 ),
757 )
758 })?;
759 Ok(Fire {
760 job_id: req_str(file, &format!("{id}.job_id"), row.get("job_id"))?,
761 session_id: row
762 .get("session_id")
763 .and_then(text_of)
764 .filter(|s| !s.is_empty()),
765 status,
766 claimed_at: req_str(file, &format!("{id}.claimed_at"), row.get("claimed_at"))?,
767 started_at: opt_str(file, &format!("{id}.started_at"), row.get("started_at"))?,
768 finished_at: opt_str(file, &format!("{id}.finished_at"), row.get("finished_at"))?,
769 error: opt_str(file, &format!("{id}.error"), row.get("error"))?,
770 obligation_id: row
771 .get("obligation_id")
772 .and_then(text_of)
773 .filter(|s| !s.is_empty()),
774 residue: {
775 let mut residue = row_residue_of(
776 row,
777 &[
778 "id",
779 "job_id",
780 "status",
781 "claimed_at",
782 "started_at",
783 "finished_at",
784 "error",
785 "residue_json",
786 "session_id",
787 "obligation_id",
788 ],
789 );
790 if let Some(extra) = row
793 .get("residue_json")
794 .and_then(text_of)
795 .and_then(|t| serde_json::from_str::<Map<String, Value>>(&t).ok())
796 {
797 for (k, v) in extra {
798 residue.keep(k, v);
799 }
800 }
801 residue
802 },
803 id,
804 })
805}
806
807pub fn encode_fire_row(fire: &Fire) -> Vec<Value> {
809 let r = &fire.residue.0;
810 let opt = |s: &Option<String>| s.clone().map(Value::String).unwrap_or(Value::Null);
811 vec![
812 Value::String(fire.id.clone()),
813 Value::String(fire.job_id.clone()),
814 r.get("source")
815 .cloned()
816 .unwrap_or(Value::String("scheduler".into())),
817 r.get("process_id")
818 .cloned()
819 .unwrap_or(Value::String(String::new())),
820 r.get("pid").cloned().unwrap_or(Value::from(0)),
821 r.get("process_started_at").cloned().unwrap_or(Value::Null),
822 Value::String(fire.status.hermes_word().into()),
823 Value::String(fire.claimed_at.clone()),
824 opt(&fire.started_at),
825 opt(&fire.finished_at),
826 opt(&fire.error),
827 ]
828}
829
830pub const EXECUTION_COLUMNS: &[&str] = &[
832 "id",
833 "job_id",
834 "source",
835 "process_id",
836 "pid",
837 "process_started_at",
838 "status",
839 "claimed_at",
840 "started_at",
841 "finished_at",
842 "error",
843];
844
845pub fn decode_obligation_row(file: &str, row: &Map<String, Value>) -> Result<Obligation> {
847 let id = req_str(file, "obligation_id", row.get("obligation_id"))?;
848 let state_word = row.get("state").and_then(Value::as_str).unwrap_or("");
849 let state = ObligationState::from_hermes_word(state_word).ok_or_else(|| {
850 load_error(
851 file,
852 &format!("{id}.state"),
853 format!(
854 "unknown obligation state {}",
855 serde_json::to_string(state_word).unwrap()
856 ),
857 )
858 })?;
859 let session_key = row
860 .get("session_key")
861 .and_then(Value::as_str)
862 .filter(|s| !s.is_empty())
863 .map(str::to_string);
864 let parsed = session_key
865 .as_deref()
866 .and_then(crate::ontology::parse_hermes_session_key)
867 .map(|(k, _)| k);
868 let target = SurfaceKey {
869 key: None,
870 platform: Some(req_str(
871 file,
872 &format!("{id}.platform"),
873 row.get("platform"),
874 )?),
875 kind: parsed.as_ref().and_then(|k| k.kind.clone()),
876 chat_id: Some(row.get("chat_id").and_then(text_of).unwrap_or_default()),
877 thread_id: row
878 .get("thread_id")
879 .and_then(text_of)
880 .filter(|s| !s.is_empty()),
881 participant_id: None,
882 };
883 let text = |k: &str| row.get(k).and_then(text_of);
884 let created_at = text("created_at").unwrap_or_else(|| "undefined".into());
885 let updated_at = text("updated_at").unwrap_or_else(|| created_at.clone());
886 Ok(Obligation {
887 target,
888 session_key,
889 content: OutboundContent {
890 text: row.get("content").and_then(text_of).unwrap_or_default(),
891 attachments: None,
892 reply_to: None,
893 format: None,
894 },
895 state,
896 attempts: row.get("attempts").and_then(number_of).unwrap_or(0.0) as u64,
897 last_error: row.get("last_error").and_then(text_of),
898 delivered_at: if state == ObligationState::Sent {
899 Some(updated_at.clone())
900 } else {
901 None
902 },
903 created_at,
904 updated_at,
905 posted: row
909 .get("posted_message_id")
910 .and_then(text_of)
911 .filter(|s| !s.is_empty())
912 .map(|message_id| Posted { message_id }),
913 source: row
914 .get("source_json")
915 .and_then(text_of)
916 .and_then(|text| serde_json::from_str::<ObligationSource>(&text).ok())
917 .unwrap_or(ObligationSource::Turn { key: None }),
918 residue: row_residue_of(
919 row,
920 &[
921 "obligation_id",
922 "session_key",
923 "platform",
924 "chat_id",
925 "thread_id",
926 "content",
927 "state",
928 "attempts",
929 "created_at",
930 "updated_at",
931 "last_error",
932 "posted_message_id",
933 "source_json",
934 ],
935 ),
936 id,
937 })
938}
939
940pub const FOLDER_OBLIGATION_EXTRA_COLUMNS: &[&str] = &["posted_message_id", "source_json"];
942
943pub fn encode_obligation_folder_extras(o: &Obligation) -> Vec<Value> {
945 vec![
946 o.posted
947 .as_ref()
948 .map(|p| Value::String(p.message_id.clone()))
949 .unwrap_or(Value::Null),
950 Value::String(serde_json::to_string(&o.source).unwrap()),
951 ]
952}
953
954pub fn encode_obligation_row(o: &Obligation) -> Vec<Value> {
956 let r = &o.residue.0;
957 let num = |s: &str| {
958 s.parse::<f64>()
959 .map(|f| serde_json::json!(f))
960 .unwrap_or(Value::from(0))
961 };
962 vec![
963 Value::String(o.id.clone()),
964 Value::String(o.session_key.clone().unwrap_or_default()),
965 Value::String(o.target.platform.clone().unwrap_or_default()),
966 Value::String(o.target.chat_id.clone().unwrap_or_default()),
967 o.target
968 .thread_id
969 .clone()
970 .map(Value::String)
971 .unwrap_or(Value::Null),
972 Value::String(o.content.text.clone()),
973 Value::String(o.state.hermes_word().into()),
974 Value::from(o.attempts),
975 num(&o.created_at),
976 num(o
977 .delivered_at
978 .as_deref()
979 .map(|_| o.updated_at.as_str())
980 .unwrap_or(&o.updated_at)),
981 r.get("owner_pid").cloned().unwrap_or(Value::Null),
982 r.get("owner_started_at").cloned().unwrap_or(Value::Null),
983 o.last_error
984 .clone()
985 .map(Value::String)
986 .unwrap_or(Value::Null),
987 r.get("adapter_profile").cloned().unwrap_or(Value::Null),
988 ]
989}
990
991pub const OBLIGATION_COLUMNS: &[&str] = &[
993 "obligation_id",
994 "session_key",
995 "platform",
996 "chat_id",
997 "thread_id",
998 "content",
999 "state",
1000 "attempts",
1001 "created_at",
1002 "updated_at",
1003 "owner_pid",
1004 "owner_started_at",
1005 "last_error",
1006 "adapter_profile",
1007];
1008
1009const ROUTE_MATCH: &[&str] = &["platform", "guild_id", "chat_id", "thread_id"];
1010
1011pub fn decode_route(file: &str, index: usize, raw: &Value) -> Result<Route> {
1013 let map = raw.as_object().ok_or_else(|| {
1014 load_error(
1015 file,
1016 &format!("profile_routes[{index}]"),
1017 "expected an object",
1018 )
1019 })?;
1020 let text = |k: &str| map.get(k).filter(|v| !v.is_null()).and_then(text_of);
1021 Ok(Route {
1022 name: opt_str(
1023 file,
1024 &format!("profile_routes[{index}].name"),
1025 map.get("name"),
1026 )?,
1027 matches: RouteMatch {
1028 platform: req_str(
1029 file,
1030 &format!("profile_routes[{index}].platform"),
1031 map.get("platform"),
1032 )?,
1033 guild_id: text("guild_id"),
1034 chat_id: text("chat_id"),
1035 thread_id: text("thread_id"),
1036 },
1037 profile: req_str(
1038 file,
1039 &format!("profile_routes[{index}].profile"),
1040 map.get("profile"),
1041 )?,
1042 residue: residue_of(
1043 map,
1044 &[
1045 "name",
1046 "profile",
1047 "platform",
1048 "guild_id",
1049 "chat_id",
1050 "thread_id",
1051 ],
1052 ),
1053 })
1054}
1055
1056pub fn encode_route(r: &Route) -> Value {
1058 let mut out = Map::new();
1059 if let Some(n) = &r.name {
1060 out.insert("name".into(), Value::String(n.clone()));
1061 }
1062 let m = &r.matches;
1063 for (k, v) in [
1064 ("platform", Some(&m.platform)),
1065 ("guild_id", m.guild_id.as_ref()),
1066 ("chat_id", m.chat_id.as_ref()),
1067 ("thread_id", m.thread_id.as_ref()),
1068 ] {
1069 if let Some(v) = v {
1070 out.insert(k.into(), Value::String(v.clone()));
1071 }
1072 }
1073 let _ = ROUTE_MATCH;
1074 out.insert("profile".into(), Value::String(r.profile.clone()));
1075 for (k, v) in &r.residue.0 {
1076 out.insert(k.clone(), v.clone());
1077 }
1078 Value::Object(out)
1079}
1080
1081pub fn is_credential_key(key: &str) -> bool {
1083 let k = key.to_ascii_lowercase();
1084 [
1085 "token",
1086 "secret",
1087 "key",
1088 "password",
1089 "passwd",
1090 "api_key",
1091 "app_secret",
1092 "signing",
1093 "webhook_url",
1094 "private",
1095 ]
1096 .iter()
1097 .any(|w| k.contains(w))
1098}
1099
1100pub fn placeholder_ref(text: &str) -> Option<&str> {
1107 let name = text.strip_prefix("${")?.strip_suffix('}')?;
1108 let mut chars = name.chars();
1109 let first = chars.next()?;
1110 ((first.is_ascii_alphabetic() || first == '_')
1111 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_'))
1112 .then_some(name)
1113}
1114
1115pub fn render_ref(r: &SecretRef) -> Value {
1118 Value::String(format!("${{{}}}", r.name()))
1119}
1120
1121pub fn credential_ref_name(platform: &str, key: &str) -> String {
1123 format!("{platform}_{key}")
1124 .chars()
1125 .map(|c| {
1126 if c.is_ascii_alphanumeric() {
1127 c.to_ascii_uppercase()
1128 } else {
1129 '_'
1130 }
1131 })
1132 .collect()
1133}
1134
1135pub fn decode_channel(
1137 file: &str,
1138 platform: &str,
1139 raw: &Value,
1140 vault: &mut BTreeMap<String, String>,
1141) -> Result<ChannelConfig> {
1142 let map = raw
1143 .as_object()
1144 .ok_or_else(|| load_error(file, &format!("platforms.{platform}"), "expected an object"))?;
1145 let mut ch = ChannelConfig {
1146 platform: platform.to_string(),
1147 enabled: opt_bool(
1148 file,
1149 &format!("platforms.{platform}.enabled"),
1150 map.get("enabled"),
1151 Some(true),
1152 )?
1153 .unwrap_or(true),
1154 credentials: BTreeMap::new(),
1155 extra: BTreeMap::new(),
1156 };
1157 fn walk(
1158 map: &Map<String, Value>,
1159 prefix: &str,
1160 platform: &str,
1161 ch: &mut ChannelConfig,
1162 vault: &mut BTreeMap<String, String>,
1163 ) {
1164 for (k, v) in map {
1165 if k == "enabled" && prefix.is_empty() {
1166 continue;
1167 }
1168 let name = format!("{prefix}{k}");
1169 if let Value::String(s) = v {
1170 if let Some(r) = placeholder_ref(s) {
1171 ch.credentials
1172 .insert(name, SecretRef::Dotenv(r.to_string()));
1173 continue;
1174 }
1175 }
1176 if let Some(obj) = v.as_object() {
1177 if k == "extra" && prefix.is_empty() {
1178 walk(obj, "extra.", platform, ch, vault);
1179 continue;
1180 }
1181 }
1182 if let Value::String(s) = v {
1183 if is_credential_key(k) {
1184 let r = credential_ref_name(platform, &name);
1185 vault.insert(r.clone(), s.clone());
1186 ch.credentials.insert(name, SecretRef::Dotenv(r));
1187 continue;
1188 }
1189 }
1190 ch.extra.insert(name, v.clone());
1191 }
1192 }
1193 walk(map, "", platform, &mut ch, vault);
1194 Ok(ch)
1195}
1196
1197pub fn encode_channel(ch: &ChannelConfig, vault: Option<&BTreeMap<String, String>>) -> Value {
1199 let mut out = Map::new();
1200 out.insert("enabled".into(), Value::Bool(ch.enabled));
1201 let mut extra = Map::new();
1202 for (k, v) in &ch.extra {
1203 match k.strip_prefix("extra.") {
1204 Some(inner) => {
1205 extra.insert(inner.to_string(), v.clone());
1206 }
1207 None => {
1208 out.insert(k.clone(), v.clone());
1209 }
1210 }
1211 }
1212 for (k, r) in &ch.credentials {
1213 let rendered = match vault.and_then(|v| v.get(r.name())) {
1214 Some(value) => Value::String(value.clone()),
1215 None => render_ref(r),
1216 };
1217 match k.strip_prefix("extra.") {
1218 Some(inner) => {
1219 extra.insert(inner.to_string(), rendered);
1220 }
1221 None => {
1222 out.insert(k.clone(), rendered);
1223 }
1224 }
1225 }
1226 if !extra.is_empty() {
1227 out.insert("extra".into(), Value::Object(extra));
1228 }
1229 Value::Object(out)
1230}
1231
1232const SUB_MAPPED: &[&str] = &[
1233 "events",
1234 "prompt",
1235 "skills",
1236 "deliver",
1237 "deliver_extra",
1238 "secret",
1239 "description",
1240 "created_at",
1241];
1242
1243pub fn decode_subscription(
1245 file: &str,
1246 name: &str,
1247 raw: &Value,
1248 vault: &mut BTreeMap<String, String>,
1249) -> Result<WebhookSubscription> {
1250 let map = raw
1251 .as_object()
1252 .ok_or_else(|| load_error(file, name, "expected an object"))?;
1253 let mut sub = WebhookSubscription {
1254 name: name.to_string(),
1255 secret: None,
1256 events: map.get("events").and_then(Value::as_array).map(|a| {
1257 a.iter()
1258 .map(|v| {
1259 v.as_str()
1260 .map(str::to_string)
1261 .unwrap_or_else(|| v.to_string())
1262 })
1263 .collect()
1264 }),
1265 prompt_template: opt_str(file, &format!("{name}.prompt"), map.get("prompt"))?
1266 .unwrap_or_default(),
1267 deliver: parse_target(
1268 file,
1269 &format!("{name}.deliver"),
1270 map.get("deliver"),
1271 map.get("deliver_extra"),
1272 )?,
1273 skills: map
1274 .get("skills")
1275 .and_then(Value::as_array)
1276 .map(|a| {
1277 a.iter()
1278 .map(|v| {
1279 v.as_str()
1280 .map(str::to_string)
1281 .unwrap_or_else(|| v.to_string())
1282 })
1283 .collect()
1284 })
1285 .unwrap_or_default(),
1286 description: opt_str(file, &format!("{name}.description"), map.get("description"))?,
1287 created_at: opt_str(file, &format!("{name}.created_at"), map.get("created_at"))?,
1288 residue: residue_of(map, SUB_MAPPED),
1289 };
1290 match map.get("secret") {
1291 Some(Value::String(secret)) => {
1294 let r = match placeholder_ref(secret) {
1295 Some(name) => name.to_string(),
1296 None => {
1297 let r = credential_ref_name("webhook", &format!("{name}_secret"));
1298 vault.insert(r.clone(), secret.clone());
1299 r
1300 }
1301 };
1302 sub.secret = Some(SecretRef::Dotenv(r));
1303 }
1304 Some(Value::Null) | None => {}
1305 Some(_) => {
1306 return Err(load_error(
1307 file,
1308 &format!("{name}.secret"),
1309 "expected a string (a secret is written `${NAME}`)",
1310 ))
1311 }
1312 }
1313 Ok(sub)
1314}
1315
1316pub fn encode_subscription(
1318 sub: &WebhookSubscription,
1319 vault: Option<&BTreeMap<String, String>>,
1320) -> Value {
1321 let mut out = Map::new();
1322 if let Some(d) = &sub.description {
1323 out.insert("description".into(), Value::String(d.clone()));
1324 }
1325 if let Some(e) = &sub.events {
1326 out.insert(
1327 "events".into(),
1328 Value::Array(e.iter().map(|s| Value::String(s.clone())).collect()),
1329 );
1330 }
1331 out.insert("prompt".into(), Value::String(sub.prompt_template.clone()));
1332 out.insert(
1333 "skills".into(),
1334 Value::Array(
1335 sub.skills
1336 .iter()
1337 .map(|s| Value::String(s.clone()))
1338 .collect(),
1339 ),
1340 );
1341 if let Some(t) = &sub.deliver {
1342 match t {
1343 Target::Explicit {
1344 platform,
1345 chat_id,
1346 thread_id,
1347 } => {
1348 out.insert("deliver".into(), Value::String(platform.clone()));
1349 let mut extra = Map::new();
1350 if let Some(c) = chat_id {
1351 extra.insert("chat_id".into(), Value::String(c.clone()));
1352 }
1353 if let Some(th) = thread_id {
1354 extra.insert("thread_id".into(), Value::String(th.clone()));
1355 }
1356 if !extra.is_empty() {
1357 out.insert("deliver_extra".into(), Value::Object(extra));
1358 }
1359 }
1360 other => {
1361 out.insert("deliver".into(), Value::String(other.render()));
1362 }
1363 }
1364 }
1365 if let Some(s) = &sub.secret {
1366 let value = vault
1367 .and_then(|v| v.get(s.name()).cloned())
1368 .unwrap_or_else(|| format!("${{{}}}", s.name()));
1369 out.insert("secret".into(), Value::String(value));
1370 }
1371 if let Some(c) = &sub.created_at {
1372 out.insert("created_at".into(), Value::String(c.clone()));
1373 }
1374 for (k, v) in &sub.residue.0 {
1375 out.insert(k.clone(), v.clone());
1376 }
1377 Value::Object(out)
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382 use super::*;
1383
1384 #[test]
1385 fn job_round_trip_keeps_hermes_order_and_residue() {
1386 let raw = serde_json::json!({
1387 "id": "j1", "schedule": {"kind": "cron", "expr": "0 8 * * *", "tz": "UTC", "jitter": 3},
1388 "prompt": "p", "deliver": "slack:C1:t1", "context_from": "self", "repeat": true, "attach_to_session": true,
1389 "origin": {"platform": "telegram", "chat_id": "1", "thread_id": null, "chat_name": "x"},
1390 "script": "echo hi", "fire_claim": {"pid": 1}, "enabled": false,
1391 });
1392 let job = decode_job("jobs.json", &raw).unwrap();
1393 assert_eq!(job.context_from.as_deref(), Some(&["self".to_string()][..]));
1394 assert_eq!(
1395 job.repeat,
1396 Some(Repeat {
1397 times: None,
1398 completed: 0
1399 })
1400 );
1401 assert_eq!(job.attach_to_session, Some(true));
1402 assert_eq!(
1403 job.deliver,
1404 Target::Explicit {
1405 platform: "slack".into(),
1406 chat_id: Some("C1".into()),
1407 thread_id: Some("t1".into())
1408 }
1409 );
1410 assert_eq!(
1411 job.residue.0.get("script").and_then(Value::as_str),
1412 Some("echo hi")
1413 );
1414 assert_eq!(job.residue.0["__schedule"]["jitter"], serde_json::json!(3));
1415 assert_eq!(
1416 job.residue.0["__origin"]["chat_name"],
1417 serde_json::json!("x")
1418 );
1419 let ordered = encode_job(&job);
1420 let keys: Vec<&str> = ordered.iter().map(|(k, _)| k.as_str()).collect();
1421 assert_eq!(
1422 keys,
1423 vec![
1424 "id",
1425 "schedule",
1426 "prompt",
1427 "skills",
1428 "script",
1429 "model",
1430 "workdir",
1431 "context_from",
1432 "deliver",
1433 "failure_deliver",
1434 "attach_to_session",
1435 "origin",
1436 "repeat",
1437 "enabled",
1438 "next_run_at",
1439 "last_run_at",
1440 "last_status",
1441 "created_at",
1442 "fire_claim"
1443 ]
1444 );
1445 let encoded: Map<String, Value> = encode_job(&job).into_iter().collect();
1446 assert_eq!(encoded["schedule"]["jitter"], serde_json::json!(3));
1447 assert_eq!(
1448 encoded["repeat"],
1449 serde_json::json!({"times": null, "completed": 0})
1450 );
1451 assert_eq!(encoded["origin"]["chat_name"], serde_json::json!("x"));
1452 assert!(decode_job(
1453 "jobs.json",
1454 &serde_json::json!({"id": "x", "schedule": {"kind": "weekly"}})
1455 )
1456 .is_err());
1457 assert!(decode_job("jobs.json", &serde_json::json!({"id": "x", "schedule": {"kind": "once", "run_at": "2026-01-01T00:00:00Z"}, "context_from": 7})).is_err());
1458 }
1459
1460 #[test]
1461 fn strict_o_records() {
1462 let w = decode_worker(
1463 "config.yaml",
1464 Some(&serde_json::json!({"harness": "codex", "cwd": "../x"})),
1465 )
1466 .unwrap_err();
1467 assert!(w.to_string().contains("worker.cwd"), "{w}");
1468 let w = decode_worker("config.yaml", Some(&serde_json::json!({"harness": "codex", "env": {"A": "${A_KEY}", "B": "lit"}, "permission": {"default": "allow"}}))).unwrap().unwrap();
1469 assert_eq!(
1470 w.env["A"],
1471 EnvValue::Secret(SecretRef::Dotenv("A_KEY".into()))
1472 );
1473 assert_eq!(w.permission.default, PermissionDefault::Allow);
1474 assert_eq!(w.cwd, ".");
1475 }
1476
1477 #[test]
1478 fn channels_redact_credentials_into_the_vault() {
1479 let mut vault = BTreeMap::new();
1480 let ch = decode_channel("config.yaml", "telegram", &serde_json::json!({"enabled": true, "token": "T", "extra": {"key": "K", "host": "h"}, "mode": "polling"}), &mut vault).unwrap();
1481 assert_eq!(vault.get("TELEGRAM_TOKEN").map(String::as_str), Some("T"));
1482 assert_eq!(
1483 vault.get("TELEGRAM_EXTRA_KEY").map(String::as_str),
1484 Some("K")
1485 );
1486 assert_eq!(
1487 ch.credentials["token"],
1488 SecretRef::Dotenv("TELEGRAM_TOKEN".into())
1489 );
1490 assert_eq!(
1491 ch.credentials["extra.key"],
1492 SecretRef::Dotenv("TELEGRAM_EXTRA_KEY".into())
1493 );
1494 assert_eq!(ch.extra["extra.host"], serde_json::json!("h"));
1495 assert_eq!(ch.extra["mode"], serde_json::json!("polling"));
1496 let ours = encode_channel(&ch, None);
1497 assert_eq!(ours["token"], serde_json::json!("${TELEGRAM_TOKEN}"));
1498 let hermes = encode_channel(&ch, Some(&vault));
1499 assert_eq!(hermes["token"], serde_json::json!("T"));
1500 assert_eq!(hermes["extra"]["key"], serde_json::json!("K"));
1501 }
1502
1503 #[test]
1504 fn fires_obligations_subscriptions() {
1505 let row: Map<String, Value> = serde_json::from_value(serde_json::json!({"id": "f1", "job_id": "j", "status": "completed", "claimed_at": "t0", "started_at": null, "finished_at": "t1", "error": null, "source": "scheduler", "pid": 4})).unwrap();
1506 let fire = decode_fire_row("executions.db", &row).unwrap();
1507 assert_eq!(fire.status, FireStatus::Succeeded);
1508 assert_eq!(fire.residue.0.get("pid"), Some(&serde_json::json!(4)));
1509 assert_eq!(encode_fire_row(&fire)[6], serde_json::json!("completed"));
1510 let row: Map<String, Value> = serde_json::from_value(serde_json::json!({"obligation_id": "o1", "session_key": "agent:coder:telegram:group:-1:55", "platform": "telegram", "chat_id": "-1", "thread_id": "55", "content": "hi", "state": "delivered", "attempts": 1, "created_at": 1.5, "updated_at": 2.5, "adapter_profile": "coder"})).unwrap();
1511 let o = decode_obligation_row("state.db", &row).unwrap();
1512 assert_eq!(o.state, ObligationState::Sent);
1513 assert_eq!(o.target.kind.as_deref(), Some("group"));
1514 assert_eq!(o.delivered_at.as_deref(), Some("2.5"));
1515 assert_eq!(encode_obligation_row(&o)[6], serde_json::json!("delivered"));
1516 let mut vault = BTreeMap::new();
1517 let sub = decode_subscription("webhook_subscriptions.json", "deploys", &serde_json::json!({"events": ["push"], "prompt": "P", "deliver": "telegram", "deliver_extra": {"chat_id": "1"}, "secret": "S", "note": 1}), &mut vault).unwrap();
1518 assert_eq!(
1519 sub.deliver,
1520 Some(Target::Explicit {
1521 platform: "telegram".into(),
1522 chat_id: Some("1".into()),
1523 thread_id: None
1524 })
1525 );
1526 assert_eq!(
1527 vault.get("WEBHOOK_DEPLOYS_SECRET").map(String::as_str),
1528 Some("S")
1529 );
1530 let back = encode_subscription(&sub, Some(&vault));
1531 assert_eq!(back["secret"], serde_json::json!("S"));
1532 assert_eq!(back["deliver_extra"]["chat_id"], serde_json::json!("1"));
1533 assert_eq!(back["note"], serde_json::json!(1));
1534 assert_eq!(
1535 encode_subscription(&sub, None)["secret"],
1536 serde_json::json!("${WEBHOOK_DEPLOYS_SECRET}")
1537 );
1538 let mut ours = BTreeMap::new();
1542 let reread = decode_subscription(
1543 "webhook_subscriptions.json",
1544 "deploys",
1545 &encode_subscription(&sub, None),
1546 &mut ours,
1547 )
1548 .unwrap();
1549 assert_eq!(reread.secret, sub.secret);
1550 assert!(ours.is_empty(), "a placeholder is never a value: {ours:?}");
1551 }
1552}