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    sessions: PathBuf,
276    /// The job store beside this ledger. A fire's delivery SURFACE is
277    /// declared on the job, not on the execution row, so the obligation
278    /// match (ORCH-13) needs it.
279    jobs: PathBuf,
280    profile: Option<String>,
281}
282
283/// Every `cron/executions.db` a Hermes install can hold.
284///
285/// A Hermes profile home IS a full HERMES_HOME (`hermes_constants.get_hermes_home`
286/// resolves the context-local profile override first), so the root home and
287/// every `profiles/<name>/` carry their own ledger AND their own `state.db`.
288/// A profile that has no `state.db` of its own falls back to the root store,
289/// where its rows carry `profile_name = <name>`.
290fn hermes_ledgers(homes: &HarnessHomes) -> Vec<HermesLedger> {
291    // `HarnessHomes::hermes` addresses `state.db`; the cron store is its
292    // sibling under the same HERMES_HOME.
293    let root = homes
294        .hermes
295        .parent()
296        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
297    let mut ledgers = vec![HermesLedger {
298        executions: root.join("cron/executions.db"),
299        sessions: homes.hermes.clone(),
300        jobs: root.join("cron/jobs.json"),
301        profile: None,
302    }];
303    if let Ok(entries) = std::fs::read_dir(root.join("profiles")) {
304        let mut found: Vec<HermesLedger> = entries
305            .flatten()
306            .filter(|entry| entry.path().is_dir())
307            .map(|entry| {
308                let home = entry.path();
309                let own = home.join("state.db");
310                HermesLedger {
311                    executions: home.join("cron/executions.db"),
312                    sessions: if own.is_file() {
313                        own
314                    } else {
315                        homes.hermes.clone()
316                    },
317                    jobs: home.join("cron/jobs.json"),
318                    profile: Some(entry.file_name().to_string_lossy().into_owned()),
319                }
320            })
321            .collect();
322        found.sort_by(|left, right| left.profile.cmp(&right.profile));
323        ledgers.extend(found);
324    }
325    ledgers
326}
327
328/// Every ledger an orchestrator home holds.
329///
330/// Identical in shape to [`hermes_ledgers`] because the folder is: each
331/// profile folder is a complete home with its own `cron/executions.db`,
332/// `cron/jobs.json` and `state.db` (`docs/ORCHESTRATOR-IR.md` §6). Bindings
333/// and obligations live in the profile's own store, so — unlike Hermes's
334/// multiplexed gateway — there is no fallback to a root store.
335fn orchestrator_ledgers(homes: &HarnessHomes) -> Vec<HermesLedger> {
336    crate::orchestrator_profile_dirs(&homes.orchestrator)
337        .into_iter()
338        .map(|(name, dir)| HermesLedger {
339            executions: dir.join("cron/executions.db"),
340            sessions: dir.join("state.db"),
341            jobs: dir.join("cron/jobs.json"),
342            profile: (name != "default").then_some(name),
343        })
344        .collect()
345}
346
347const HERMES_EXECUTION_COLUMNS: &[&str] = &[
348    "id",
349    "job_id",
350    "source",
351    "process_id",
352    "pid",
353    "process_started_at",
354    "status",
355    "claimed_at",
356    "started_at",
357    "finished_at",
358    "error",
359];
360
361fn collect_hermes(
362    query: &RunsQuery,
363    rows: &mut Vec<(HarnessRun, Value)>,
364    sources: &mut Vec<RunSource>,
365) {
366    collect_hermes_shaped(
367        HarnessId::HERMES,
368        hermes_ledgers(&query.homes),
369        query,
370        rows,
371        sources,
372    );
373}
374
375/// Every fire of a set of Hermes-SHAPED ledgers, as the orchestration codec
376/// reads the home: Hermes and the orchestrator keep the same `executions`
377/// table and `delivery_obligations` ledger, so one compile serves both and
378/// each ledger's fires are projected through [`HarnessRun::from_fire`] —
379/// newest-claimed first, the ledger's own order. The fire's session and
380/// delivery are the codec's derived links (`Fire.session_id`,
381/// `Fire.obligation_id`).
382fn collect_hermes_shaped(
383    harness: &str,
384    ledgers: Vec<HermesLedger>,
385    query: &RunsQuery,
386    rows: &mut Vec<(HarnessRun, Value)>,
387    sources: &mut Vec<RunSource>,
388) {
389    use supercode_interchange::orchestration::codec::{from_hermes, load_home, Flavor};
390    let loaded = match harness {
391        HarnessId::HERMES => {
392            let home = query
393                .homes
394                .hermes
395                .parent()
396                .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
397            from_hermes(&home)
398        }
399        _ => load_home(&query.homes.orchestrator, Flavor::Orchestrator),
400    };
401    let loaded = match loaded {
402        Ok(loaded) => loaded,
403        Err(error) => {
404            for ledger in ledgers {
405                let state = if ledger.executions.is_file() {
406                    "unreadable"
407                } else {
408                    "absent_store"
409                };
410                sources.push(RunSource {
411                    detail: (state == "unreadable").then(|| error.to_string()),
412                    ..RunSource::store(harness, ledger.executions, state, ledger.profile)
413                });
414            }
415            return;
416        }
417    };
418    for ledger in ledgers {
419        if !ledger.executions.is_file() {
420            sources.push(RunSource::store(
421                harness,
422                ledger.executions,
423                "absent_store",
424                ledger.profile,
425            ));
426            continue;
427        }
428        let name = ledger.profile.clone().unwrap_or_else(|| "default".into());
429        let Some(profile) = loaded.orchestration.profiles.get(&name) else {
430            sources.push(RunSource::store(
431                harness,
432                ledger.executions,
433                "absent_store",
434                ledger.profile,
435            ));
436            continue;
437        };
438        sources.push(RunSource::store(
439            harness,
440            ledger.executions,
441            "read",
442            ledger.profile,
443        ));
444        let mut fires: Vec<_> = profile
445            .fires
446            .iter()
447            .filter(|fire| query.job.as_deref().is_none_or(|job| job == fire.job_id))
448            .collect();
449        fires.sort_by(|a, b| {
450            b.claimed_at
451                .cmp(&a.claimed_at)
452                .then_with(|| b.id.cmp(&a.id))
453        });
454        if let Some(limit) = query.limit {
455            fires.truncate(limit);
456        }
457        for fire in fires {
458            let delivery = fire
459                .obligation_id
460                .as_deref()
461                .and_then(|id| obligation_delivery(&loaded, id));
462            let native: Map<String, Value> = HERMES_EXECUTION_COLUMNS
463                .iter()
464                .map(|c| (*c).to_string())
465                .zip(supercode_interchange::orchestration::codec::decode::encode_fire_row(fire))
466                .collect();
467            rows.push((
468                HarnessRun::from_fire(harness, fire, None, delivery),
469                Value::Object(native),
470            ));
471        }
472    }
473}
474
475/// The delivery an obligation records, as the run shows it.
476fn obligation_delivery(
477    loaded: &supercode_interchange::orchestration::codec::LoadedHome,
478    id: &str,
479) -> Option<RunDelivery> {
480    let obligation = loaded
481        .orchestration
482        .profiles
483        .values()
484        .flat_map(|p| p.obligations.iter())
485        .find(|o| o.id == id)?;
486    let platform = obligation.target.platform.clone().unwrap_or_default();
487    let chat_id = obligation.target.chat_id.clone().unwrap_or_default();
488    Some(RunDelivery {
489        target: Some(
490            match obligation
491                .target
492                .thread_id
493                .as_deref()
494                .filter(|t| !t.is_empty())
495            {
496                Some(thread) => format!("{platform}:{chat_id}:{thread}"),
497                None => format!("{platform}:{chat_id}"),
498            },
499        ),
500        state: Some(obligation.state.hermes_word().to_string()),
501        attempts: Some(obligation.attempts),
502        last_error: obligation.last_error.clone().filter(|e| !e.is_empty()),
503        delivered_at: obligation
504            .delivered_at
505            .as_deref()
506            .and_then(|at| at.parse::<f64>().ok())
507            .map(|seconds| crate::sidecar::ms_to_rfc3339((seconds * 1000.0) as i64)),
508    })
509}
510
511fn openclaw_state_db(homes: &HarnessHomes) -> PathBuf {
512    homes.openclaw.join("state/openclaw.sqlite")
513}
514
515/// OpenClaw's run logs as the orchestration codec reads the store: every
516/// profile's fires, newest first (`ts`, then id), the store's own status word
517/// and delivery columns from the fire's residue, the job's delivery channel
518/// as the target.
519fn collect_openclaw(
520    query: &RunsQuery,
521    rows: &mut Vec<(HarnessRun, Value)>,
522    sources: &mut Vec<RunSource>,
523) {
524    use supercode_interchange::orchestration::codec::{from_openclaw, openclaw::encode_fire_row};
525    let state_db = openclaw_state_db(&query.homes);
526    if !state_db.is_file() {
527        sources.push(RunSource::store(
528            HarnessId::OPENCLAW,
529            state_db,
530            "absent_store",
531            None,
532        ));
533        return;
534    }
535    let loaded = match from_openclaw(&query.homes.openclaw) {
536        Ok(loaded) => loaded,
537        Err(error) => {
538            sources.push(RunSource {
539                detail: Some(error.to_string()),
540                ..RunSource::store(HarnessId::OPENCLAW, state_db, "unreadable", None)
541            });
542            return;
543        }
544    };
545    sources.push(RunSource::store(
546        HarnessId::OPENCLAW,
547        state_db,
548        "read",
549        None,
550    ));
551    let text = |fire: &supercode_interchange::orchestration::Fire, key: &str| {
552        fire.residue
553            .0
554            .get(key)
555            .and_then(Value::as_str)
556            .map(str::to_string)
557    };
558    let mut fires: Vec<_> = loaded
559        .orchestration
560        .profiles
561        .values()
562        .flat_map(|profile| profile.fires.iter().map(move |fire| (profile, fire)))
563        .filter(|(_, fire)| query.job.as_deref().is_none_or(|job| job == fire.job_id))
564        .collect();
565    fires.sort_by(|(_, a), (_, b)| {
566        b.finished_at
567            .cmp(&a.finished_at)
568            .then_with(|| b.id.cmp(&a.id))
569    });
570    if let Some(limit) = query.limit {
571        fires.truncate(limit);
572    }
573    for (profile, fire) in fires {
574        let target = profile.jobs.get(&fire.job_id).and_then(|job| {
575            let delivery = job.residue.0.get("__delivery")?.as_object()?;
576            let word = |k: &str| {
577                delivery
578                    .get(k)
579                    .and_then(Value::as_str)
580                    .filter(|v| !v.is_empty())
581                    .map(str::to_string)
582            };
583            match (word("channel"), word("to")) {
584                (Some(channel), Some(to)) => Some(format!("{channel}:{to}")),
585                (Some(only), None) | (None, Some(only)) => Some(only),
586                (None, None) => None,
587            }
588        });
589        let delivery = openclaw_delivery(
590            target,
591            text(fire, "delivery_status"),
592            text(fire, "delivery_error"),
593            fire.residue.0.get("delivered").and_then(Value::as_i64),
594        );
595        let store_key = text(fire, "store_key").unwrap_or_default();
596        rows.push((
597            HarnessRun {
598                id: fire.id.clone(),
599                harness: HarnessId::OPENCLAW.into(),
600                job_id: fire.job_id.clone(),
601                status: text(fire, "status").unwrap_or_default(),
602                claimed_at: None,
603                started_at: fire.started_at.clone(),
604                finished_at: fire.finished_at.clone(),
605                error: fire.error.clone().filter(|e| !e.is_empty()),
606                session_id: fire.session_id.clone().filter(|s| !s.is_empty()),
607                delivery,
608            },
609            Value::Object(encode_fire_row(fire, None, &store_key)),
610        ));
611    }
612}
613
614fn openclaw_delivery(
615    target: Option<String>,
616    status: Option<String>,
617    error: Option<String>,
618    delivered: Option<i64>,
619) -> Option<RunDelivery> {
620    let status = status.filter(|status| !status.is_empty());
621    let error = error.filter(|error| !error.is_empty());
622    if status.is_none() && error.is_none() && delivered.is_none() {
623        return None;
624    }
625    Some(RunDelivery {
626        target,
627        state: status.or_else(|| {
628            delivered.map(|delivered| {
629                if delivered == 0 {
630                    "not-delivered".to_string()
631                } else {
632                    "delivered".to_string()
633                }
634            })
635        }),
636        // OpenClaw's run log counts no delivery attempts, and stamps no
637        // instant on `delivered` — the flag rides the finish record.
638        attempts: None,
639        last_error: error,
640        delivered_at: None,
641    })
642}