Skip to main content

supercode_interchange/workflow/codec/
hermes.rs

1//! Hermes's board: `kanban.db` at the home's root is the default board and `kanban/boards/<slug>/kanban.db`
2//! the named ones (`hermes_cli/kanban_db.py`, `kanban_db_path`). Tables read: `tasks`, `task_links`,
3//! `task_comments`, `task_events`, `task_runs`. Epoch seconds become RFC3339; a `tasks` column
4//! the record does not model is kept in the task's residue. Reviews are the review events on
5//! the thread (`review_requested`, `changes_requested`, `escalated`, `approved`). What supercode's
6//! dispatcher writes beside Hermes's columns is read too: a card's machine and arc (`supercode_cards`) and
7//! the session an attempt ran in (the run's `metadata.supercode`).
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::Path;
11
12use serde_json::{Map, Value};
13
14use super::super::{
15    Attempt, AttemptSession, Board, Comment, Dependency, Handoff, Lane, Review, Task, Verdict,
16    Workflow, Workspace, WorkspaceKind,
17};
18use crate::error::Result;
19use crate::ontology::Residue;
20use crate::orchestration::codec::sqlite::{read_rows, table_exists};
21use crate::sidecar::ms_to_rfc3339;
22
23type Row = Map<String, Value>;
24
25/// The columns of `tasks` the record models; the rest go to residue.
26const TASK_COLUMNS: &[&str] = &[
27    "id",
28    "title",
29    "body",
30    "assignee",
31    "status",
32    "priority",
33    "created_by",
34    "created_at",
35    "started_at",
36    "completed_at",
37    "workspace_kind",
38    "workspace_path",
39    "branch_name",
40    "tenant",
41    "idempotency_key",
42    "result",
43    "skills",
44    "model_override",
45    "provider_override",
46];
47
48fn text(row: &Row, key: &str) -> Option<String> {
49    match row.get(key) {
50        Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
51        Some(Value::Number(n)) => Some(n.to_string()),
52        _ => None,
53    }
54}
55
56fn int(row: &Row, key: &str) -> Option<i64> {
57    match row.get(key) {
58        Some(Value::Number(n)) => n.as_i64(),
59        Some(Value::String(s)) => s.parse().ok(),
60        _ => None,
61    }
62}
63
64/// Epoch seconds → RFC3339.
65fn at(row: &Row, key: &str) -> Option<String> {
66    int(row, key).map(|secs| ms_to_rfc3339(secs.saturating_mul(1000)))
67}
68
69fn json_text(row: &Row, key: &str) -> Option<Value> {
70    text(row, key).and_then(|s| serde_json::from_str(&s).ok())
71}
72
73/// `skills` is a JSON array in the store; a bare comma list is accepted too.
74fn skills(row: &Row) -> Vec<String> {
75    match json_text(row, "skills") {
76        Some(Value::Array(items)) => items
77            .into_iter()
78            .filter_map(|v| v.as_str().map(str::to_string))
79            .collect(),
80        _ => text(row, "skills")
81            .map(|s| {
82                s.split(',')
83                    .map(|p| p.trim().to_string())
84                    .filter(|p| !p.is_empty())
85                    .collect()
86            })
87            .unwrap_or_default(),
88    }
89}
90
91fn workspace(row: &Row) -> Workspace {
92    let kind = match text(row, "workspace_kind").as_deref() {
93        Some("scratch") | None => WorkspaceKind::Scratch,
94        Some("dir") => WorkspaceKind::Dir,
95        Some("worktree") => WorkspaceKind::Worktree,
96        Some(_) => WorkspaceKind::Unknown,
97    };
98    Workspace {
99        kind,
100        path: text(row, "workspace_path"),
101        branch: text(row, "branch_name"),
102    }
103}
104
105fn task(row: &Row) -> Option<Task> {
106    let id = text(row, "id")?;
107    let status = text(row, "status").unwrap_or_default();
108    let lane = Lane::parse(&status);
109    let mut residue = Residue::default();
110    for (key, value) in row {
111        if !TASK_COLUMNS.contains(&key.as_str()) && !value.is_null() {
112            residue.keep(key.clone(), value.clone());
113        }
114    }
115    if lane == Lane::Unknown {
116        residue.keep("status", Value::String(status));
117    }
118    let ws = workspace(row);
119    if ws.kind == WorkspaceKind::Unknown {
120        residue.keep("workspace_kind", row["workspace_kind"].clone());
121    }
122    Some(Task {
123        id,
124        title: text(row, "title").unwrap_or_default(),
125        body: text(row, "body"),
126        assignee: text(row, "assignee"),
127        machine: None,
128        subtask_of: None,
129        lane,
130        priority: int(row, "priority").unwrap_or(0),
131        tenant: text(row, "tenant"),
132        idempotency_key: text(row, "idempotency_key"),
133        workspace: ws,
134        skills: skills(row),
135        model: text(row, "model_override"),
136        provider: text(row, "provider_override"),
137        created_by: text(row, "created_by"),
138        created_at: at(row, "created_at"),
139        started_at: at(row, "started_at"),
140        completed_at: at(row, "completed_at"),
141        result: text(row, "result"),
142        attempts: Vec::new(),
143        reviews: Vec::new(),
144        comments: Vec::new(),
145        residue,
146    })
147}
148
149/// `metadata.supercode`: the session supercode's dispatcher launched for the run.
150fn attempt_session(value: &Value) -> AttemptSession {
151    let field = |k: &str| value.get(k).and_then(Value::as_str).map(str::to_string);
152    AttemptSession {
153        surface: field("surface"),
154        id: field("session_id"),
155        harness: field("harness"),
156        machine: field("machine"),
157        pane: field("pane"),
158        address: field("address"),
159        mode: field("mode"),
160        pid: value.get("pid").and_then(Value::as_i64),
161    }
162}
163
164fn attempt(row: &Row) -> Option<Attempt> {
165    let summary = text(row, "summary");
166    let mut metadata = json_text(row, "metadata");
167    let session = metadata
168        .as_mut()
169        .and_then(Value::as_object_mut)
170        .and_then(|m| m.remove("supercode"))
171        .map(|v| attempt_session(&v));
172    if metadata
173        .as_ref()
174        .and_then(Value::as_object)
175        .is_some_and(|m| m.is_empty())
176    {
177        metadata = None;
178    }
179    Some(Attempt {
180        id: text(row, "id")?,
181        profile: text(row, "profile"),
182        step: text(row, "step_key"),
183        status: text(row, "status").unwrap_or_default(),
184        started_at: at(row, "started_at"),
185        ended_at: at(row, "ended_at"),
186        outcome: text(row, "outcome"),
187        handoff: (summary.is_some() || metadata.is_some()).then_some(Handoff { summary, metadata }),
188        error: text(row, "error"),
189        session,
190    })
191}
192
193fn review(row: &Row) -> Option<Review> {
194    let verdict = match text(row, "kind")?.as_str() {
195        "review_requested" => Verdict::Requested,
196        "approved" | "review_approved" => Verdict::Approved,
197        "changes_requested" => Verdict::ChangesRequested,
198        "escalated" => Verdict::Escalated,
199        _ => return None,
200    };
201    let payload = json_text(row, "payload").unwrap_or(Value::Null);
202    let field = |k: &str| payload.get(k).and_then(Value::as_str).map(str::to_string);
203    Some(Review {
204        verdict,
205        by: field("reviewer")
206            .or_else(|| field("by"))
207            .or_else(|| field("profile")),
208        reason: field("reason"),
209        at: at(row, "created_at"),
210    })
211}
212
213fn rows(db: &Path, table: &str, sql: &str) -> Result<Vec<Row>> {
214    if !table_exists(db, table) {
215        return Ok(Vec::new());
216    }
217    Ok(read_rows(db, sql, &[])?.unwrap_or_default())
218}
219
220fn read_board(slug: &str, dir: &Path) -> Result<Board> {
221    let db = dir.join("kanban.db");
222    let mut tasks: BTreeMap<String, Task> = rows(&db, "tasks", "SELECT * FROM tasks")?
223        .iter()
224        .filter_map(task)
225        .map(|t| (t.id.clone(), t))
226        .collect();
227    for row in rows(&db, "supercode_cards", "SELECT * FROM supercode_cards")? {
228        if let Some(t) = text(&row, "task_id").and_then(|id| tasks.get_mut(&id)) {
229            t.machine = text(&row, "machine");
230            t.subtask_of = text(&row, "subtask_of");
231        }
232    }
233    for row in rows(&db, "task_runs", "SELECT * FROM task_runs ORDER BY id")? {
234        if let (Some(task_id), Some(a)) = (text(&row, "task_id"), attempt(&row)) {
235            if let Some(t) = tasks.get_mut(&task_id) {
236                t.attempts.push(a);
237            }
238        }
239    }
240    let events = "SELECT task_id, kind, payload, created_at FROM task_events ORDER BY id";
241    for row in rows(&db, "task_events", events)? {
242        if let (Some(task_id), Some(r)) = (text(&row, "task_id"), review(&row)) {
243            if let Some(t) = tasks.get_mut(&task_id) {
244                t.reviews.push(r);
245            }
246        }
247    }
248    let comments = "SELECT task_id, author, body, created_at FROM task_comments ORDER BY id";
249    for row in rows(&db, "task_comments", comments)? {
250        if let (Some(task_id), Some(author), Some(body)) = (
251            text(&row, "task_id"),
252            text(&row, "author"),
253            text(&row, "body"),
254        ) {
255            if let Some(t) = tasks.get_mut(&task_id) {
256                t.comments.push(Comment {
257                    author,
258                    body,
259                    at: at(&row, "created_at"),
260                });
261            }
262        }
263    }
264    let links = "SELECT parent_id, child_id FROM task_links ORDER BY parent_id, child_id";
265    let dependencies = rows(&db, "task_links", links)?
266        .iter()
267        .filter_map(|row| {
268            Some(Dependency {
269                parent: text(row, "parent_id")?,
270                child: text(row, "child_id")?,
271            })
272        })
273        .collect();
274    Ok(Board {
275        slug: slug.to_string(),
276        name: None,
277        root: dir.to_path_buf(),
278        tasks,
279        dependencies,
280    })
281}
282
283/// Read a Hermes home's boards. A home with no board answers an empty workflow.
284pub fn from_hermes(home: &Path) -> Result<Workflow> {
285    let mut boards = BTreeMap::new();
286    if home.join("kanban.db").is_file() {
287        boards.insert("default".to_string(), read_board("default", home)?);
288    }
289    if let Ok(entries) = fs::read_dir(home.join("kanban").join("boards")) {
290        let mut dirs: Vec<_> = entries.flatten().map(|e| e.path()).collect();
291        dirs.sort();
292        for dir in dirs {
293            let slug = dir
294                .file_name()
295                .and_then(|n| n.to_str())
296                .unwrap_or_default()
297                .to_string();
298            if slug.is_empty() || slug.starts_with('_') || !dir.join("kanban.db").is_file() {
299                continue;
300            }
301            boards.insert(slug.clone(), read_board(&slug, &dir)?);
302        }
303    }
304    Ok(Workflow {
305        root: home.to_path_buf(),
306        boards,
307    })
308}