Skip to main content

supercode_interchange/workflow/codec/
hermes.rs

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