use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{HarnessHomes, HarnessId, Result};
pub const RUN_HARNESSES: &[&str] = &[
HarnessId::HERMES,
HarnessId::OPENCLAW,
HarnessId::ORCHESTRATOR,
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HarnessRun {
pub id: String,
pub harness: String,
pub job_id: String,
pub status: String,
pub claimed_at: Option<String>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub error: Option<String>,
pub session_id: Option<String>,
pub delivery: Option<RunDelivery>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunDelivery {
pub target: Option<String>,
pub state: Option<String>,
pub attempts: Option<u64>,
pub last_error: Option<String>,
pub delivered_at: Option<String>,
}
impl HarnessRun {
pub fn from_fire(
harness: &str,
fire: &supercode_interchange::orchestration::Fire,
session_id: Option<String>,
delivery: Option<RunDelivery>,
) -> Self {
Self {
id: fire.id.clone(),
harness: harness.into(),
job_id: fire.job_id.clone(),
status: fire.status.hermes_word().to_string(),
claimed_at: Some(fire.claimed_at.clone()),
started_at: fire.started_at.clone(),
finished_at: fire.finished_at.clone(),
error: fire.error.clone(),
session_id: session_id.or_else(|| fire.session_id.clone()),
delivery,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunSource {
pub harness: String,
pub path: PathBuf,
pub state: String,
pub profile: Option<String>,
pub detail: Option<String>,
}
impl RunSource {
fn store(harness: &str, path: PathBuf, state: &str, profile: Option<String>) -> Self {
Self {
harness: harness.to_string(),
path,
state: state.to_string(),
profile,
detail: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunsListing {
pub runs: Vec<HarnessRun>,
pub sources: Vec<RunSource>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct RunsQuery {
pub harness: Option<String>,
pub job: Option<String>,
pub limit: Option<usize>,
pub homes: HarnessHomes,
}
pub fn supports_runs(harness: &str) -> bool {
RUN_HARNESSES.contains(&harness)
}
pub fn list_runs(query: &RunsQuery) -> Result<RunsListing> {
let (rows, sources) = collect(query);
let mut runs: Vec<HarnessRun> = rows.into_iter().map(|(run, _)| run).collect();
if let Some(limit) = query.limit {
runs.truncate(limit);
}
Ok(RunsListing { runs, sources })
}
pub fn get_run(
harness: &str,
id: &str,
homes: &HarnessHomes,
) -> Result<Option<(HarnessRun, Value)>> {
let (rows, _) = collect(&RunsQuery {
harness: Some(harness.to_string()),
homes: homes.clone(),
..RunsQuery::default()
});
Ok(rows.into_iter().find(|(run, _)| run.id == id))
}
fn collect(query: &RunsQuery) -> (Vec<(HarnessRun, Value)>, Vec<RunSource>) {
let mut rows = Vec::new();
let mut sources = Vec::new();
let wanted = query.harness.as_deref();
if wanted.is_none_or(|harness| harness == HarnessId::HERMES) {
collect_hermes(query, &mut rows, &mut sources);
}
if wanted.is_none_or(|harness| harness == HarnessId::OPENCLAW) {
collect_openclaw(query, &mut rows, &mut sources);
}
if wanted.is_none_or(|harness| harness == HarnessId::ORCHESTRATOR) {
collect_hermes_shaped(
HarnessId::ORCHESTRATOR,
orchestrator_ledgers(&query.homes),
query,
&mut rows,
&mut sources,
);
}
(rows, sources)
}
struct HermesLedger {
executions: PathBuf,
profile: Option<String>,
}
fn hermes_ledgers(homes: &HarnessHomes) -> Vec<HermesLedger> {
let root = homes
.hermes
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let mut ledgers = vec![HermesLedger {
executions: root.join("cron/executions.db"),
profile: None,
}];
if let Ok(entries) = std::fs::read_dir(root.join("profiles")) {
let mut found: Vec<HermesLedger> = entries
.flatten()
.filter(|entry| entry.path().is_dir())
.map(|entry| {
let home = entry.path();
HermesLedger {
executions: home.join("cron/executions.db"),
profile: Some(entry.file_name().to_string_lossy().into_owned()),
}
})
.collect();
found.sort_by(|left, right| left.profile.cmp(&right.profile));
ledgers.extend(found);
}
ledgers
}
fn orchestrator_ledgers(homes: &HarnessHomes) -> Vec<HermesLedger> {
crate::orchestrator_profile_dirs(&homes.orchestrator)
.into_iter()
.map(|(name, dir)| HermesLedger {
executions: dir.join("cron/executions.db"),
profile: (name != "default").then_some(name),
})
.collect()
}
const HERMES_EXECUTION_COLUMNS: &[&str] = &[
"id",
"job_id",
"source",
"process_id",
"pid",
"process_started_at",
"status",
"claimed_at",
"started_at",
"finished_at",
"error",
];
fn collect_hermes(
query: &RunsQuery,
rows: &mut Vec<(HarnessRun, Value)>,
sources: &mut Vec<RunSource>,
) {
collect_hermes_shaped(
HarnessId::HERMES,
hermes_ledgers(&query.homes),
query,
rows,
sources,
);
}
fn collect_hermes_shaped(
harness: &str,
ledgers: Vec<HermesLedger>,
query: &RunsQuery,
rows: &mut Vec<(HarnessRun, Value)>,
sources: &mut Vec<RunSource>,
) {
use supercode_interchange::orchestration::codec::{from_hermes, load_home, Flavor};
let loaded = match harness {
HarnessId::HERMES => {
let home = query
.homes
.hermes
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
from_hermes(&home)
}
_ => load_home(&query.homes.orchestrator, Flavor::Orchestrator),
};
let loaded = match loaded {
Ok(loaded) => loaded,
Err(error) => {
for ledger in ledgers {
let state = if ledger.executions.is_file() {
"unreadable"
} else {
"absent_store"
};
sources.push(RunSource {
detail: (state == "unreadable").then(|| error.to_string()),
..RunSource::store(harness, ledger.executions, state, ledger.profile)
});
}
return;
}
};
for ledger in ledgers {
if !ledger.executions.is_file() {
sources.push(RunSource::store(
harness,
ledger.executions,
"absent_store",
ledger.profile,
));
continue;
}
let name = ledger.profile.clone().unwrap_or_else(|| "default".into());
let Some(profile) = loaded.orchestration.profiles.get(&name) else {
sources.push(RunSource::store(
harness,
ledger.executions,
"absent_store",
ledger.profile,
));
continue;
};
sources.push(RunSource::store(
harness,
ledger.executions,
"read",
ledger.profile,
));
let mut fires: Vec<_> = profile
.fires
.iter()
.filter(|fire| query.job.as_deref().is_none_or(|job| job == fire.job_id))
.collect();
fires.sort_by(|a, b| {
b.claimed_at
.cmp(&a.claimed_at)
.then_with(|| b.id.cmp(&a.id))
});
if let Some(limit) = query.limit {
fires.truncate(limit);
}
for fire in fires {
let delivery = fire
.obligation_id
.as_deref()
.and_then(|id| obligation_delivery(&loaded, id));
let native: Map<String, Value> = HERMES_EXECUTION_COLUMNS
.iter()
.map(|c| (*c).to_string())
.zip(supercode_interchange::orchestration::codec::decode::encode_fire_row(fire))
.collect();
rows.push((
HarnessRun::from_fire(harness, fire, None, delivery),
Value::Object(native),
));
}
}
}
fn obligation_delivery(
loaded: &supercode_interchange::orchestration::codec::LoadedHome,
id: &str,
) -> Option<RunDelivery> {
let obligation = loaded
.orchestration
.profiles
.values()
.flat_map(|p| p.obligations.iter())
.find(|o| o.id == id)?;
let platform = obligation.target.platform.clone().unwrap_or_default();
let chat_id = obligation.target.chat_id.clone().unwrap_or_default();
Some(RunDelivery {
target: Some(
match obligation
.target
.thread_id
.as_deref()
.filter(|t| !t.is_empty())
{
Some(thread) => format!("{platform}:{chat_id}:{thread}"),
None => format!("{platform}:{chat_id}"),
},
),
state: Some(obligation.state.hermes_word().to_string()),
attempts: Some(obligation.attempts),
last_error: obligation.last_error.clone().filter(|e| !e.is_empty()),
delivered_at: obligation
.delivered_at
.as_deref()
.and_then(|at| at.parse::<f64>().ok())
.map(|seconds| crate::sidecar::ms_to_rfc3339((seconds * 1000.0) as i64)),
})
}
fn openclaw_state_db(homes: &HarnessHomes) -> PathBuf {
homes.openclaw.join("state/openclaw.sqlite")
}
fn collect_openclaw(
query: &RunsQuery,
rows: &mut Vec<(HarnessRun, Value)>,
sources: &mut Vec<RunSource>,
) {
use supercode_interchange::orchestration::codec::{from_openclaw, openclaw::encode_fire_row};
let state_db = openclaw_state_db(&query.homes);
if !state_db.is_file() {
sources.push(RunSource::store(
HarnessId::OPENCLAW,
state_db,
"absent_store",
None,
));
return;
}
let loaded = match from_openclaw(&query.homes.openclaw) {
Ok(loaded) => loaded,
Err(error) => {
sources.push(RunSource {
detail: Some(error.to_string()),
..RunSource::store(HarnessId::OPENCLAW, state_db, "unreadable", None)
});
return;
}
};
sources.push(RunSource::store(
HarnessId::OPENCLAW,
state_db,
"read",
None,
));
let text = |fire: &supercode_interchange::orchestration::Fire, key: &str| {
fire.residue
.0
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
};
let mut fires: Vec<_> = loaded
.orchestration
.profiles
.values()
.flat_map(|profile| profile.fires.iter().map(move |fire| (profile, fire)))
.filter(|(_, fire)| query.job.as_deref().is_none_or(|job| job == fire.job_id))
.collect();
fires.sort_by(|(_, a), (_, b)| {
b.finished_at
.cmp(&a.finished_at)
.then_with(|| b.id.cmp(&a.id))
});
if let Some(limit) = query.limit {
fires.truncate(limit);
}
for (profile, fire) in fires {
let target = profile.jobs.get(&fire.job_id).and_then(|job| {
let delivery = job.residue.0.get("__delivery")?.as_object()?;
let word = |k: &str| {
delivery
.get(k)
.and_then(Value::as_str)
.filter(|v| !v.is_empty())
.map(str::to_string)
};
match (word("channel"), word("to")) {
(Some(channel), Some(to)) => Some(format!("{channel}:{to}")),
(Some(only), None) | (None, Some(only)) => Some(only),
(None, None) => None,
}
});
let delivery = openclaw_delivery(
target,
text(fire, "delivery_status"),
text(fire, "delivery_error"),
fire.residue.0.get("delivered").and_then(Value::as_i64),
);
let store_key = text(fire, "store_key").unwrap_or_default();
rows.push((
HarnessRun {
id: fire.id.clone(),
harness: HarnessId::OPENCLAW.into(),
job_id: fire.job_id.clone(),
status: text(fire, "status").unwrap_or_default(),
claimed_at: None,
started_at: fire.started_at.clone(),
finished_at: fire.finished_at.clone(),
error: fire.error.clone().filter(|e| !e.is_empty()),
session_id: fire.session_id.clone().filter(|s| !s.is_empty()),
delivery,
},
Value::Object(encode_fire_row(fire, None, &store_key)),
));
}
}
fn openclaw_delivery(
target: Option<String>,
status: Option<String>,
error: Option<String>,
delivered: Option<i64>,
) -> Option<RunDelivery> {
let status = status.filter(|status| !status.is_empty());
let error = error.filter(|error| !error.is_empty());
if status.is_none() && error.is_none() && delivered.is_none() {
return None;
}
Some(RunDelivery {
target,
state: status.or_else(|| {
delivered.map(|delivered| {
if delivered == 0 {
"not-delivered".to_string()
} else {
"delivered".to_string()
}
})
}),
attempts: None,
last_error: error,
delivered_at: None,
})
}