1use 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
23const 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
62fn 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
71fn 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
245pub 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}