Skip to main content

supercode_interchange/orchestration/codec/
decode.rs

1//! Strict decoders and canonical encoders for every record's FILE form
2//! (`ir.mjs`). O-records refuse an unknown key by file and key; H-records
3//! keep what they do not model in `residue`.
4
5use 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/// A load refusal naming the file and the key (`ir.mjs::LoadError`).
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct LoadError {
20    /// The file.
21    pub file: String,
22    /// The key within it, when one applies.
23    pub key: Option<String>,
24    /// What was wrong.
25    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
111/// Row residue: SQL NULLs are the column's absence, not a value worth carrying.
112fn 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
122/// `String(value)` as JavaScript spells it: an integral REAL prints without a fraction.
123fn 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
142// ------------------------------------------------------------- surface keys
143
144/// `dm` | `group` | `channel` | `thread`.
145pub const CHAT_TYPES: &[&str] = &["dm", "group", "channel", "thread"];
146
147/// The map key of a binding: `<platform>|<chat_type>|<chat_id>|<thread_id>|<participant_id>`.
148pub 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
159/// A surface key from the IR's own JSON (`{platform, chat_type, chat_id, thread_id?, participant_id?}`).
160pub 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
214/// The orchestration's JSON of a surface key (`kind`, as the ontology spells it; absent optionals omitted).
215pub 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
237// ------------------------------------------------------------------ targets
238
239/// Hermes `deliver` word → Target: `origin | local | home | <platform>[:<chat_id>[:<thread_id>]]`.
240pub 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
277// ---------------------------------------------------------------- O-records
278
279/// A worker env value: a string, `${NAME}` (a secret, Hermes's reference form) or a literal.
280pub 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
294/// `worker:` block of config.yaml.
295pub 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
382// ---------------------------------------------------------------- H-records
383
384const 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
404/// Hermes 0.21.0's canonical key order for a job record.
405pub 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
430/// `context_from`: a job id, a list of job ids, or `self` (`cron/jobs.py:2486-2492`).
431pub 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
462/// Hermes 0.21.0 stores `repeat` as `{times: N | null, completed: M}`; older bool/number forms load.
463pub 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
515/// One job record of `cron/jobs.json`.
516pub 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
620/// A job record in Hermes's canonical key order, mapped fields then residue (`ir.mjs::encodeJob`).
621/// Returned as an ordered key list because `serde_json`'s object order is a cargo feature.
622pub 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        // `__schedule` and `__origin` are re-emitted as structure above and
725        // rebuilt on decode; every OTHER `__` residue key (a foreign codec's,
726        // e.g. OpenClaw's `__delivery` / `__session_target` / `__payload`)
727        // has no home in the Hermes shape and rides as a literal key, which is
728        // what carries it losslessly through our folder
729        .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
745/// Fire status words on the Hermes boundary.
746pub 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            // our folder's ledger keeps what another harness's run log had
791            // and Hermes's columns do not (ORC-12 finding 5)
792            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
807/// The `executions` row of a fire (Hermes's own status word; residue fills the process columns).
808pub 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
830/// The column list `encode_fire_row` fills, in order.
831pub 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
845/// One `delivery_obligations` row.
846pub 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        // our own folder's store carries the two facts Hermes's has no column
906        // for (ORC-9 finding 6): the platform's handle for the sent message,
907        // and where the obligation came from. A Hermes store has neither.
908        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
940/// Our folder's two columns beyond Hermes's `delivery_obligations` shape.
941pub const FOLDER_OBLIGATION_EXTRA_COLUMNS: &[&str] = &["posted_message_id", "source_json"];
942
943/// The values for [`FOLDER_OBLIGATION_EXTRA_COLUMNS`].
944pub 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
954/// The `delivery_obligations` row of an obligation, in `OBLIGATION_COLUMNS` order.
955pub 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
991/// The column list `encode_obligation_row` fills, in order.
992pub 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
1011/// One `profile_routes[]` entry.
1012pub 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
1056/// A route back in Hermes's form.
1057pub 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
1081/// Whether a config key names a credential (`ir.mjs::CREDENTIAL_KEY`).
1082pub 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
1100/// The `.env` name inside a `${NAME}` reference, Hermes's own form (docs/plans/hermes-compat.md row 5).
1101///
1102/// Our own folder never inlines a secret: where a Hermes home holds the value,
1103/// we write the reference and keep the value in `.env`. A reader that took
1104/// the placeholder for a value would put the placeholder TEXT in the vault and
1105/// write it back as the secret on the next save.
1106pub 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
1115/// A secret reference as Hermes writes one in `config.yaml`: `${NAME}`, expanded from the home's `.env`
1116/// (which Hermes loads into its environment) and then the process environment.
1117pub fn render_ref(r: &SecretRef) -> Value {
1118    Value::String(format!("${{{}}}", r.name()))
1119}
1120
1121/// The `.env` name for a credential.
1122pub 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
1135/// One `platforms.<name>` block; credential VALUES go to the vault, refs into the record.
1136pub 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
1197/// A platform block; with a vault (a Hermes export) values are inlined, without one the refs are written.
1198pub 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
1243/// One `webhook_subscriptions.json` entry; the secret's value goes to the vault.
1244pub 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        // A Hermes home inlines the value here or writes `${NAME}`; our own
1292        // folder writes `${NAME}` and keeps the value in `.env`.
1293        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
1316/// A subscription back in Hermes's form; the secret inlined when a vault is given, else a placeholder.
1317pub 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        // Reading our own folder back: the placeholder is a REF, so nothing
1539        // goes into the vault and the next save does not write the
1540        // placeholder text where the secret belongs.
1541        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}