Skip to main content

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}