supercode_harness/runs.rs
1//! Observed-tier, READ-ONLY inventory of scheduled-job FIRES across the
2//! harnesses that keep a run store (Domain 11, concept 7).
3//!
4//! A run is one execution of a [`crate::jobs::ScheduledJob`], with its
5//! outcome and — where the harness leaves enough behind to recover it — the
6//! session the fire opened.
7//!
8//! * **Hermes** — `HERMES_HOME/cron/executions.db`, a profile-local SQLite
9//! audit ledger (`cron/executions.py`: "the ledger records what is known
10//! about each attempt; it is not a retry queue"). One `executions` row per
11//! attempt: `id, job_id, source, process_id, pid, process_started_at,
12//! status, claimed_at, started_at, finished_at, error`, status one of
13//! `claimed | running | completed | failed | unknown`. A Hermes profile home
14//! is a full HERMES_HOME, so each `profiles/<name>/` has its own ledger.
15//! * **OpenClaw** — `cron_run_logs` in the shared state database
16//! (`<state dir>/state/openclaw.sqlite`) at the pinned 2026.7.1-2:
17//! `store_key, job_id, seq, ts, status, error, …, session_id, session_key,
18//! run_id, run_at_ms, duration_ms, …, entry_json`, status one of
19//! `ok | error | skipped`. `store_key` is `path.resolve(cron.store)` — the
20//! legacy `cron/jobs.json` path used purely as a per-store partition key;
21//! at the pin no such file exists, and neither does a `cron/runs/*.jsonl`
22//! run log (that shape is what `openclaw doctor --fix` imports FROM).
23//! * **Claude Code** — has no run store at all. A `CronCreate` fire is an
24//! ordinary turn inside the session that created the job, so `runs.list` /
25//! `runs.get` refuse for `claude-code` rather than inventing a fire record
26//! from turns. See [`RUN_HARNESSES`].
27//!
28//! Nothing here writes, claims, retries, or prunes. Every store is opened
29//! `SQLITE_OPEN_READ_ONLY` — these are live databases owned by a running
30//! scheduler.
31//!
32//! **Status words are the harness's own.** Hermes says `completed`/`failed`,
33//! OpenClaw says `ok`/`error`; renaming either onto a shared vocabulary would
34//! discard the distinction Hermes draws between `failed` (a terminal result
35//! it wrote) and `unknown` (an attempt whose owner died before writing one).
36//!
37//! **Delivery (ORCH-13).** Where a fire's output went is read from each
38//! harness's own delivery record:
39//!
40//! * **OpenClaw** writes it onto the run-log row itself — `delivery_status`,
41//! `delivery_error`, `delivered` — and declares the destination on the job
42//! (`cron_jobs.delivery_channel` / `delivery_to`), so the row's `target` is
43//! joined from there: the run log records the OUTCOME, never the address.
44//! * **Hermes** keeps a separate `delivery_obligations` ledger inside
45//! `state.db` (`gateway/delivery_ledger.py`), keyed by the CONVERSATION's
46//! `session_key` and the platform surface — not by job or fire. So a fire is
47//! matched to an obligation the way [`join_hermes_session`] matches a
48//! session: by the fire's own `[claimed_at, finished_at]` window, on the
49//! fire's own surface. See [`hermes_delivery`] for the two questions asked,
50//! in order, and for why an unanchored ledger instant answers `None`.
51//!
52//! `None` stays honest: a fire whose delivery nothing recorded says so rather
53//! than borrowing a neighbouring fire's outcome.
54
55use std::path::{Path, PathBuf};
56
57use serde::{Deserialize, Serialize};
58use serde_json::{Map, Value};
59
60use crate::{HarnessHomes, HarnessId, Result};
61
62/// Harnesses that keep a run store at all. Every other harness answers
63/// `runs.list` / `runs.get` with `UnsupportedAction`, never an empty list —
64/// an absent store and an empty history are different answers.
65pub const RUN_HARNESSES: &[&str] = &[
66 HarnessId::HERMES,
67 HarnessId::OPENCLAW,
68 HarnessId::ORCHESTRATOR,
69];
70
71/// Hard stop on how far a compression chain is followed from a fire's own
72/// session to the readable tip. Hermes chains are short; a cycle in a
73/// corrupted store must not spin.
74/// One fire of one scheduled job, projected onto the uniform Domain 11 row.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct HarnessRun {
77 /// Harness-native run id: Hermes's `executions.id` (a uuid hex),
78 /// OpenClaw's `run_id` — or `<job_id>#<seq>` when a run-log row predates
79 /// run ids, since the `(job_id, seq)` pair is that store's own key.
80 pub id: String,
81 /// Owning harness.
82 pub harness: String,
83 /// The scheduled job this fire belongs to.
84 pub job_id: String,
85 /// The harness's own outcome word: Hermes `claimed | running | completed
86 /// | failed | unknown`, OpenClaw `ok | error | skipped`.
87 pub status: String,
88 /// When the scheduler claimed the fire. Hermes only — OpenClaw's run log
89 /// is written once, at finish, and records no claim.
90 pub claimed_at: Option<String>,
91 /// When the fire began executing.
92 pub started_at: Option<String>,
93 /// When the fire reached a terminal state.
94 pub finished_at: Option<String>,
95 /// The failure the harness recorded, verbatim.
96 pub error: Option<String>,
97 /// The session this fire opened, when it is recoverable: OpenClaw records
98 /// it on the row; Hermes does not, so it is recovered by matching
99 /// `cron_<job_id>_<YYYYMMDD_HHMMSS>` session ids inside the fire's own
100 /// window (see [`join_hermes_session`]). `None` means no session is
101 /// recoverable — never a guess.
102 pub session_id: Option<String>,
103 /// Where this fire's output went, when the harness recorded a delivery
104 /// for it. `None` means nothing in the harness's delivery record matches
105 /// this fire — never that the delivery failed.
106 pub delivery: Option<RunDelivery>,
107}
108
109/// A fire's delivery outcome (ORCH-13).
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct RunDelivery {
112 /// Where the output was addressed: Hermes's obligation surface
113 /// `<platform>:<chat_id>[:<thread_id>]`, or OpenClaw's job-declared
114 /// `<channel>[:<to>]`.
115 pub target: Option<String>,
116 /// The harness's own state word: Hermes `pending | attempting |
117 /// delivered | failed | abandoned`, OpenClaw `delivery_status`.
118 pub state: Option<String>,
119 /// Delivery attempts recorded. Hermes only — OpenClaw's run log counts
120 /// no attempts.
121 pub attempts: Option<u64>,
122 /// The last delivery failure, verbatim.
123 pub last_error: Option<String>,
124 /// When the harness stamped the delivery as done. Hermes only: it is the
125 /// obligation's `updated_at` on a `delivered` row (the ledger writes no
126 /// separate delivered-at column). OpenClaw's run log records `delivered`
127 /// as a flag with no instant of its own, so it stays empty there.
128 pub delivered_at: Option<String>,
129}
130
131impl HarnessRun {
132 /// The observed row of a typed orchestration [`Fire`] (`docs/ONTOLOGY.md` §2.7):
133 /// the join to its session and delivery is the caller's, as it is for the
134 /// ledger read, so `runs list` and the orchestration never disagree about a fire.
135 pub fn from_fire(
136 harness: &str,
137 fire: &supercode_interchange::orchestration::Fire,
138 session_id: Option<String>,
139 delivery: Option<RunDelivery>,
140 ) -> Self {
141 Self {
142 id: fire.id.clone(),
143 harness: harness.into(),
144 job_id: fire.job_id.clone(),
145 status: fire.status.hermes_word().to_string(),
146 claimed_at: Some(fire.claimed_at.clone()),
147 started_at: fire.started_at.clone(),
148 finished_at: fire.finished_at.clone(),
149 error: fire.error.clone(),
150 session_id: session_id.or_else(|| fire.session_id.clone()),
151 delivery,
152 }
153 }
154}
155
156/// One store the listing consulted, and what it found there.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct RunSource {
159 /// Harness the store belongs to.
160 pub harness: String,
161 /// Absolute path consulted.
162 pub path: PathBuf,
163 /// `read` | `absent_store` | `unreadable`.
164 pub state: String,
165 /// Hermes profile home this ledger belongs to.
166 pub profile: Option<String>,
167 /// Why a store is `unreadable`.
168 pub detail: Option<String>,
169}
170
171impl RunSource {
172 fn store(harness: &str, path: PathBuf, state: &str, profile: Option<String>) -> Self {
173 Self {
174 harness: harness.to_string(),
175 path,
176 state: state.to_string(),
177 profile,
178 detail: None,
179 }
180 }
181}
182
183/// Result of a `runs.list`: the rows plus every store that was consulted.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct RunsListing {
186 /// Uniform rows, harness-major then store order, newest fire first
187 /// within a store.
188 pub runs: Vec<HarnessRun>,
189 /// Stores consulted, including the ones that were absent.
190 pub sources: Vec<RunSource>,
191}
192
193/// Filters for a run-history read.
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(default)]
196pub struct RunsQuery {
197 /// Restrict to one harness. Absent means every harness in
198 /// [`RUN_HARNESSES`].
199 pub harness: Option<String>,
200 /// Restrict to one job's fires.
201 pub job: Option<String>,
202 /// Cap on rows. Applied per store as the read's own `LIMIT` (so a long
203 /// history is never fully materialized) and again to the merged listing.
204 pub limit: Option<usize>,
205 /// Storage roots to read.
206 pub homes: HarnessHomes,
207}
208
209/// Whether `harness` keeps a run store.
210pub fn supports_runs(harness: &str) -> bool {
211 RUN_HARNESSES.contains(&harness)
212}
213
214/// Read every fire the query selects.
215///
216/// Read-only: every store is opened `SQLITE_OPEN_READ_ONLY`, and no fire is
217/// claimed, retried, or pruned.
218pub fn list_runs(query: &RunsQuery) -> Result<RunsListing> {
219 let (rows, sources) = collect(query);
220 let mut runs: Vec<HarnessRun> = rows.into_iter().map(|(run, _)| run).collect();
221 if let Some(limit) = query.limit {
222 runs.truncate(limit);
223 }
224 Ok(RunsListing { runs, sources })
225}
226
227/// Read one fire by harness and id, with the verbatim native record beside
228/// the uniform row. `Ok(None)` means the harness's stores hold no such run.
229pub fn get_run(
230 harness: &str,
231 id: &str,
232 homes: &HarnessHomes,
233) -> Result<Option<(HarnessRun, Value)>> {
234 let (rows, _) = collect(&RunsQuery {
235 harness: Some(harness.to_string()),
236 homes: homes.clone(),
237 ..RunsQuery::default()
238 });
239 Ok(rows.into_iter().find(|(run, _)| run.id == id))
240}
241
242/// Every store the query selects, in harness-major order, each row paired
243/// with the harness's own record so `get` never re-reads (and so the two
244/// verbs can never disagree about a fire).
245fn collect(query: &RunsQuery) -> (Vec<(HarnessRun, Value)>, Vec<RunSource>) {
246 let mut rows = Vec::new();
247 let mut sources = Vec::new();
248 let wanted = query.harness.as_deref();
249 if wanted.is_none_or(|harness| harness == HarnessId::HERMES) {
250 collect_hermes(query, &mut rows, &mut sources);
251 }
252 if wanted.is_none_or(|harness| harness == HarnessId::OPENCLAW) {
253 collect_openclaw(query, &mut rows, &mut sources);
254 }
255 if wanted.is_none_or(|harness| harness == HarnessId::ORCHESTRATOR) {
256 collect_hermes_shaped(
257 HarnessId::ORCHESTRATOR,
258 orchestrator_ledgers(&query.homes),
259 query,
260 &mut rows,
261 &mut sources,
262 );
263 }
264 (rows, sources)
265}
266
267// ---------------------------------------------------------------------------
268// Hermes — `cron/executions.db`, joined to `state.db` sessions
269// ---------------------------------------------------------------------------
270
271/// One Hermes execution ledger, with the profile home it belongs to and the
272/// `state.db` whose sessions its fires opened.
273struct HermesLedger {
274 executions: PathBuf,
275 profile: Option<String>,
276}
277
278/// Every `cron/executions.db` a Hermes install can hold.
279///
280/// A Hermes profile home IS a full HERMES_HOME (`hermes_constants.get_hermes_home`
281/// resolves the context-local profile override first), so the root home and
282/// every `profiles/<name>/` carry their own ledger AND their own `state.db`.
283/// A profile that has no `state.db` of its own falls back to the root store,
284/// where its rows carry `profile_name = <name>`.
285fn hermes_ledgers(homes: &HarnessHomes) -> Vec<HermesLedger> {
286 // `HarnessHomes::hermes` addresses `state.db`; the cron store is its
287 // sibling under the same HERMES_HOME.
288 let root = homes
289 .hermes
290 .parent()
291 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
292 let mut ledgers = vec![HermesLedger {
293 executions: root.join("cron/executions.db"),
294 profile: None,
295 }];
296 if let Ok(entries) = std::fs::read_dir(root.join("profiles")) {
297 let mut found: Vec<HermesLedger> = entries
298 .flatten()
299 .filter(|entry| entry.path().is_dir())
300 .map(|entry| {
301 let home = entry.path();
302 HermesLedger {
303 executions: home.join("cron/executions.db"),
304 profile: Some(entry.file_name().to_string_lossy().into_owned()),
305 }
306 })
307 .collect();
308 found.sort_by(|left, right| left.profile.cmp(&right.profile));
309 ledgers.extend(found);
310 }
311 ledgers
312}
313
314/// Every ledger an orchestrator home holds.
315///
316/// Identical in shape to [`hermes_ledgers`] because the folder is: each
317/// profile folder is a complete home with its own `cron/executions.db`,
318/// `cron/jobs.json` and `state.db` (`docs/ORCHESTRATOR-IR.md` §6). Bindings
319/// and obligations live in the profile's own store, so — unlike Hermes's
320/// multiplexed gateway — there is no fallback to a root store.
321fn orchestrator_ledgers(homes: &HarnessHomes) -> Vec<HermesLedger> {
322 crate::orchestrator_profile_dirs(&homes.orchestrator)
323 .into_iter()
324 .map(|(name, dir)| HermesLedger {
325 executions: dir.join("cron/executions.db"),
326 profile: (name != "default").then_some(name),
327 })
328 .collect()
329}
330
331const HERMES_EXECUTION_COLUMNS: &[&str] = &[
332 "id",
333 "job_id",
334 "source",
335 "process_id",
336 "pid",
337 "process_started_at",
338 "status",
339 "claimed_at",
340 "started_at",
341 "finished_at",
342 "error",
343];
344
345fn collect_hermes(
346 query: &RunsQuery,
347 rows: &mut Vec<(HarnessRun, Value)>,
348 sources: &mut Vec<RunSource>,
349) {
350 collect_hermes_shaped(
351 HarnessId::HERMES,
352 hermes_ledgers(&query.homes),
353 query,
354 rows,
355 sources,
356 );
357}
358
359/// Every fire of a set of Hermes-SHAPED ledgers, as the orchestration codec
360/// reads the home: Hermes and the orchestrator keep the same `executions`
361/// table and `delivery_obligations` ledger, so one compile serves both and
362/// each ledger's fires are projected through [`HarnessRun::from_fire`] —
363/// newest-claimed first, the ledger's own order. The fire's session and
364/// delivery are the codec's derived links (`Fire.session_id`,
365/// `Fire.obligation_id`).
366fn collect_hermes_shaped(
367 harness: &str,
368 ledgers: Vec<HermesLedger>,
369 query: &RunsQuery,
370 rows: &mut Vec<(HarnessRun, Value)>,
371 sources: &mut Vec<RunSource>,
372) {
373 use supercode_interchange::orchestration::codec::{from_hermes, load_home, Flavor};
374 let loaded = match harness {
375 HarnessId::HERMES => {
376 let home = query
377 .homes
378 .hermes
379 .parent()
380 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
381 from_hermes(&home)
382 }
383 _ => load_home(&query.homes.orchestrator, Flavor::Orchestrator),
384 };
385 let loaded = match loaded {
386 Ok(loaded) => loaded,
387 Err(error) => {
388 for ledger in ledgers {
389 let state = if ledger.executions.is_file() {
390 "unreadable"
391 } else {
392 "absent_store"
393 };
394 sources.push(RunSource {
395 detail: (state == "unreadable").then(|| error.to_string()),
396 ..RunSource::store(harness, ledger.executions, state, ledger.profile)
397 });
398 }
399 return;
400 }
401 };
402 for ledger in ledgers {
403 if !ledger.executions.is_file() {
404 sources.push(RunSource::store(
405 harness,
406 ledger.executions,
407 "absent_store",
408 ledger.profile,
409 ));
410 continue;
411 }
412 let name = ledger.profile.clone().unwrap_or_else(|| "default".into());
413 let Some(profile) = loaded.orchestration.profiles.get(&name) else {
414 sources.push(RunSource::store(
415 harness,
416 ledger.executions,
417 "absent_store",
418 ledger.profile,
419 ));
420 continue;
421 };
422 sources.push(RunSource::store(
423 harness,
424 ledger.executions,
425 "read",
426 ledger.profile,
427 ));
428 let mut fires: Vec<_> = profile
429 .fires
430 .iter()
431 .filter(|fire| query.job.as_deref().is_none_or(|job| job == fire.job_id))
432 .collect();
433 fires.sort_by(|a, b| {
434 b.claimed_at
435 .cmp(&a.claimed_at)
436 .then_with(|| b.id.cmp(&a.id))
437 });
438 if let Some(limit) = query.limit {
439 fires.truncate(limit);
440 }
441 for fire in fires {
442 let delivery = fire
443 .obligation_id
444 .as_deref()
445 .and_then(|id| obligation_delivery(&loaded, id));
446 let native: Map<String, Value> = HERMES_EXECUTION_COLUMNS
447 .iter()
448 .map(|c| (*c).to_string())
449 .zip(supercode_interchange::orchestration::codec::decode::encode_fire_row(fire))
450 .collect();
451 rows.push((
452 HarnessRun::from_fire(harness, fire, None, delivery),
453 Value::Object(native),
454 ));
455 }
456 }
457}
458
459/// The delivery an obligation records, as the run shows it.
460fn obligation_delivery(
461 loaded: &supercode_interchange::orchestration::codec::LoadedHome,
462 id: &str,
463) -> Option<RunDelivery> {
464 let obligation = loaded
465 .orchestration
466 .profiles
467 .values()
468 .flat_map(|p| p.obligations.iter())
469 .find(|o| o.id == id)?;
470 let platform = obligation.target.platform.clone().unwrap_or_default();
471 let chat_id = obligation.target.chat_id.clone().unwrap_or_default();
472 Some(RunDelivery {
473 target: Some(
474 match obligation
475 .target
476 .thread_id
477 .as_deref()
478 .filter(|t| !t.is_empty())
479 {
480 Some(thread) => format!("{platform}:{chat_id}:{thread}"),
481 None => format!("{platform}:{chat_id}"),
482 },
483 ),
484 state: Some(obligation.state.hermes_word().to_string()),
485 attempts: Some(obligation.attempts),
486 last_error: obligation.last_error.clone().filter(|e| !e.is_empty()),
487 delivered_at: obligation
488 .delivered_at
489 .as_deref()
490 .and_then(|at| at.parse::<f64>().ok())
491 .map(|seconds| crate::sidecar::ms_to_rfc3339((seconds * 1000.0) as i64)),
492 })
493}
494
495fn openclaw_state_db(homes: &HarnessHomes) -> PathBuf {
496 homes.openclaw.join("state/openclaw.sqlite")
497}
498
499/// OpenClaw's run logs as the orchestration codec reads the store: every
500/// profile's fires, newest first (`ts`, then id), the store's own status word
501/// and delivery columns from the fire's residue, the job's delivery channel
502/// as the target.
503fn collect_openclaw(
504 query: &RunsQuery,
505 rows: &mut Vec<(HarnessRun, Value)>,
506 sources: &mut Vec<RunSource>,
507) {
508 use supercode_interchange::orchestration::codec::{from_openclaw, openclaw::encode_fire_row};
509 let state_db = openclaw_state_db(&query.homes);
510 if !state_db.is_file() {
511 sources.push(RunSource::store(
512 HarnessId::OPENCLAW,
513 state_db,
514 "absent_store",
515 None,
516 ));
517 return;
518 }
519 let loaded = match from_openclaw(&query.homes.openclaw) {
520 Ok(loaded) => loaded,
521 Err(error) => {
522 sources.push(RunSource {
523 detail: Some(error.to_string()),
524 ..RunSource::store(HarnessId::OPENCLAW, state_db, "unreadable", None)
525 });
526 return;
527 }
528 };
529 sources.push(RunSource::store(
530 HarnessId::OPENCLAW,
531 state_db,
532 "read",
533 None,
534 ));
535 let text = |fire: &supercode_interchange::orchestration::Fire, key: &str| {
536 fire.residue
537 .0
538 .get(key)
539 .and_then(Value::as_str)
540 .map(str::to_string)
541 };
542 let mut fires: Vec<_> = loaded
543 .orchestration
544 .profiles
545 .values()
546 .flat_map(|profile| profile.fires.iter().map(move |fire| (profile, fire)))
547 .filter(|(_, fire)| query.job.as_deref().is_none_or(|job| job == fire.job_id))
548 .collect();
549 fires.sort_by(|(_, a), (_, b)| {
550 b.finished_at
551 .cmp(&a.finished_at)
552 .then_with(|| b.id.cmp(&a.id))
553 });
554 if let Some(limit) = query.limit {
555 fires.truncate(limit);
556 }
557 for (profile, fire) in fires {
558 let target = profile.jobs.get(&fire.job_id).and_then(|job| {
559 let delivery = job.residue.0.get("__delivery")?.as_object()?;
560 let word = |k: &str| {
561 delivery
562 .get(k)
563 .and_then(Value::as_str)
564 .filter(|v| !v.is_empty())
565 .map(str::to_string)
566 };
567 match (word("channel"), word("to")) {
568 (Some(channel), Some(to)) => Some(format!("{channel}:{to}")),
569 (Some(only), None) | (None, Some(only)) => Some(only),
570 (None, None) => None,
571 }
572 });
573 let delivery = openclaw_delivery(
574 target,
575 text(fire, "delivery_status"),
576 text(fire, "delivery_error"),
577 fire.residue.0.get("delivered").and_then(Value::as_i64),
578 );
579 let store_key = text(fire, "store_key").unwrap_or_default();
580 rows.push((
581 HarnessRun {
582 id: fire.id.clone(),
583 harness: HarnessId::OPENCLAW.into(),
584 job_id: fire.job_id.clone(),
585 status: text(fire, "status").unwrap_or_default(),
586 claimed_at: None,
587 started_at: fire.started_at.clone(),
588 finished_at: fire.finished_at.clone(),
589 error: fire.error.clone().filter(|e| !e.is_empty()),
590 session_id: fire.session_id.clone().filter(|s| !s.is_empty()),
591 delivery,
592 },
593 Value::Object(encode_fire_row(fire, None, &store_key)),
594 ));
595 }
596}
597
598fn openclaw_delivery(
599 target: Option<String>,
600 status: Option<String>,
601 error: Option<String>,
602 delivered: Option<i64>,
603) -> Option<RunDelivery> {
604 let status = status.filter(|status| !status.is_empty());
605 let error = error.filter(|error| !error.is_empty());
606 if status.is_none() && error.is_none() && delivered.is_none() {
607 return None;
608 }
609 Some(RunDelivery {
610 target,
611 state: status.or_else(|| {
612 delivered.map(|delivered| {
613 if delivered == 0 {
614 "not-delivered".to_string()
615 } else {
616 "delivered".to_string()
617 }
618 })
619 }),
620 // OpenClaw's run log counts no delivery attempts, and stamps no
621 // instant on `delivered` — the flag rides the finish record.
622 attempts: None,
623 last_error: error,
624 delivered_at: None,
625 })
626}