1use 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
25const 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
64fn 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
73fn 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
149fn 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
283pub 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}