use std::collections::BTreeMap;
use serde_json::{Map, Value};
use crate::ontology::Recurrence;
use crate::ontology::{
Binding, EndReason, Handoff, HarnessId, Residue, SecretRef, SurfaceKey, Worker,
};
use crate::orchestration::{
Access, AccessPolicy, ChannelConfig, EnvValue, ExpiryPolicy, ExpiryScope, Fire, FireStatus,
Job, JobOrigin, Obligation, ObligationSource, ObligationState, OutboundContent, PendingPairing,
PermissionDefault, PermissionPolicy, Repeat, Route, RouteMatch, Schedule, Target,
WebhookSubscription, WorkerSpec,
};
use crate::Result;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadError {
pub file: String,
pub key: Option<String>,
pub message: String,
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.key {
Some(key) => write!(f, "{} [{key}]: {}", self.file, self.message),
None => write!(f, "{}: {}", self.file, self.message),
}
}
}
impl From<LoadError> for crate::Error {
fn from(error: LoadError) -> Self {
crate::Error::Other(error.to_string())
}
}
pub(crate) fn load_error(file: &str, key: &str, message: impl Into<String>) -> crate::Error {
LoadError {
file: file.to_string(),
key: if key.is_empty() {
None
} else {
Some(key.to_string())
},
message: message.into(),
}
.into()
}
fn expect_keys(file: &str, key: &str, obj: &Value, allowed: &[&str]) -> Result<()> {
let Some(map) = obj.as_object() else {
return Err(load_error(file, key, "expected an object"));
};
for k in map.keys() {
if !allowed.contains(&k.as_str()) {
let path = if key.is_empty() {
k.clone()
} else {
format!("{key}.{k}")
};
return Err(load_error(file, &path, "unknown key"));
}
}
Ok(())
}
fn req_str(file: &str, key: &str, v: Option<&Value>) -> Result<String> {
match v {
Some(Value::String(s)) if !s.is_empty() => Ok(s.clone()),
_ => Err(load_error(file, key, "expected a non-empty string")),
}
}
fn opt_str(file: &str, key: &str, v: Option<&Value>) -> Result<Option<String>> {
match v {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
_ => Err(load_error(file, key, "expected a string")),
}
}
fn opt_bool(
file: &str,
key: &str,
v: Option<&Value>,
default: Option<bool>,
) -> Result<Option<bool>> {
match v {
None | Some(Value::Null) => Ok(default),
Some(Value::Bool(b)) => Ok(Some(*b)),
_ => Err(load_error(file, key, "expected a boolean")),
}
}
fn residue_of(obj: &Map<String, Value>, mapped: &[&str]) -> Residue {
let mut out = Residue::default();
for (k, v) in obj {
if !mapped.contains(&k.as_str()) {
out.keep(k.clone(), v.clone());
}
}
out
}
fn row_residue_of(row: &Map<String, Value>, mapped: &[&str]) -> Residue {
let mut out = Residue::default();
for (k, v) in row {
if !mapped.contains(&k.as_str()) && !v.is_null() {
out.keep(k.clone(), v.clone());
}
}
out
}
fn text_of(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(match n.as_f64() {
Some(f) if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e21 => format!("{}", f as i64),
_ => n.to_string(),
}),
_ => None,
}
}
fn number_of(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.parse().ok(),
_ => None,
}
}
pub const CHAT_TYPES: &[&str] = &["dm", "group", "channel", "thread"];
pub fn surface_key_string(key: &SurfaceKey) -> String {
format!(
"{}|{}|{}|{}|{}",
key.platform.as_deref().unwrap_or(""),
key.kind.as_deref().unwrap_or(""),
key.chat_id.as_deref().unwrap_or(""),
key.thread_id.as_deref().unwrap_or(""),
key.participant_id.as_deref().unwrap_or("")
)
}
pub fn decode_surface_key(file: &str, key: &str, raw: &Value) -> Result<SurfaceKey> {
let map = raw
.as_object()
.ok_or_else(|| load_error(file, key, "expected an object"))?;
for k in map.keys() {
if ![
"platform",
"chat_type",
"kind",
"chat_id",
"thread_id",
"participant_id",
"key",
]
.contains(&k.as_str())
{
return Err(load_error(file, &format!("{key}.{k}"), "unknown key"));
}
}
let chat_type = map
.get("chat_type")
.or_else(|| map.get("kind"))
.and_then(Value::as_str)
.map(str::to_string);
if !chat_type
.as_deref()
.is_some_and(|c| CHAT_TYPES.contains(&c))
{
return Err(load_error(
file,
key,
"expected platform, chat_type, chat_id",
));
}
Ok(SurfaceKey {
key: None,
platform: Some(req_str(
file,
&format!("{key}.platform"),
map.get("platform"),
)?),
kind: chat_type,
chat_id: map.get("chat_id").and_then(text_of),
thread_id: map
.get("thread_id")
.and_then(text_of)
.filter(|s| !s.is_empty()),
participant_id: map
.get("participant_id")
.and_then(text_of)
.filter(|s| !s.is_empty()),
})
}
pub fn encode_surface_key(key: &SurfaceKey) -> Value {
let mut out = Map::new();
out.insert(
"platform".into(),
Value::String(key.platform.clone().unwrap_or_default()),
);
out.insert(
"kind".into(),
Value::String(key.kind.clone().unwrap_or_default()),
);
if let Some(c) = &key.chat_id {
out.insert("chat_id".into(), Value::String(c.clone()));
}
if let Some(t) = &key.thread_id {
out.insert("thread_id".into(), Value::String(t.clone()));
}
if let Some(p) = &key.participant_id {
out.insert("participant_id".into(), Value::String(p.clone()));
}
Value::Object(out)
}
pub fn parse_target(
file: &str,
key: &str,
word: Option<&Value>,
extra: Option<&Value>,
) -> Result<Option<Target>> {
let word = match word {
None | Some(Value::Null) => return Ok(None),
Some(Value::String(s)) if !s.is_empty() => s.as_str(),
_ => return Err(load_error(file, key, "expected a delivery word")),
};
Ok(Some(match word {
"origin" => Target::Origin,
"local" => Target::Local,
"home" => Target::Home,
_ => {
let parts: Vec<&str> = word.split(':').collect();
let extra_get = |name: &str| extra.and_then(|e| e.get(name)).and_then(text_of);
let chat_id = parts
.get(1)
.map(|s| s.to_string())
.or_else(|| extra_get("chat_id"))
.filter(|s| !s.is_empty());
let thread_id = parts
.get(2)
.map(|s| s.to_string())
.or_else(|| extra_get("thread_id"))
.filter(|s| !s.is_empty());
Target::Explicit {
platform: parts[0].to_string(),
chat_id,
thread_id,
}
}
}))
}
pub fn decode_expiry(file: &str, raw: Option<&Value>) -> Result<ExpiryPolicy> {
let Some(raw) = raw.filter(|v| !v.is_null()) else {
return Ok(ExpiryPolicy::default());
};
expect_keys(
file,
"expiry",
raw,
&["idle_minutes", "daily_reset_hour", "scope"],
)?;
let idle = match raw.get("idle_minutes") {
None => 1440,
Some(Value::Number(n)) if n.as_u64().is_some_and(|v| v >= 1) => n.as_u64().unwrap() as u32,
_ => {
return Err(load_error(
file,
"expiry.idle_minutes",
"expected an integer >= 1",
))
}
};
let hour = match raw.get("daily_reset_hour") {
None | Some(Value::Null) => None,
Some(Value::Number(n)) if n.as_u64().is_some_and(|v| v <= 23) => {
Some(n.as_u64().unwrap() as u8)
}
_ => {
return Err(load_error(
file,
"expiry.daily_reset_hour",
"expected null or an integer 0..23",
))
}
};
let scope = match raw.get("scope").and_then(Value::as_str) {
None => ExpiryScope::PerChat,
Some("per_chat") => ExpiryScope::PerChat,
Some("per_user_in_group") => ExpiryScope::PerUserInGroup,
Some("per_thread") => ExpiryScope::PerThread,
_ => {
return Err(load_error(
file,
"expiry.scope",
"expected one of per_chat|per_user_in_group|per_thread",
))
}
};
Ok(ExpiryPolicy {
idle_minutes: idle,
daily_reset_hour: hour,
scope,
})
}
pub fn decode_env_value(file: &str, key: &str, v: &Value) -> Result<EnvValue> {
match v {
Value::String(s) => Ok(EnvValue::Literal(s.clone())),
Value::Object(_) => {
expect_keys(file, key, v, &["env", "dotenv"])?;
if let Some(Value::String(n)) = v.get("env") {
return Ok(EnvValue::Secret(SecretRef::Env(n.clone())));
}
if let Some(Value::String(n)) = v.get("dotenv") {
return Ok(EnvValue::Secret(SecretRef::Dotenv(n.clone())));
}
Err(load_error(
file,
key,
"expected a string or {env}|{dotenv} ref",
))
}
_ => Err(load_error(
file,
key,
"expected a string or {env}|{dotenv} ref",
)),
}
}
pub fn decode_worker(file: &str, raw: Option<&Value>) -> Result<Option<WorkerSpec>> {
let Some(raw) = raw.filter(|v| !v.is_null()) else {
return Ok(None);
};
expect_keys(
file,
"worker",
raw,
&["harness", "model", "preset", "cwd", "env", "permission"],
)?;
let cwd = match raw.get("cwd") {
None => ".".to_string(),
v => req_str(file, "worker.cwd", v)?,
};
if cwd.starts_with('/') || cwd.split('/').any(|p| p == "..") {
return Err(load_error(
file,
"worker.cwd",
"must be a relative path inside the profile",
));
}
let mut env = BTreeMap::new();
if let Some(e) = raw.get("env") {
let map = e
.as_object()
.ok_or_else(|| load_error(file, "worker.env", "expected a map"))?;
for (k, v) in map {
env.insert(
k.clone(),
decode_env_value(file, &format!("worker.env.{k}"), v)?,
);
}
}
let mut permission = PermissionPolicy::default();
if let Some(p) = raw.get("permission") {
expect_keys(
file,
"worker.permission",
p,
&["timeout_seconds", "default"],
)?;
if let Some(t) = p.get("timeout_seconds") {
permission.timeout_seconds = t.as_u64().filter(|v| *v >= 1).ok_or_else(|| {
load_error(
file,
"worker.permission.timeout_seconds",
"expected an integer >= 1",
)
})? as u32;
}
if let Some(d) = p.get("default") {
permission.default = match d.as_str() {
Some("deny") => PermissionDefault::Deny,
Some("allow") => PermissionDefault::Allow,
_ => {
return Err(load_error(
file,
"worker.permission.default",
"expected deny|allow",
))
}
};
}
}
Ok(Some(WorkerSpec {
harness: HarnessId::new(req_str(file, "worker.harness", raw.get("harness"))?),
model: opt_str(file, "worker.model", raw.get("model"))?,
preset: opt_str(file, "worker.preset", raw.get("preset"))?,
cwd,
env,
permission,
}))
}
pub fn decode_home(file: &str, raw: Option<&Value>) -> Result<Option<SurfaceKey>> {
let Some(raw) = raw.filter(|v| !v.is_null()) else {
return Ok(None);
};
let map = raw
.as_object()
.ok_or_else(|| load_error(file, "home", "expected an object"))?;
for k in map.keys() {
if !["platform", "kind", "chat_type", "chat_id", "thread_id"].contains(&k.as_str()) {
return Err(load_error(file, &format!("home.{k}"), "unknown key"));
}
}
let platform = map.get("platform").and_then(Value::as_str);
let chat_type = map
.get("kind")
.or_else(|| map.get("chat_type"))
.and_then(Value::as_str);
let chat_id = map.get("chat_id").and_then(Value::as_str);
let (Some(platform), Some(chat_type), Some(chat_id)) = (platform, chat_type, chat_id) else {
return Err(load_error(
file,
"home",
"expected platform, chat_type, chat_id",
));
};
if !CHAT_TYPES.contains(&chat_type) {
return Err(load_error(
file,
"home",
"expected platform, chat_type, chat_id",
));
}
Ok(Some(SurfaceKey {
key: None,
platform: Some(platform.to_string()),
kind: Some(chat_type.to_string()),
chat_id: Some(chat_id.to_string()),
thread_id: map
.get("thread_id")
.and_then(text_of)
.filter(|s| !s.is_empty()),
participant_id: None,
}))
}
pub fn decode_access(file: &str, raw: Option<&Value>) -> Result<Access> {
let mut access = Access::default();
let Some(raw) = raw.filter(|v| !v.is_null()) else {
return Ok(access);
};
expect_keys(
file,
"",
raw,
&[
"allowlist",
"admins",
"pending_pairings",
"policy",
"pairing_ttl_minutes",
],
)?;
if let Some(ttl) = raw.get("pairing_ttl_minutes").filter(|v| !v.is_null()) {
access.pairing_ttl_minutes =
Some(ttl.as_u64().filter(|v| *v >= 1).ok_or_else(|| {
load_error(file, "pairing_ttl_minutes", "expected an integer >= 1")
})? as u32);
}
let set_map = |name: &str, into: &mut BTreeMap<String, Vec<String>>| -> Result<()> {
let Some(m) = raw.get(name) else {
return Ok(());
};
let map = m
.as_object()
.ok_or_else(|| load_error(file, name, "expected a map platform -> list"))?;
for (platform, users) in map {
let list = users
.as_array()
.filter(|a| a.iter().all(Value::is_string))
.ok_or_else(|| {
load_error(
file,
&format!("{name}.{platform}"),
"expected a list of user ids",
)
})?;
let mut ids: Vec<String> = list
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
ids.sort();
ids.dedup();
into.insert(platform.clone(), ids);
}
Ok(())
};
set_map("allowlist", &mut access.allowlist)?;
set_map("admins", &mut access.admins)?;
if let Some(p) = raw.get("pending_pairings") {
let map = p
.as_object()
.ok_or_else(|| load_error(file, "pending_pairings", "expected a map code -> record"))?;
for (code, rec) in map {
let k = format!("pending_pairings.{code}");
expect_keys(file, &k, rec, &["platform", "user_id", "issued_at"])?;
access.pending_pairings.insert(
code.clone(),
PendingPairing {
platform: req_str(file, &format!("{k}.platform"), rec.get("platform"))?,
user_id: req_str(file, &format!("{k}.user_id"), rec.get("user_id"))?,
issued_at: req_str(file, &format!("{k}.issued_at"), rec.get("issued_at"))?,
},
);
}
}
if let Some(p) = raw.get("policy") {
let map = p.as_object().ok_or_else(|| {
load_error(file, "policy", "expected a map platform -> allowlist|open")
})?;
for (platform, word) in map {
let policy = match word.as_str() {
Some("allowlist") => AccessPolicy::Allowlist,
Some("open") => AccessPolicy::Open,
_ => {
return Err(load_error(
file,
&format!("policy.{platform}"),
"expected allowlist|open",
))
}
};
access.policy.insert(platform.clone(), policy);
}
}
Ok(access)
}
pub fn encode_access(access: &Access) -> Value {
let mut out = serde_json::to_value(access).unwrap();
if access.pairing_ttl_minutes.is_none() {
if let Some(map) = out.as_object_mut() {
map.remove("pairing_ttl_minutes");
}
}
out
}
pub fn decode_binding_row(file: &str, row: &Map<String, Value>) -> Result<Binding> {
let text = |k: &str| row.get(k).and_then(text_of).filter(|s| !s.is_empty());
let chat_type = req_str(file, "chat_type", row.get("chat_type"))?;
if !CHAT_TYPES.contains(&chat_type.as_str()) {
return Err(load_error(
file,
"chat_type",
format!("expected one of {}", CHAT_TYPES.join("|")),
));
}
let key = SurfaceKey {
key: None,
platform: Some(req_str(file, "platform", row.get("platform"))?),
kind: Some(chat_type),
chat_id: text("chat_id"),
thread_id: text("thread_id"),
participant_id: text("participant_id"),
};
let end_reason = match text("end_reason") {
None => None,
Some(word) => Some(
EndReason::parse(&word)
.ok_or_else(|| load_error(file, "end_reason", "unknown end reason"))?,
),
};
let handoff = if text("handoff_to").is_some() || text("handoff_state").is_some() {
Some(Handoff {
to: text("handoff_to"),
state: text("handoff_state").unwrap_or_default(),
error: text("handoff_error"),
})
} else {
None
};
let recurrence = text("recurrence_job_id").map(|job_id| Recurrence {
job_id,
kind: "cron".into(),
});
let residue = match row.get("residue_json").and_then(Value::as_str) {
Some(json) if !json.is_empty() => serde_json::from_str::<Value>(json)
.ok()
.and_then(|v| v.as_object().cloned())
.map(|m| Residue(m.into_iter().collect()))
.unwrap_or_default(),
_ => Residue::default(),
};
Ok(Binding {
trigger: if recurrence.is_some() {
crate::ontology::Trigger::Cron
} else if key.platform.as_deref() == Some("webhook") {
crate::ontology::Trigger::Webhook
} else {
crate::ontology::Trigger::Channel
},
key,
profile: None,
worker: Worker {
harness: HarnessId::new(req_str(file, "worker_harness", row.get("worker_harness"))?),
session_id: opt_str(file, "worker_session_id", row.get("worker_session_id"))?
.filter(|s| !s.is_empty()),
locator: opt_str(file, "worker_locator", row.get("worker_locator"))?,
},
recurrence,
handoff,
started_at: Some(req_str(file, "started_at", row.get("started_at"))?),
last_activity_at: Some(req_str(
file,
"last_activity_at",
row.get("last_activity_at"),
)?),
ended_at: opt_str(file, "ended_at", row.get("ended_at"))?,
end_reason,
residue,
})
}
const JOB_MAPPED: &[&str] = &[
"id",
"schedule",
"prompt",
"workdir",
"model",
"skills",
"context_from",
"deliver",
"failure_deliver",
"origin",
"attach_to_session",
"repeat",
"enabled",
"next_run_at",
"last_run_at",
"last_status",
"created_at",
];
pub const HERMES_JOB_ORDER: &[&str] = &[
"id",
"schedule",
"prompt",
"skills",
"script",
"no_agent",
"model",
"provider",
"workdir",
"enabled_toolsets",
"context_from",
"deliver",
"failure_deliver",
"attach_to_session",
"origin",
"repeat",
"enabled",
"next_run_at",
"last_run_at",
"last_status",
"created_at",
"fire_claim",
];
pub fn decode_context_from(
file: &str,
key: &str,
v: Option<&Value>,
) -> Result<Option<Vec<String>>> {
let items: Vec<String> = match v {
None | Some(Value::Null) => return Ok(None),
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(a)) => a
.iter()
.map(|x| match x {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect(),
_ => {
return Err(load_error(
file,
key,
"expected a job id, a list of job ids, or \"self\"",
))
}
};
let refs: Vec<String> = items
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(if refs.is_empty() { None } else { Some(refs) })
}
pub fn decode_repeat(file: &str, key: &str, raw: Option<&Value>) -> Result<Option<Repeat>> {
Ok(match raw {
None | Some(Value::Null) => None,
Some(Value::Bool(true)) => Some(Repeat {
times: None,
completed: 0,
}),
Some(Value::Bool(false)) => Some(Repeat {
times: Some(1),
completed: 0,
}),
Some(Value::Number(n)) => Some(Repeat {
times: n.as_f64().filter(|f| *f > 0.0).map(|f| f.floor() as u32),
completed: 0,
}),
Some(Value::Object(map)) => {
let times = match map.get("times") {
None | Some(Value::Null) => None,
Some(Value::Number(n)) if n.as_u64().is_some_and(|v| v >= 1) => {
Some(n.as_u64().unwrap() as u32)
}
_ => {
return Err(load_error(
file,
&format!("{key}.times"),
"expected null or an integer >= 1",
))
}
};
let completed = match map.get("completed") {
None => 0,
Some(Value::Number(n)) if n.as_u64().is_some() => n.as_u64().unwrap() as u32,
_ => {
return Err(load_error(
file,
&format!("{key}.completed"),
"expected an integer >= 0",
))
}
};
Some(Repeat { times, completed })
}
_ => {
return Err(load_error(
file,
key,
"expected null, {times, completed}, a number or a boolean",
))
}
})
}
pub fn decode_job(file: &str, raw: &Value) -> Result<Job> {
let map = raw
.as_object()
.ok_or_else(|| load_error(file, "", "expected a job object"))?;
let id = req_str(file, "id", map.get("id"))?;
let k = |s: &str| format!("{id}.{s}");
let sched = map
.get("schedule")
.and_then(Value::as_object)
.ok_or_else(|| load_error(file, &k("schedule"), "expected an object"))?;
let schedule = match sched.get("kind").and_then(Value::as_str) {
Some("once") => Schedule::Once {
run_at: req_str(file, &k("schedule.run_at"), sched.get("run_at"))?,
},
Some("interval") => {
let minutes = sched
.get("minutes")
.and_then(Value::as_f64)
.filter(|m| *m > 0.0)
.ok_or_else(|| {
load_error(file, &k("schedule.minutes"), "expected a positive number")
})?;
Schedule::Interval { minutes }
}
Some("cron") => Schedule::Cron {
expr: req_str(file, &k("schedule.expr"), sched.get("expr"))?,
tz: opt_str(file, &k("schedule.tz"), sched.get("tz"))?.unwrap_or_else(|| "UTC".into()),
},
_ => {
return Err(load_error(
file,
&k("schedule.kind"),
"expected once|interval|cron",
))
}
};
let schedule_residue = residue_of(sched, &["kind", "run_at", "minutes", "expr", "tz"]);
let mut residue = residue_of(map, JOB_MAPPED);
if !schedule_residue.is_empty() {
residue.keep(
"__schedule",
Value::Object(schedule_residue.0.into_iter().collect()),
);
}
let origin = match map.get("origin") {
Some(Value::Object(o)) => {
let r = residue_of(o, &["platform", "chat_id", "thread_id"]);
if !r.is_empty() {
residue.keep("__origin", Value::Object(r.0.into_iter().collect()));
}
Some(JobOrigin {
platform: req_str(file, &k("origin.platform"), o.get("platform"))?,
chat_type: None,
chat_id: opt_str(file, &k("origin.chat_id"), o.get("chat_id"))?,
thread_id: opt_str(file, &k("origin.thread_id"), o.get("thread_id"))?,
})
}
_ => None,
};
Ok(Job {
schedule,
prompt: opt_str(file, &k("prompt"), map.get("prompt"))?,
workdir: opt_str(file, &k("workdir"), map.get("workdir"))?,
model: opt_str(file, &k("model"), map.get("model"))?,
skills: map
.get("skills")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
})
.collect()
})
.unwrap_or_default(),
context_from: decode_context_from(file, &k("context_from"), map.get("context_from"))?,
deliver: parse_target(file, &k("deliver"), map.get("deliver"), None)?
.unwrap_or(Target::Local),
failure_deliver: parse_target(
file,
&k("failure_deliver"),
map.get("failure_deliver"),
None,
)?,
origin,
attach_to_session: opt_bool(
file,
&k("attach_to_session"),
map.get("attach_to_session"),
None,
)?,
repeat: decode_repeat(file, &k("repeat"), map.get("repeat"))?,
enabled: opt_bool(file, &k("enabled"), map.get("enabled"), Some(true))?.unwrap_or(true),
next_run_at: opt_str(file, &k("next_run_at"), map.get("next_run_at"))?,
last_run_at: opt_str(file, &k("last_run_at"), map.get("last_run_at"))?,
last_status: opt_str(file, &k("last_status"), map.get("last_status"))?,
created_at: opt_str(file, &k("created_at"), map.get("created_at"))?,
residue,
id,
})
}
pub fn encode_job(job: &Job) -> Vec<(String, Value)> {
let mut sched = Map::new();
sched.insert("kind".into(), Value::String(job.schedule.kind().into()));
match &job.schedule {
Schedule::Once { run_at } => {
sched.insert("run_at".into(), Value::String(run_at.clone()));
}
Schedule::Interval { minutes } => {
sched.insert("minutes".into(), serde_json::json!(*minutes));
}
Schedule::Cron { expr, tz } => {
sched.insert("expr".into(), Value::String(expr.clone()));
if tz != "UTC" {
sched.insert("tz".into(), Value::String(tz.clone()));
}
}
}
if let Some(Value::Object(extra)) = job.residue.0.get("__schedule") {
for (k, v) in extra {
sched.insert(k.clone(), v.clone());
}
}
let origin = job
.origin
.as_ref()
.map(|o| {
let mut m = Map::new();
m.insert("platform".into(), Value::String(o.platform.clone()));
m.insert(
"chat_id".into(),
o.chat_id.clone().map(Value::String).unwrap_or(Value::Null),
);
m.insert(
"thread_id".into(),
o.thread_id
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
);
if let Some(Value::Object(extra)) = job.residue.0.get("__origin") {
for (k, v) in extra {
m.insert(k.clone(), v.clone());
}
}
Value::Object(m)
})
.unwrap_or(Value::Null);
let opt = |s: &Option<String>| s.clone().map(Value::String).unwrap_or(Value::Null);
let mut mapped: Vec<(String, Value)> = vec![
("id".into(), Value::String(job.id.clone())),
("schedule".into(), Value::Object(sched)),
("prompt".into(), opt(&job.prompt)),
(
"skills".into(),
Value::Array(
job.skills
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
),
),
("model".into(), opt(&job.model)),
("workdir".into(), opt(&job.workdir)),
(
"context_from".into(),
job.context_from
.as_ref()
.map(|l| Value::Array(l.iter().map(|s| Value::String(s.clone())).collect()))
.unwrap_or(Value::Null),
),
("deliver".into(), Value::String(job.deliver.render())),
(
"failure_deliver".into(),
job.failure_deliver
.as_ref()
.map(|t| Value::String(t.render()))
.unwrap_or(Value::Null),
),
(
"attach_to_session".into(),
job.attach_to_session
.map(Value::Bool)
.unwrap_or(Value::Null),
),
("origin".into(), origin),
(
"repeat".into(),
job.repeat
.as_ref()
.map(|r| serde_json::json!({"times": r.times, "completed": r.completed}))
.unwrap_or(Value::Null),
),
("enabled".into(), Value::Bool(job.enabled)),
("next_run_at".into(), opt(&job.next_run_at)),
("last_run_at".into(), opt(&job.last_run_at)),
("last_status".into(), opt(&job.last_status)),
("created_at".into(), opt(&job.created_at)),
];
let mut residue: Vec<(String, Value)> = job
.residue
.0
.iter()
.filter(|(k, _)| k.as_str() != "__schedule" && k.as_str() != "__origin")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let mut out: Vec<(String, Value)> = Vec::new();
for key in HERMES_JOB_ORDER {
if let Some(pos) = mapped.iter().position(|(k, _)| k == key) {
out.push(mapped.remove(pos));
} else if let Some(pos) = residue.iter().position(|(k, _)| k == key) {
out.push(residue.remove(pos));
}
}
out.extend(mapped);
out.extend(residue);
out
}
pub fn decode_fire_row(file: &str, row: &Map<String, Value>) -> Result<Fire> {
let id = req_str(file, "id", row.get("id"))?;
let status_word = row.get("status").and_then(Value::as_str).unwrap_or("");
let status = FireStatus::from_hermes_word(status_word).ok_or_else(|| {
load_error(
file,
&format!("{id}.status"),
format!(
"unknown fire status {}",
serde_json::to_string(status_word).unwrap()
),
)
})?;
Ok(Fire {
job_id: req_str(file, &format!("{id}.job_id"), row.get("job_id"))?,
session_id: None,
status,
claimed_at: req_str(file, &format!("{id}.claimed_at"), row.get("claimed_at"))?,
started_at: opt_str(file, &format!("{id}.started_at"), row.get("started_at"))?,
finished_at: opt_str(file, &format!("{id}.finished_at"), row.get("finished_at"))?,
error: opt_str(file, &format!("{id}.error"), row.get("error"))?,
obligation_id: None,
residue: row_residue_of(
row,
&[
"id",
"job_id",
"status",
"claimed_at",
"started_at",
"finished_at",
"error",
],
),
id,
})
}
pub fn encode_fire_row(fire: &Fire) -> Vec<Value> {
let r = &fire.residue.0;
let opt = |s: &Option<String>| s.clone().map(Value::String).unwrap_or(Value::Null);
vec![
Value::String(fire.id.clone()),
Value::String(fire.job_id.clone()),
r.get("source")
.cloned()
.unwrap_or(Value::String("scheduler".into())),
r.get("process_id")
.cloned()
.unwrap_or(Value::String(String::new())),
r.get("pid").cloned().unwrap_or(Value::from(0)),
r.get("process_started_at").cloned().unwrap_or(Value::Null),
Value::String(fire.status.hermes_word().into()),
Value::String(fire.claimed_at.clone()),
opt(&fire.started_at),
opt(&fire.finished_at),
opt(&fire.error),
]
}
pub const EXECUTION_COLUMNS: &[&str] = &[
"id",
"job_id",
"source",
"process_id",
"pid",
"process_started_at",
"status",
"claimed_at",
"started_at",
"finished_at",
"error",
];
pub fn decode_obligation_row(file: &str, row: &Map<String, Value>) -> Result<Obligation> {
let id = req_str(file, "obligation_id", row.get("obligation_id"))?;
let state_word = row.get("state").and_then(Value::as_str).unwrap_or("");
let state = ObligationState::from_hermes_word(state_word).ok_or_else(|| {
load_error(
file,
&format!("{id}.state"),
format!(
"unknown obligation state {}",
serde_json::to_string(state_word).unwrap()
),
)
})?;
let session_key = row
.get("session_key")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
let parsed = session_key
.as_deref()
.and_then(crate::ontology::parse_hermes_session_key)
.map(|(k, _)| k);
let target = SurfaceKey {
key: None,
platform: Some(req_str(
file,
&format!("{id}.platform"),
row.get("platform"),
)?),
kind: parsed.as_ref().and_then(|k| k.kind.clone()),
chat_id: Some(row.get("chat_id").and_then(text_of).unwrap_or_default()),
thread_id: row
.get("thread_id")
.and_then(text_of)
.filter(|s| !s.is_empty()),
participant_id: None,
};
let text = |k: &str| row.get(k).and_then(text_of);
let created_at = text("created_at").unwrap_or_else(|| "undefined".into());
let updated_at = text("updated_at").unwrap_or_else(|| created_at.clone());
Ok(Obligation {
target,
session_key,
content: OutboundContent {
text: row.get("content").and_then(text_of).unwrap_or_default(),
attachments: None,
reply_to: None,
format: None,
},
state,
attempts: row.get("attempts").and_then(number_of).unwrap_or(0.0) as u64,
last_error: row.get("last_error").and_then(text_of),
delivered_at: if state == ObligationState::Sent {
Some(updated_at.clone())
} else {
None
},
created_at,
updated_at,
posted: None,
source: ObligationSource::Turn { key: None },
residue: row_residue_of(
row,
&[
"obligation_id",
"session_key",
"platform",
"chat_id",
"thread_id",
"content",
"state",
"attempts",
"created_at",
"updated_at",
"last_error",
],
),
id,
})
}
pub fn encode_obligation_row(o: &Obligation) -> Vec<Value> {
let r = &o.residue.0;
let num = |s: &str| {
s.parse::<f64>()
.map(|f| serde_json::json!(f))
.unwrap_or(Value::from(0))
};
vec![
Value::String(o.id.clone()),
Value::String(o.session_key.clone().unwrap_or_default()),
Value::String(o.target.platform.clone().unwrap_or_default()),
Value::String(o.target.chat_id.clone().unwrap_or_default()),
o.target
.thread_id
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
Value::String(o.content.text.clone()),
Value::String(o.state.hermes_word().into()),
Value::from(o.attempts),
num(&o.created_at),
num(o
.delivered_at
.as_deref()
.map(|_| o.updated_at.as_str())
.unwrap_or(&o.updated_at)),
r.get("owner_pid").cloned().unwrap_or(Value::Null),
r.get("owner_started_at").cloned().unwrap_or(Value::Null),
o.last_error
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
r.get("adapter_profile").cloned().unwrap_or(Value::Null),
]
}
pub const OBLIGATION_COLUMNS: &[&str] = &[
"obligation_id",
"session_key",
"platform",
"chat_id",
"thread_id",
"content",
"state",
"attempts",
"created_at",
"updated_at",
"owner_pid",
"owner_started_at",
"last_error",
"adapter_profile",
];
const ROUTE_MATCH: &[&str] = &["platform", "guild_id", "chat_id", "thread_id"];
pub fn decode_route(file: &str, index: usize, raw: &Value) -> Result<Route> {
let map = raw.as_object().ok_or_else(|| {
load_error(
file,
&format!("profile_routes[{index}]"),
"expected an object",
)
})?;
let text = |k: &str| map.get(k).filter(|v| !v.is_null()).and_then(text_of);
Ok(Route {
name: opt_str(
file,
&format!("profile_routes[{index}].name"),
map.get("name"),
)?,
matches: RouteMatch {
platform: req_str(
file,
&format!("profile_routes[{index}].platform"),
map.get("platform"),
)?,
guild_id: text("guild_id"),
chat_id: text("chat_id"),
thread_id: text("thread_id"),
},
profile: req_str(
file,
&format!("profile_routes[{index}].profile"),
map.get("profile"),
)?,
residue: residue_of(
map,
&[
"name",
"profile",
"platform",
"guild_id",
"chat_id",
"thread_id",
],
),
})
}
pub fn encode_route(r: &Route) -> Value {
let mut out = Map::new();
if let Some(n) = &r.name {
out.insert("name".into(), Value::String(n.clone()));
}
let m = &r.matches;
for (k, v) in [
("platform", Some(&m.platform)),
("guild_id", m.guild_id.as_ref()),
("chat_id", m.chat_id.as_ref()),
("thread_id", m.thread_id.as_ref()),
] {
if let Some(v) = v {
out.insert(k.into(), Value::String(v.clone()));
}
}
let _ = ROUTE_MATCH;
out.insert("profile".into(), Value::String(r.profile.clone()));
for (k, v) in &r.residue.0 {
out.insert(k.clone(), v.clone());
}
Value::Object(out)
}
pub fn is_credential_key(key: &str) -> bool {
let k = key.to_ascii_lowercase();
[
"token",
"secret",
"key",
"password",
"passwd",
"api_key",
"app_secret",
"signing",
"webhook_url",
"private",
]
.iter()
.any(|w| k.contains(w))
}
pub fn placeholder_ref(text: &str) -> Option<&str> {
let inner = text.strip_prefix("${")?.strip_suffix('}')?;
inner
.strip_prefix("dotenv:")
.or_else(|| inner.strip_prefix("env:"))
.filter(|name| !name.is_empty())
}
pub fn credential_ref_name(platform: &str, key: &str) -> String {
format!("{platform}_{key}")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
pub fn decode_channel(
file: &str,
platform: &str,
raw: &Value,
vault: &mut BTreeMap<String, String>,
) -> Result<ChannelConfig> {
let map = raw
.as_object()
.ok_or_else(|| load_error(file, &format!("platforms.{platform}"), "expected an object"))?;
let mut ch = ChannelConfig {
platform: platform.to_string(),
enabled: opt_bool(
file,
&format!("platforms.{platform}.enabled"),
map.get("enabled"),
Some(true),
)?
.unwrap_or(true),
credentials: BTreeMap::new(),
extra: BTreeMap::new(),
};
fn walk(
map: &Map<String, Value>,
prefix: &str,
platform: &str,
ch: &mut ChannelConfig,
vault: &mut BTreeMap<String, String>,
) {
for (k, v) in map {
if k == "enabled" && prefix.is_empty() {
continue;
}
let name = format!("{prefix}{k}");
if let Some(obj) = v.as_object() {
if obj.len() == 1 {
if let Some(Value::String(n)) = obj.get("dotenv") {
ch.credentials.insert(name, SecretRef::Dotenv(n.clone()));
continue;
}
if let Some(Value::String(n)) = obj.get("env") {
ch.credentials.insert(name, SecretRef::Env(n.clone()));
continue;
}
}
if k == "extra" && prefix.is_empty() {
walk(obj, "extra.", platform, ch, vault);
continue;
}
}
if let Value::String(s) = v {
if is_credential_key(k) {
let r = credential_ref_name(platform, &name);
vault.insert(r.clone(), s.clone());
ch.credentials.insert(name, SecretRef::Dotenv(r));
continue;
}
}
ch.extra.insert(name, v.clone());
}
}
walk(map, "", platform, &mut ch, vault);
Ok(ch)
}
pub fn encode_channel(ch: &ChannelConfig, vault: Option<&BTreeMap<String, String>>) -> Value {
let mut out = Map::new();
out.insert("enabled".into(), Value::Bool(ch.enabled));
let mut extra = Map::new();
for (k, v) in &ch.extra {
match k.strip_prefix("extra.") {
Some(inner) => {
extra.insert(inner.to_string(), v.clone());
}
None => {
out.insert(k.clone(), v.clone());
}
}
}
for (k, r) in &ch.credentials {
let rendered = match vault.and_then(|v| v.get(r.name())) {
Some(value) => Value::String(value.clone()),
None => serde_json::to_value(r).unwrap(),
};
match k.strip_prefix("extra.") {
Some(inner) => {
extra.insert(inner.to_string(), rendered);
}
None => {
out.insert(k.clone(), rendered);
}
}
}
if !extra.is_empty() {
out.insert("extra".into(), Value::Object(extra));
}
Value::Object(out)
}
const SUB_MAPPED: &[&str] = &[
"events",
"prompt",
"skills",
"deliver",
"deliver_extra",
"secret",
"description",
"created_at",
];
pub fn decode_subscription(
file: &str,
name: &str,
raw: &Value,
vault: &mut BTreeMap<String, String>,
) -> Result<WebhookSubscription> {
let map = raw
.as_object()
.ok_or_else(|| load_error(file, name, "expected an object"))?;
let mut sub = WebhookSubscription {
name: name.to_string(),
secret: None,
events: map.get("events").and_then(Value::as_array).map(|a| {
a.iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
})
.collect()
}),
prompt_template: opt_str(file, &format!("{name}.prompt"), map.get("prompt"))?
.unwrap_or_default(),
deliver: parse_target(
file,
&format!("{name}.deliver"),
map.get("deliver"),
map.get("deliver_extra"),
)?,
skills: map
.get("skills")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
})
.collect()
})
.unwrap_or_default(),
description: opt_str(file, &format!("{name}.description"), map.get("description"))?,
created_at: opt_str(file, &format!("{name}.created_at"), map.get("created_at"))?,
residue: residue_of(map, SUB_MAPPED),
};
match map.get("secret") {
Some(Value::String(secret)) => {
let r = match placeholder_ref(secret) {
Some(name) => name.to_string(),
None => {
let r = credential_ref_name("webhook", &format!("{name}_secret"));
vault.insert(r.clone(), secret.clone());
r
}
};
sub.secret = Some(SecretRef::Dotenv(r));
}
Some(Value::Object(obj)) => {
if let Some(Value::String(n)) = obj.get("dotenv") {
sub.secret = Some(SecretRef::Dotenv(n.clone()));
} else if let Some(Value::String(n)) = obj.get("env") {
sub.secret = Some(SecretRef::Env(n.clone()));
} else {
return Err(load_error(
file,
&format!("{name}.secret"),
"a secret ref is {dotenv: NAME} or {env: NAME}",
));
}
}
Some(Value::Null) | None => {}
Some(_) => {
return Err(load_error(
file,
&format!("{name}.secret"),
"expected a string or a {dotenv|env: NAME} ref",
))
}
}
Ok(sub)
}
pub fn encode_subscription(
sub: &WebhookSubscription,
vault: Option<&BTreeMap<String, String>>,
) -> Value {
let mut out = Map::new();
if let Some(d) = &sub.description {
out.insert("description".into(), Value::String(d.clone()));
}
if let Some(e) = &sub.events {
out.insert(
"events".into(),
Value::Array(e.iter().map(|s| Value::String(s.clone())).collect()),
);
}
out.insert("prompt".into(), Value::String(sub.prompt_template.clone()));
out.insert(
"skills".into(),
Value::Array(
sub.skills
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
),
);
if let Some(t) = &sub.deliver {
match t {
Target::Explicit {
platform,
chat_id,
thread_id,
} => {
out.insert("deliver".into(), Value::String(platform.clone()));
let mut extra = Map::new();
if let Some(c) = chat_id {
extra.insert("chat_id".into(), Value::String(c.clone()));
}
if let Some(th) = thread_id {
extra.insert("thread_id".into(), Value::String(th.clone()));
}
if !extra.is_empty() {
out.insert("deliver_extra".into(), Value::Object(extra));
}
}
other => {
out.insert("deliver".into(), Value::String(other.render()));
}
}
}
if let Some(s) = &sub.secret {
let value = vault
.and_then(|v| v.get(s.name()).cloned())
.unwrap_or_else(|| format!("${{dotenv:{}}}", s.name()));
out.insert("secret".into(), Value::String(value));
}
if let Some(c) = &sub.created_at {
out.insert("created_at".into(), Value::String(c.clone()));
}
for (k, v) in &sub.residue.0 {
out.insert(k.clone(), v.clone());
}
Value::Object(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn job_round_trip_keeps_hermes_order_and_residue() {
let raw = serde_json::json!({
"id": "j1", "schedule": {"kind": "cron", "expr": "0 8 * * *", "tz": "UTC", "jitter": 3},
"prompt": "p", "deliver": "slack:C1:t1", "context_from": "self", "repeat": true, "attach_to_session": true,
"origin": {"platform": "telegram", "chat_id": "1", "thread_id": null, "chat_name": "x"},
"script": "echo hi", "fire_claim": {"pid": 1}, "enabled": false,
});
let job = decode_job("jobs.json", &raw).unwrap();
assert_eq!(job.context_from.as_deref(), Some(&["self".to_string()][..]));
assert_eq!(
job.repeat,
Some(Repeat {
times: None,
completed: 0
})
);
assert_eq!(job.attach_to_session, Some(true));
assert_eq!(
job.deliver,
Target::Explicit {
platform: "slack".into(),
chat_id: Some("C1".into()),
thread_id: Some("t1".into())
}
);
assert_eq!(
job.residue.0.get("script").and_then(Value::as_str),
Some("echo hi")
);
assert_eq!(job.residue.0["__schedule"]["jitter"], serde_json::json!(3));
assert_eq!(
job.residue.0["__origin"]["chat_name"],
serde_json::json!("x")
);
let ordered = encode_job(&job);
let keys: Vec<&str> = ordered.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(
keys,
vec![
"id",
"schedule",
"prompt",
"skills",
"script",
"model",
"workdir",
"context_from",
"deliver",
"failure_deliver",
"attach_to_session",
"origin",
"repeat",
"enabled",
"next_run_at",
"last_run_at",
"last_status",
"created_at",
"fire_claim"
]
);
let encoded: Map<String, Value> = encode_job(&job).into_iter().collect();
assert_eq!(encoded["schedule"]["jitter"], serde_json::json!(3));
assert_eq!(
encoded["repeat"],
serde_json::json!({"times": null, "completed": 0})
);
assert_eq!(encoded["origin"]["chat_name"], serde_json::json!("x"));
assert!(decode_job(
"jobs.json",
&serde_json::json!({"id": "x", "schedule": {"kind": "weekly"}})
)
.is_err());
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());
}
#[test]
fn strict_o_records() {
let e = decode_expiry(
"config.yaml",
Some(&serde_json::json!({"idle_minutes": 5, "bogus": 1})),
)
.unwrap_err();
assert!(e.to_string().contains("[expiry.bogus]: unknown key"), "{e}");
let w = decode_worker(
"config.yaml",
Some(&serde_json::json!({"harness": "codex", "cwd": "../x"})),
)
.unwrap_err();
assert!(w.to_string().contains("worker.cwd"), "{w}");
let w = decode_worker("config.yaml", Some(&serde_json::json!({"harness": "codex", "env": {"A": {"dotenv": "A_KEY"}, "B": "lit"}, "permission": {"default": "allow"}}))).unwrap().unwrap();
assert_eq!(
w.env["A"],
EnvValue::Secret(SecretRef::Dotenv("A_KEY".into()))
);
assert_eq!(w.permission.default, PermissionDefault::Allow);
assert_eq!(w.cwd, ".");
let a = decode_access("access.yaml", Some(&serde_json::json!({"allowlist": {"telegram": ["b", "a", "a"]}, "policy": {"slack": "open"}, "pairing_ttl_minutes": 30}))).unwrap();
assert_eq!(a.allowlist["telegram"], vec!["a", "b"]);
assert_eq!(a.policy["slack"], AccessPolicy::Open);
assert_eq!(a.pairing_ttl_minutes, Some(30));
assert!(decode_access(
"access.yaml",
Some(&serde_json::json!({"policy": {"slack": "maybe"}}))
)
.is_err());
let h = decode_home(
"config.yaml",
Some(&serde_json::json!({"platform": "telegram", "chat_type": "dm", "chat_id": "1"})),
)
.unwrap()
.unwrap();
assert_eq!(surface_key_string(&h), "telegram|dm|1||");
let h = decode_home(
"config.yaml",
Some(&serde_json::json!({"platform": "telegram", "kind": "dm", "chat_id": "1"})),
)
.unwrap()
.unwrap();
assert_eq!(encode_surface_key(&h)["kind"], serde_json::json!("dm"));
}
#[test]
fn channels_redact_credentials_into_the_vault() {
let mut vault = BTreeMap::new();
let ch = decode_channel("config.yaml", "telegram", &serde_json::json!({"enabled": true, "token": "T", "extra": {"key": "K", "host": "h"}, "mode": "polling"}), &mut vault).unwrap();
assert_eq!(vault.get("TELEGRAM_TOKEN").map(String::as_str), Some("T"));
assert_eq!(
vault.get("TELEGRAM_EXTRA_KEY").map(String::as_str),
Some("K")
);
assert_eq!(
ch.credentials["token"],
SecretRef::Dotenv("TELEGRAM_TOKEN".into())
);
assert_eq!(
ch.credentials["extra.key"],
SecretRef::Dotenv("TELEGRAM_EXTRA_KEY".into())
);
assert_eq!(ch.extra["extra.host"], serde_json::json!("h"));
assert_eq!(ch.extra["mode"], serde_json::json!("polling"));
let ours = encode_channel(&ch, None);
assert_eq!(
ours["token"],
serde_json::json!({"dotenv": "TELEGRAM_TOKEN"})
);
let hermes = encode_channel(&ch, Some(&vault));
assert_eq!(hermes["token"], serde_json::json!("T"));
assert_eq!(hermes["extra"]["key"], serde_json::json!("K"));
}
#[test]
fn fires_obligations_subscriptions() {
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();
let fire = decode_fire_row("executions.db", &row).unwrap();
assert_eq!(fire.status, FireStatus::Succeeded);
assert_eq!(fire.residue.0.get("pid"), Some(&serde_json::json!(4)));
assert_eq!(encode_fire_row(&fire)[6], serde_json::json!("completed"));
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();
let o = decode_obligation_row("state.db", &row).unwrap();
assert_eq!(o.state, ObligationState::Sent);
assert_eq!(o.target.kind.as_deref(), Some("group"));
assert_eq!(o.delivered_at.as_deref(), Some("2.5"));
assert_eq!(encode_obligation_row(&o)[6], serde_json::json!("delivered"));
let mut vault = BTreeMap::new();
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();
assert_eq!(
sub.deliver,
Some(Target::Explicit {
platform: "telegram".into(),
chat_id: Some("1".into()),
thread_id: None
})
);
assert_eq!(
vault.get("WEBHOOK_DEPLOYS_SECRET").map(String::as_str),
Some("S")
);
let back = encode_subscription(&sub, Some(&vault));
assert_eq!(back["secret"], serde_json::json!("S"));
assert_eq!(back["deliver_extra"]["chat_id"], serde_json::json!("1"));
assert_eq!(back["note"], serde_json::json!(1));
assert_eq!(
encode_subscription(&sub, None)["secret"],
serde_json::json!("${dotenv:WEBHOOK_DEPLOYS_SECRET}")
);
let mut ours = BTreeMap::new();
let reread = decode_subscription(
"webhook_subscriptions.json",
"deploys",
&encode_subscription(&sub, None),
&mut ours,
)
.unwrap();
assert_eq!(reread.secret, sub.secret);
assert!(ours.is_empty(), "a placeholder is never a value: {ours:?}");
}
}