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