Skip to main content

libbrat_grite/
client.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use serde::de::DeserializeOwned;
5use serde::Deserialize;
6
7use crate::error::GriteError;
8use crate::id::{
9    generate_convoy_id, generate_session_id, generate_task_id, is_valid_convoy_id,
10    is_valid_session_id, is_valid_task_id,
11};
12use crate::state_machine::StateMachine;
13use crate::types::{
14    ContextIndexResult, Convoy, ConvoyStatus, DependencyType, FileContext, GriteIssue,
15    GriteIssueSummary, ProjectContextEntry, Session, SessionRole, SessionStatus, SessionType,
16    Symbol, SymbolMatch, Task, TaskDependency, TaskStatus,
17};
18
19/// Expected Grite CLI JSON schema version.
20const EXPECTED_GRIT_SCHEMA_VERSION: u32 = 1;
21
22/// JSON envelope from Gritee CLI responses (used by lock commands).
23#[derive(Debug, Deserialize)]
24struct JsonResponse<T> {
25    #[serde(default)]
26    schema_version: Option<u32>,
27    #[serde(default)]
28    #[allow(dead_code)] // Used by Grit but not checked in our code
29    ok: bool,
30    data: Option<T>,
31    error: Option<JsonError>,
32}
33
34#[derive(Debug, Deserialize)]
35struct JsonError {
36    #[serde(default)]
37    code: String,
38    message: String,
39}
40
41/// Convert a byte array to a hex string.
42fn bytes_to_hex(bytes: &[u8]) -> String {
43    bytes.iter().map(|b| format!("{:02x}", b)).collect()
44}
45
46/// Issue ID that can be either a hex string or a byte array.
47#[derive(Debug, Deserialize)]
48#[serde(untagged)]
49enum IssueIdFormat {
50    Hex(String),
51    Bytes(Vec<u8>),
52}
53
54impl IssueIdFormat {
55    fn to_hex(&self) -> String {
56        match self {
57            IssueIdFormat::Hex(s) => s.clone(),
58            IssueIdFormat::Bytes(bytes) => bytes_to_hex(bytes),
59        }
60    }
61}
62
63/// Response from issue create command (new format).
64#[derive(Debug, Deserialize)]
65struct IssueCreateResponse {
66    issue_id: IssueIdFormat,
67    #[allow(dead_code)]
68    event_id: Option<String>,
69}
70
71/// Raw issue summary from grite issue list (new format).
72#[derive(Debug, Deserialize)]
73struct RawIssueSummary {
74    issue_id: IssueIdFormat,
75    title: String,
76    state: String,
77    labels: Vec<String>,
78    #[serde(default)]
79    assignees: Vec<String>,
80    #[serde(default)]
81    updated_ts: i64,
82    #[serde(default)]
83    comment_count: u32,
84}
85
86impl RawIssueSummary {
87    fn into_grite_issue_summary(self) -> GriteIssueSummary {
88        GriteIssueSummary {
89            issue_id: self.issue_id.to_hex(),
90            title: self.title,
91            state: self.state,
92            labels: self.labels,
93            updated_ts: self.updated_ts,
94        }
95    }
96}
97
98/// Response from issue list command (new format - no envelope).
99#[derive(Debug, Deserialize)]
100struct IssueListResponse {
101    issues: Vec<RawIssueSummary>,
102}
103
104/// Response wrapper for issue show command.
105#[derive(Debug, Deserialize)]
106struct IssueShowResponse {
107    issue: RawIssue,
108    #[allow(dead_code)]
109    events: Option<Vec<serde_json::Value>>,
110}
111
112/// Raw issue from grite issue show (new format).
113#[derive(Debug, Deserialize)]
114struct RawIssue {
115    issue_id: IssueIdFormat,
116    title: String,
117    #[serde(default)]
118    body: String,
119    state: String,
120    labels: Vec<String>,
121    #[serde(default)]
122    assignees: Vec<String>,
123    #[serde(default)]
124    comments: Vec<RawComment>,
125    #[serde(default)]
126    updated_ts: i64,
127    #[serde(default)]
128    comment_count: u32,
129}
130
131/// Raw comment from grite issue show.
132#[derive(Debug, Deserialize)]
133struct RawComment {
134    #[allow(dead_code)]
135    comment_id: Option<IssueIdFormat>,
136    body: String,
137    #[allow(dead_code)]
138    author: Option<String>,
139    #[allow(dead_code)]
140    created_ts: Option<i64>,
141}
142
143impl RawIssue {
144    fn into_grite_issue(self) -> GriteIssue {
145        GriteIssue {
146            issue_id: self.issue_id.to_hex(),
147            title: self.title,
148            body: self.body,
149            state: self.state,
150            labels: self.labels,
151            updated_ts: self.updated_ts,
152        }
153    }
154}
155
156/// Response from lock acquire command.
157#[derive(Debug, Deserialize)]
158struct LockAcquireResponse {
159    resource: String,
160    owner: String,
161    #[serde(default)]
162    nonce: Option<String>,
163    #[serde(default)]
164    expires_unix_ms: Option<i64>,
165    #[serde(default)]
166    ttl_seconds: Option<i64>,
167}
168
169/// Result of a lock acquisition attempt.
170#[derive(Debug, Clone)]
171pub struct LockResult {
172    /// Whether the lock was successfully acquired.
173    pub acquired: bool,
174    /// The resource that was locked.
175    pub resource: String,
176    /// The current holder of the lock (if not acquired).
177    pub holder: Option<String>,
178    /// When the lock expires (Unix timestamp in ms).
179    pub expires_unix_ms: Option<i64>,
180}
181
182/// Response from grite dep list command.
183#[derive(Debug, Deserialize)]
184struct DepListResponse {
185    #[allow(dead_code)]
186    issue_id: String,
187    #[allow(dead_code)]
188    direction: String,
189    deps: Vec<DepListEntry>,
190}
191
192/// Entry in dependency list response.
193#[derive(Debug, Deserialize)]
194struct DepListEntry {
195    issue_id: String,
196    dep_type: String,
197    title: String,
198}
199
200/// Response from grite dep topo command.
201#[derive(Debug, Deserialize)]
202struct DepTopoResponse {
203    issues: Vec<RawIssueSummary>,
204    #[allow(dead_code)]
205    order: String,
206}
207
208/// Response from grite context index command.
209#[derive(Debug, Deserialize)]
210struct ContextIndexResponse {
211    indexed: u32,
212    skipped: u32,
213    total_files: u32,
214}
215
216/// Response from grite context query command.
217#[derive(Debug, Deserialize)]
218struct ContextQueryResponse {
219    #[allow(dead_code)]
220    query: String,
221    matches: Vec<ContextQueryMatch>,
222    #[allow(dead_code)]
223    count: u32,
224}
225
226/// Match entry from context query.
227#[derive(Debug, Deserialize)]
228struct ContextQueryMatch {
229    symbol: String,
230    path: String,
231}
232
233/// Response from grite context show command.
234#[derive(Debug, Deserialize)]
235struct ContextShowResponse {
236    path: String,
237    language: String,
238    summary: String,
239    content_hash: String,
240    symbols: Vec<ContextSymbol>,
241    #[allow(dead_code)]
242    symbol_count: u32,
243}
244
245/// Symbol entry from context show.
246#[derive(Debug, Deserialize)]
247struct ContextSymbol {
248    name: String,
249    kind: String,
250    line_start: u32,
251    line_end: u32,
252}
253
254/// Response from grite context project (single key).
255#[derive(Debug, Deserialize)]
256struct ContextProjectSingleResponse {
257    key: String,
258    value: String,
259}
260
261/// Response from grite context project (list).
262#[derive(Debug, Deserialize)]
263struct ContextProjectListResponse {
264    entries: Vec<ContextProjectEntry>,
265    #[allow(dead_code)]
266    count: u32,
267}
268
269/// Entry in project context list.
270#[derive(Debug, Deserialize)]
271struct ContextProjectEntry {
272    key: String,
273    value: String,
274}
275
276/// Client for interacting with the Grite CLI.
277#[derive(Clone)]
278pub struct GriteClient {
279    repo_root: PathBuf,
280}
281
282impl GriteClient {
283    /// Create a new GriteClient for the given repository root.
284    pub fn new(repo_root: impl Into<PathBuf>) -> Self {
285        Self {
286            repo_root: repo_root.into(),
287        }
288    }
289
290    /// Check if Grit is initialized in the repository.
291    pub fn is_initialized(&self, git_dir: &Path) -> bool {
292        git_dir.join("grite").exists()
293    }
294
295    /// Get the repository root path.
296    pub fn repo_root(&self) -> &Path {
297        &self.repo_root
298    }
299
300    // -------------------------------------------------------------------------
301    // Low-level issue operations
302    // -------------------------------------------------------------------------
303
304    /// Create a new issue with the given title, body, and labels.
305    pub fn issue_create(
306        &self,
307        title: &str,
308        body: &str,
309        labels: &[String],
310    ) -> Result<String, GriteError> {
311        let mut args = vec!["issue", "create", "--title", title, "--body", body];
312        for label in labels {
313            args.push("--label");
314            args.push(label);
315        }
316
317        let response: IssueCreateResponse = self.run_json_direct(&args)?;
318        Ok(response.issue_id.to_hex())
319    }
320
321    /// List issues with optional label filters.
322    pub fn issue_list(
323        &self,
324        labels: &[&str],
325        state: Option<&str>,
326    ) -> Result<Vec<GriteIssueSummary>, GriteError> {
327        let mut args = vec!["issue", "list"];
328
329        if let Some(state) = state {
330            args.push("--state");
331            args.push(state);
332        }
333
334        for label in labels {
335            args.push("--label");
336            args.push(label);
337        }
338
339        let response: IssueListResponse = self.run_json_direct(&args)?;
340        Ok(response
341            .issues
342            .into_iter()
343            .map(|r| r.into_grite_issue_summary())
344            .collect())
345    }
346
347    /// Get a single issue by ID.
348    pub fn issue_show(&self, issue_id: &str) -> Result<GriteIssue, GriteError> {
349        let args = vec!["issue", "show", issue_id];
350        let response: IssueShowResponse = self.run_json_direct(&args)?;
351        Ok(response.issue.into_grite_issue())
352    }
353
354    /// Add labels to an issue.
355    pub fn issue_label_add(&self, issue_id: &str, labels: &[&str]) -> Result<(), GriteError> {
356        for label in labels {
357            let args = vec!["issue", "label", "add", "--label", label, issue_id];
358            let _: serde_json::Value = self.run_json_direct(&args)?;
359        }
360        Ok(())
361    }
362
363    /// Remove labels from an issue.
364    pub fn issue_label_remove(&self, issue_id: &str, labels: &[&str]) -> Result<(), GriteError> {
365        for label in labels {
366            let args = vec!["issue", "label", "remove", "--label", label, issue_id];
367            // Ignore errors for labels that don't exist
368            let _ = self.run_json_direct::<serde_json::Value>(&args);
369        }
370        Ok(())
371    }
372
373    /// Add a comment to an issue.
374    pub fn issue_comment(&self, issue_id: &str, body: &str) -> Result<(), GriteError> {
375        let args = vec!["issue", "comment", issue_id, "--body", body];
376        let _: serde_json::Value = self.run_json_direct(&args)?;
377        Ok(())
378    }
379
380    // -------------------------------------------------------------------------
381    // Convoy operations
382    // -------------------------------------------------------------------------
383
384    /// Create a new convoy.
385    pub fn convoy_create(&self, title: &str, body: Option<&str>) -> Result<Convoy, GriteError> {
386        let convoy_id = generate_convoy_id();
387        let labels = vec![
388            "type:convoy".to_string(),
389            format!("convoy:{}", convoy_id),
390            ConvoyStatus::Active.as_label().to_string(),
391        ];
392
393        let grite_issue_id = self.issue_create(title, body.unwrap_or(""), &labels)?;
394
395        Ok(Convoy {
396            convoy_id,
397            grite_issue_id,
398            title: title.to_string(),
399            body: body.unwrap_or("").to_string(),
400            status: ConvoyStatus::Active,
401        })
402    }
403
404    /// List all convoys.
405    pub fn convoy_list(&self) -> Result<Vec<Convoy>, GriteError> {
406        let issues = self.issue_list(&["type:convoy"], Some("open"))?;
407        issues
408            .into_iter()
409            .filter_map(|issue| parse_convoy_from_summary(&issue).ok())
410            .collect::<Vec<_>>()
411            .into_iter()
412            .map(Ok)
413            .collect()
414    }
415
416    /// Get a convoy by its Brat convoy ID.
417    pub fn convoy_get(&self, convoy_id: &str) -> Result<Convoy, GriteError> {
418        if !is_valid_convoy_id(convoy_id) {
419            return Err(GriteError::InvalidId(convoy_id.to_string()));
420        }
421
422        let label = format!("convoy:{}", convoy_id);
423        let issues = self.issue_list(&[&label], None)?;
424
425        issues
426            .into_iter()
427            .find(|issue| issue.labels.contains(&"type:convoy".to_string()))
428            .map(|issue| parse_convoy_from_summary(&issue))
429            .transpose()?
430            .ok_or_else(|| GriteError::NotFound(format!("convoy {}", convoy_id)))
431    }
432
433    // -------------------------------------------------------------------------
434    // Task operations
435    // -------------------------------------------------------------------------
436
437    /// Create a new task linked to a convoy.
438    pub fn task_create(
439        &self,
440        convoy_id: &str,
441        title: &str,
442        body: Option<&str>,
443    ) -> Result<Task, GriteError> {
444        if !is_valid_convoy_id(convoy_id) {
445            return Err(GriteError::InvalidId(convoy_id.to_string()));
446        }
447
448        // Verify convoy exists
449        let _ = self.convoy_get(convoy_id)?;
450
451        let task_id = generate_task_id();
452        let labels = vec![
453            "type:task".to_string(),
454            format!("task:{}", task_id),
455            format!("convoy:{}", convoy_id),
456            TaskStatus::Queued.as_label().to_string(),
457        ];
458
459        let grite_issue_id = self.issue_create(title, body.unwrap_or(""), &labels)?;
460
461        Ok(Task {
462            task_id,
463            grite_issue_id,
464            convoy_id: convoy_id.to_string(),
465            title: title.to_string(),
466            body: body.unwrap_or("").to_string(),
467            status: TaskStatus::Queued,
468        })
469    }
470
471    /// List tasks, optionally filtered by convoy.
472    pub fn task_list(&self, convoy_id: Option<&str>) -> Result<Vec<Task>, GriteError> {
473        let mut labels: Vec<&str> = vec!["type:task"];
474
475        let convoy_label;
476        if let Some(cid) = convoy_id {
477            if !is_valid_convoy_id(cid) {
478                return Err(GriteError::InvalidId(cid.to_string()));
479            }
480            convoy_label = format!("convoy:{}", cid);
481            labels.push(&convoy_label);
482        }
483
484        let issues = self.issue_list(&labels, Some("open"))?;
485        issues
486            .into_iter()
487            .filter_map(|issue| parse_task_from_summary(&issue).ok())
488            .collect::<Vec<_>>()
489            .into_iter()
490            .map(Ok)
491            .collect()
492    }
493
494    /// Get a task by its Brat task ID.
495    ///
496    /// Unlike `task_list`, this returns the full task including body.
497    pub fn task_get(&self, task_id: &str) -> Result<Task, GriteError> {
498        if !is_valid_task_id(task_id) {
499            return Err(GriteError::InvalidId(task_id.to_string()));
500        }
501
502        let label = format!("task:{}", task_id);
503        let issues = self.issue_list(&[&label], None)?;
504
505        // Find the task issue
506        let summary = issues
507            .into_iter()
508            .find(|issue| issue.labels.contains(&"type:task".to_string()))
509            .ok_or_else(|| GriteError::NotFound(format!("task {}", task_id)))?;
510
511        // Fetch full issue to get body
512        let full_issue = self.issue_show(&summary.issue_id)?;
513
514        parse_task_from_full_issue(&summary, &full_issue)
515    }
516
517    /// Update task status with validation.
518    ///
519    /// This validates the state transition before updating. Use
520    /// `task_update_status_with_options` with `force=true` to bypass validation.
521    pub fn task_update_status(
522        &self,
523        task_id: &str,
524        new_status: TaskStatus,
525    ) -> Result<(), GriteError> {
526        self.task_update_status_with_options(task_id, new_status, false)
527    }
528
529    /// Update task status with options.
530    ///
531    /// If `force` is true, bypasses state machine validation (admin override).
532    pub fn task_update_status_with_options(
533        &self,
534        task_id: &str,
535        new_status: TaskStatus,
536        force: bool,
537    ) -> Result<(), GriteError> {
538        let task = self.task_get(task_id)?;
539
540        // Validate state transition
541        let state_machine = StateMachine::<TaskStatus>::new();
542        state_machine
543            .validate(task.status, new_status, force)
544            .map_err(|e| GriteError::InvalidStateTransition(e.to_string()))?;
545
546        // Remove old status labels
547        let old_labels: Vec<&str> = TaskStatus::all_labels().to_vec();
548        self.issue_label_remove(&task.grite_issue_id, &old_labels)?;
549
550        // Add new status label
551        self.issue_label_add(&task.grite_issue_id, &[new_status.as_label()])?;
552
553        Ok(())
554    }
555
556    // -------------------------------------------------------------------------
557    // Session operations
558    // -------------------------------------------------------------------------
559
560    /// Create a new session for a task.
561    ///
562    /// This posts a session comment to the task issue and updates labels.
563    /// Sessions are stored as comments on task issues, not as separate issues.
564    pub fn session_create(
565        &self,
566        task_id: &str,
567        role: SessionRole,
568        session_type: SessionType,
569        engine: &str,
570        worktree: &str,
571        pid: Option<u32>,
572    ) -> Result<Session, GriteError> {
573        self.session_create_with_id(None, task_id, role, session_type, engine, worktree, pid)
574    }
575
576    /// Create a new session with an optional pre-generated ID.
577    ///
578    /// This is useful when the session ID needs to be known before creation,
579    /// such as when creating a worktree that uses the session ID as its name.
580    ///
581    /// If `session_id` is None, a new ID will be generated.
582    pub fn session_create_with_id(
583        &self,
584        session_id: Option<&str>,
585        task_id: &str,
586        role: SessionRole,
587        session_type: SessionType,
588        engine: &str,
589        worktree: &str,
590        pid: Option<u32>,
591    ) -> Result<Session, GriteError> {
592        // Validate task exists and get grite_issue_id
593        let task = self.task_get(task_id)?;
594
595        // Generate session ID if not provided
596        let session_id = session_id
597            .map(|s| s.to_string())
598            .unwrap_or_else(generate_session_id);
599        let started_ts = std::time::SystemTime::now()
600            .duration_since(std::time::UNIX_EPOCH)
601            .map(|d| d.as_millis() as i64)
602            .unwrap_or(0);
603
604        // Build session struct
605        let session = Session {
606            session_id: session_id.clone(),
607            task_id: task_id.to_string(),
608            grite_issue_id: task.grite_issue_id.clone(),
609            role,
610            session_type,
611            engine: engine.to_string(),
612            worktree: worktree.to_string(),
613            pid,
614            status: SessionStatus::Spawned,
615            started_ts,
616            last_heartbeat_ts: None,
617            exit_code: None,
618            exit_reason: None,
619            last_output_ref: None,
620        };
621
622        // Post session comment
623        let comment_body = format_session_comment(&session);
624        self.issue_comment(&task.grite_issue_id, &comment_body)?;
625
626        // Update task labels with session info
627        self.issue_label_add(
628            &task.grite_issue_id,
629            &[
630                SessionStatus::Spawned.as_label(),
631                session_type.as_label(),
632                &format!("engine:{}", engine),
633            ],
634        )?;
635
636        Ok(session)
637    }
638
639    /// List active sessions, optionally filtered by task.
640    ///
641    /// Only returns sessions that are not in the Exit state.
642    pub fn session_list(&self, task_id: Option<&str>) -> Result<Vec<Session>, GriteError> {
643        // Build label filter
644        let mut labels: Vec<&str> = vec!["type:task"];
645        let task_label;
646        if let Some(tid) = task_id {
647            if !is_valid_task_id(tid) {
648                return Err(GriteError::InvalidId(tid.to_string()));
649            }
650            task_label = format!("task:{}", tid);
651            labels.push(&task_label);
652        }
653
654        // Get tasks with active session labels (not exit)
655        let issues = self.issue_list(&labels, Some("open"))?;
656
657        let mut sessions = Vec::new();
658        for issue in issues {
659            // Check for active session labels
660            let has_active_session = issue.labels.iter().any(|l| {
661                matches!(
662                    l.as_str(),
663                    "session:spawned" | "session:ready" | "session:running" | "session:handoff"
664                )
665            });
666
667            if has_active_session {
668                // Fetch full issue to get comments
669                if let Ok(full_issue) = self.issue_show(&issue.issue_id) {
670                    if let Some(session) = parse_latest_session_from_issue(&full_issue, &issue) {
671                        sessions.push(session);
672                    }
673                }
674            }
675        }
676
677        Ok(sessions)
678    }
679
680    /// Get a session by its Brat session ID.
681    pub fn session_get(&self, session_id: &str) -> Result<Session, GriteError> {
682        if !is_valid_session_id(session_id) {
683            return Err(GriteError::InvalidId(session_id.to_string()));
684        }
685
686        // Search all task issues for this session
687        // TODO: Optimize with session index or session: label
688        let tasks = self.issue_list(&["type:task"], None)?;
689
690        for task_summary in tasks {
691            if let Ok(issue) = self.issue_show(&task_summary.issue_id) {
692                if let Some(session) =
693                    parse_session_by_id_from_issue(&issue, &task_summary, session_id)
694                {
695                    return Ok(session);
696                }
697            }
698        }
699
700        Err(GriteError::NotFound(format!("session {}", session_id)))
701    }
702
703    /// Update session status with validation.
704    pub fn session_update_status(
705        &self,
706        session_id: &str,
707        new_status: SessionStatus,
708    ) -> Result<(), GriteError> {
709        self.session_update_status_with_options(session_id, new_status, false)
710    }
711
712    /// Update session status with options.
713    ///
714    /// If `force` is true, bypasses state machine validation.
715    pub fn session_update_status_with_options(
716        &self,
717        session_id: &str,
718        new_status: SessionStatus,
719        force: bool,
720    ) -> Result<(), GriteError> {
721        let session = self.session_get(session_id)?;
722
723        // Validate state transition
724        let state_machine = StateMachine::<SessionStatus>::new();
725        state_machine
726            .validate(session.status, new_status, force)
727            .map_err(|e| GriteError::InvalidStateTransition(e.to_string()))?;
728
729        // Update session with new state
730        let mut updated_session = session.clone();
731        updated_session.status = new_status;
732        updated_session.last_heartbeat_ts = Some(
733            std::time::SystemTime::now()
734                .duration_since(std::time::UNIX_EPOCH)
735                .map(|d| d.as_millis() as i64)
736                .unwrap_or(0),
737        );
738
739        let comment_body = format_session_comment(&updated_session);
740        self.issue_comment(&session.grite_issue_id, &comment_body)?;
741
742        // Update labels: remove old session status, add new
743        let old_labels: Vec<&str> = SessionStatus::all_labels().to_vec();
744        self.issue_label_remove(&session.grite_issue_id, &old_labels)?;
745        self.issue_label_add(&session.grite_issue_id, &[new_status.as_label()])?;
746
747        Ok(())
748    }
749
750    /// Record session heartbeat.
751    ///
752    /// Posts a new session comment with updated heartbeat timestamp.
753    pub fn session_heartbeat(&self, session_id: &str) -> Result<(), GriteError> {
754        let session = self.session_get(session_id)?;
755
756        // Update session with new heartbeat
757        let mut updated_session = session.clone();
758        updated_session.last_heartbeat_ts = Some(
759            std::time::SystemTime::now()
760                .duration_since(std::time::UNIX_EPOCH)
761                .map(|d| d.as_millis() as i64)
762                .unwrap_or(0),
763        );
764
765        let comment_body = format_session_comment(&updated_session);
766        self.issue_comment(&session.grite_issue_id, &comment_body)?;
767
768        Ok(())
769    }
770
771    /// Record session exit.
772    ///
773    /// Posts a final session comment with exit information and updates labels.
774    pub fn session_exit(
775        &self,
776        session_id: &str,
777        exit_code: i32,
778        exit_reason: &str,
779        last_output_ref: Option<&str>,
780    ) -> Result<(), GriteError> {
781        let session = self.session_get(session_id)?;
782
783        // Build exit comment
784        let mut updated_session = session.clone();
785        updated_session.status = SessionStatus::Exit;
786        updated_session.exit_code = Some(exit_code);
787        updated_session.exit_reason = Some(exit_reason.to_string());
788        updated_session.last_output_ref = last_output_ref.map(|s| s.to_string());
789        updated_session.last_heartbeat_ts = Some(
790            std::time::SystemTime::now()
791                .duration_since(std::time::UNIX_EPOCH)
792                .map(|d| d.as_millis() as i64)
793                .unwrap_or(0),
794        );
795
796        let comment_body = format_session_comment(&updated_session);
797        self.issue_comment(&session.grite_issue_id, &comment_body)?;
798
799        // Update labels
800        let old_labels: Vec<&str> = SessionStatus::all_labels().to_vec();
801        self.issue_label_remove(&session.grite_issue_id, &old_labels)?;
802        self.issue_label_add(&session.grite_issue_id, &[SessionStatus::Exit.as_label()])?;
803
804        Ok(())
805    }
806
807    // -------------------------------------------------------------------------
808    // Lock operations
809    // -------------------------------------------------------------------------
810
811    /// Acquire a lock on a resource.
812    ///
813    /// Returns a `LockResult` indicating whether the lock was acquired.
814    /// If the lock is already held by another actor, `acquired` will be false
815    /// and `holder` will contain the current owner.
816    pub fn lock_acquire(&self, resource: &str, ttl_ms: i64) -> Result<LockResult, GriteError> {
817        // Convert milliseconds to seconds (grit now uses --ttl in seconds)
818        let ttl_seconds = (ttl_ms / 1000).max(1);
819        let ttl_str = ttl_seconds.to_string();
820        let args = vec!["lock", "acquire", resource, "--ttl", &ttl_str];
821
822        let response: LockAcquireResponse = self.run_json(&args)?;
823
824        Ok(LockResult {
825            acquired: true, // If we get a response without error, the lock was acquired
826            resource: response.resource,
827            holder: Some(response.owner),
828            expires_unix_ms: response.expires_unix_ms,
829        })
830    }
831
832    /// Release a lock on a resource.
833    ///
834    /// This is a best-effort operation; errors are logged but not fatal.
835    pub fn lock_release(&self, resource: &str) -> Result<(), GriteError> {
836        let args = vec!["lock", "release", resource];
837        let _: serde_json::Value = self.run_json(&args)?;
838        Ok(())
839    }
840
841    // -------------------------------------------------------------------------
842    // Dependency operations (grite issue dep)
843    // -------------------------------------------------------------------------
844
845    /// Add a dependency between two tasks.
846    ///
847    /// The dependency is from `task_issue_id` to `target_issue_id` with the given type.
848    /// For example, `DependencyType::DependsOn` means the task depends on the target.
849    pub fn task_dep_add(
850        &self,
851        task_issue_id: &str,
852        target_issue_id: &str,
853        dep_type: DependencyType,
854    ) -> Result<(), GriteError> {
855        let args = vec![
856            "issue",
857            "dep",
858            "add",
859            task_issue_id,
860            "--target",
861            target_issue_id,
862            "--type",
863            dep_type.as_str(),
864        ];
865        let _: serde_json::Value = self.run_json_direct(&args)?;
866        Ok(())
867    }
868
869    /// Remove a dependency between two tasks.
870    pub fn task_dep_remove(
871        &self,
872        task_issue_id: &str,
873        target_issue_id: &str,
874        dep_type: DependencyType,
875    ) -> Result<(), GriteError> {
876        let args = vec![
877            "issue",
878            "dep",
879            "remove",
880            task_issue_id,
881            "--target",
882            target_issue_id,
883            "--type",
884            dep_type.as_str(),
885        ];
886        let _: serde_json::Value = self.run_json_direct(&args)?;
887        Ok(())
888    }
889
890    /// List dependencies for a task.
891    ///
892    /// If `reverse` is true, returns issues that depend on this task.
893    /// If `reverse` is false, returns issues that this task depends on.
894    pub fn task_dep_list(
895        &self,
896        task_issue_id: &str,
897        reverse: bool,
898    ) -> Result<Vec<TaskDependency>, GriteError> {
899        let mut args = vec!["issue", "dep", "list", task_issue_id];
900        if reverse {
901            args.push("--reverse");
902        }
903
904        let response: DepListResponse = self.run_json_direct(&args)?;
905        Ok(response
906            .deps
907            .into_iter()
908            .map(|d| TaskDependency {
909                issue_id: d.issue_id,
910                dep_type: DependencyType::from_str(&d.dep_type).unwrap_or(DependencyType::RelatedTo),
911                title: d.title,
912            })
913            .collect())
914    }
915
916    /// Get tasks in topological order (ready-to-run first).
917    ///
918    /// Returns issues sorted so that dependencies come before dependents.
919    /// Optionally filter by label (e.g., "convoy:c-xxx" to get tasks for a specific convoy).
920    pub fn task_topo_order(
921        &self,
922        label: Option<&str>,
923    ) -> Result<Vec<GriteIssueSummary>, GriteError> {
924        let mut args = vec!["issue", "dep", "topo", "--state", "open"];
925        if let Some(l) = label {
926            args.push("--label");
927            args.push(l);
928        }
929
930        let response: DepTopoResponse = self.run_json_direct(&args)?;
931        Ok(response
932            .issues
933            .into_iter()
934            .map(|r| r.into_grite_issue_summary())
935            .collect())
936    }
937
938    // -------------------------------------------------------------------------
939    // Context operations (grite context)
940    // -------------------------------------------------------------------------
941
942    /// Index files for symbol extraction.
943    ///
944    /// If `paths` is empty, indexes all tracked files.
945    /// If `force` is true, re-indexes even if content hasn't changed.
946    /// If `pattern` is provided, only files matching the glob are indexed.
947    pub fn context_index(
948        &self,
949        paths: &[&str],
950        force: bool,
951        pattern: Option<&str>,
952    ) -> Result<ContextIndexResult, GriteError> {
953        let mut args = vec!["context", "index"];
954        for path in paths {
955            args.push("--path");
956            args.push(path);
957        }
958        if force {
959            args.push("--force");
960        }
961        if let Some(pat) = pattern {
962            args.push("--pattern");
963            args.push(pat);
964        }
965
966        let response: ContextIndexResponse = self.run_json_direct(&args)?;
967        Ok(ContextIndexResult {
968            indexed: response.indexed,
969            skipped: response.skipped,
970            total_files: response.total_files,
971        })
972    }
973
974    /// Query for symbols matching a pattern.
975    ///
976    /// Returns symbol names and their file paths.
977    pub fn context_query(&self, query: &str) -> Result<Vec<SymbolMatch>, GriteError> {
978        let args = vec!["context", "query", query];
979        let response: ContextQueryResponse = self.run_json_direct(&args)?;
980        Ok(response
981            .matches
982            .into_iter()
983            .map(|m| SymbolMatch {
984                symbol: m.symbol,
985                path: m.path,
986            })
987            .collect())
988    }
989
990    /// Show context for a specific file.
991    ///
992    /// Returns file metadata and extracted symbols.
993    pub fn context_show(&self, path: &str) -> Result<FileContext, GriteError> {
994        let args = vec!["context", "show", path];
995        let response: ContextShowResponse = self.run_json_direct(&args)?;
996        Ok(FileContext {
997            path: response.path,
998            language: response.language,
999            summary: response.summary,
1000            content_hash: response.content_hash,
1001            symbols: response
1002                .symbols
1003                .into_iter()
1004                .map(|s| Symbol {
1005                    name: s.name,
1006                    kind: s.kind,
1007                    line_start: s.line_start,
1008                    line_end: s.line_end,
1009                })
1010                .collect(),
1011        })
1012    }
1013
1014    /// Get a project context value by key.
1015    ///
1016    /// Returns None if the key doesn't exist.
1017    pub fn context_project_get(&self, key: &str) -> Result<Option<String>, GriteError> {
1018        let args = vec!["context", "project", key];
1019        match self.run_json_direct::<ContextProjectSingleResponse>(&args) {
1020            Ok(response) => Ok(Some(response.value)),
1021            Err(GriteError::NotFound(_)) => Ok(None),
1022            Err(e) => Err(e),
1023        }
1024    }
1025
1026    /// List all project context entries.
1027    pub fn context_project_list(&self) -> Result<Vec<ProjectContextEntry>, GriteError> {
1028        let args = vec!["context", "project"];
1029        let response: ContextProjectListResponse = self.run_json_direct(&args)?;
1030        Ok(response
1031            .entries
1032            .into_iter()
1033            .map(|e| ProjectContextEntry {
1034                key: e.key,
1035                value: e.value,
1036            })
1037            .collect())
1038    }
1039
1040    /// Set a project context value.
1041    pub fn context_project_set(&self, key: &str, value: &str) -> Result<(), GriteError> {
1042        let args = vec!["context", "set", key, value];
1043        let _: serde_json::Value = self.run_json_direct(&args)?;
1044        Ok(())
1045    }
1046
1047    // -------------------------------------------------------------------------
1048    // Internal helpers
1049    // -------------------------------------------------------------------------
1050
1051    /// Check if grit should run without daemon.
1052    ///
1053    /// Returns true if the `GRITE_NO_DAEMON` environment variable is set.
1054    fn should_skip_daemon() -> bool {
1055        std::env::var("GRITE_NO_DAEMON").is_ok()
1056    }
1057
1058    /// Run a grit command and parse JSON output.
1059    ///
1060    /// Retries on db_busy errors up to 3 times with exponential backoff.
1061    fn run_json<T: DeserializeOwned>(&self, args: &[&str]) -> Result<T, GriteError> {
1062        let mut cmd_args = args.to_vec();
1063        cmd_args.push("--json");
1064        if Self::should_skip_daemon() {
1065            cmd_args.push("--no-daemon");
1066        }
1067
1068        let max_retries = 5;
1069        let mut last_error = None;
1070
1071        for attempt in 0..max_retries {
1072            if attempt > 0 {
1073                // Exponential backoff: 100ms, 200ms, 400ms, 800ms
1074                std::thread::sleep(std::time::Duration::from_millis(100 << attempt));
1075            }
1076
1077            let output = Command::new("grit")
1078                .args(&cmd_args)
1079                .current_dir(&self.repo_root)
1080                .output()
1081                .map_err(|e| GriteError::CommandFailed(format!("failed to run grite: {}", e)))?;
1082
1083            let stdout = String::from_utf8_lossy(&output.stdout);
1084
1085            // Try to parse as JSON envelope
1086            let envelope: Result<JsonResponse<T>, _> = serde_json::from_str(&stdout);
1087
1088            match envelope {
1089                Ok(env) => {
1090                    if let Some(error) = env.error {
1091                        // Check if it's a retryable database lock error
1092                        let is_retryable = error.code == "db_busy"
1093                            || error.code == "db_error"
1094                            || error.message.contains("could not acquire lock")
1095                            || error.message.contains("WouldBlock")
1096                            || error.message.contains("temporarily unavailable");
1097                        if is_retryable && attempt < max_retries - 1 {
1098                            last_error = Some(GriteError::CommandFailed(error.message));
1099                            continue;
1100                        }
1101                        return Err(GriteError::CommandFailed(error.message));
1102                    }
1103                    // Check schema version for compatibility
1104                    if let Some(version) = env.schema_version {
1105                        if version != EXPECTED_GRIT_SCHEMA_VERSION {
1106                            eprintln!(
1107                                "Warning: Grit schema version mismatch (expected {}, got {}). \
1108                                 Consider updating brat or grite.",
1109                                EXPECTED_GRIT_SCHEMA_VERSION, version
1110                            );
1111                        }
1112                    }
1113                    return env
1114                        .data
1115                        .ok_or_else(|| GriteError::UnexpectedResponse("missing data in response".into()));
1116                }
1117                Err(e) => {
1118                    if !output.status.success() {
1119                        let stderr = String::from_utf8_lossy(&output.stderr);
1120                        // Check if stderr contains retryable lock indication
1121                        let is_retryable = stderr.contains("db_busy")
1122                            || stderr.contains("db_error")
1123                            || stderr.contains("Database locked")
1124                            || stderr.contains("could not acquire lock")
1125                            || stderr.contains("WouldBlock")
1126                            || stderr.contains("temporarily unavailable");
1127                        if is_retryable && attempt < max_retries - 1 {
1128                            last_error = Some(GriteError::CommandFailed(stderr.to_string()));
1129                            continue;
1130                        }
1131                        return Err(GriteError::CommandFailed(stderr.to_string()));
1132                    }
1133                    return Err(GriteError::ParseError(format!(
1134                        "failed to parse grite output: {} - raw: {}",
1135                        e, stdout
1136                    )));
1137                }
1138            }
1139        }
1140
1141        Err(last_error.unwrap_or_else(|| GriteError::CommandFailed("max retries exceeded".into())))
1142    }
1143
1144    /// Run a grit command and parse the JSON output directly (no envelope wrapper).
1145    /// Used for issue commands which now return data directly.
1146    fn run_json_direct<T: DeserializeOwned>(&self, args: &[&str]) -> Result<T, GriteError> {
1147        let mut cmd_args = args.to_vec();
1148        cmd_args.push("--json");
1149        if Self::should_skip_daemon() {
1150            cmd_args.push("--no-daemon");
1151        }
1152
1153        let max_retries = 5;
1154        let mut last_error = None;
1155
1156        for attempt in 0..max_retries {
1157            if attempt > 0 {
1158                // Exponential backoff: 100ms, 200ms, 400ms, 800ms
1159                std::thread::sleep(std::time::Duration::from_millis(100 << attempt));
1160            }
1161
1162            let output = Command::new("grit")
1163                .args(&cmd_args)
1164                .current_dir(&self.repo_root)
1165                .output()
1166                .map_err(|e| GriteError::CommandFailed(format!("failed to run grite: {}", e)))?;
1167
1168            let stdout = String::from_utf8_lossy(&output.stdout);
1169
1170            if !output.status.success() {
1171                let stderr = String::from_utf8_lossy(&output.stderr);
1172                // Check if stderr contains retryable lock indication
1173                let is_retryable = stderr.contains("db_busy")
1174                    || stderr.contains("db_error")
1175                    || stderr.contains("Database locked")
1176                    || stderr.contains("could not acquire lock")
1177                    || stderr.contains("WouldBlock")
1178                    || stderr.contains("temporarily unavailable");
1179                if is_retryable && attempt < max_retries - 1 {
1180                    last_error = Some(GriteError::CommandFailed(stderr.to_string()));
1181                    continue;
1182                }
1183                return Err(GriteError::CommandFailed(stderr.to_string()));
1184            }
1185
1186            // First try to parse as a JSON envelope with { ok, data, error } structure
1187            let json_value: serde_json::Value = match serde_json::from_str(&stdout) {
1188                Ok(v) => v,
1189                Err(e) => {
1190                    return Err(GriteError::ParseError(format!(
1191                        "failed to parse grite JSON output: {} - raw: {}",
1192                        e, stdout
1193                    )));
1194                }
1195            };
1196
1197            // Check if it's an envelope structure
1198            if let Some(ok) = json_value.get("ok").and_then(|v| v.as_bool()) {
1199                if !ok {
1200                    // Command failed - extract error message
1201                    let error_msg = json_value
1202                        .get("error")
1203                        .and_then(|e| e.get("message"))
1204                        .and_then(|m| m.as_str())
1205                        .unwrap_or("unknown error");
1206
1207                    // Check for retryable errors
1208                    let is_retryable = error_msg.contains("db_busy")
1209                        || error_msg.contains("db_error")
1210                        || error_msg.contains("could not acquire lock");
1211                    if is_retryable && attempt < max_retries - 1 {
1212                        last_error = Some(GriteError::CommandFailed(error_msg.to_string()));
1213                        continue;
1214                    }
1215                    return Err(GriteError::CommandFailed(error_msg.to_string()));
1216                }
1217
1218                // Extract data from envelope
1219                if let Some(data) = json_value.get("data") {
1220                    match serde_json::from_value(data.clone()) {
1221                        Ok(result) => return Ok(result),
1222                        Err(e) => {
1223                            return Err(GriteError::ParseError(format!(
1224                                "failed to parse grite data: {} - raw: {}",
1225                                e, data
1226                            )));
1227                        }
1228                    }
1229                }
1230            }
1231
1232            // Fall back to parsing directly (for backward compatibility)
1233            match serde_json::from_value(json_value.clone()) {
1234                Ok(result) => return Ok(result),
1235                Err(e) => {
1236                    return Err(GriteError::ParseError(format!(
1237                        "failed to parse grite output: {} - raw: {}",
1238                        e, stdout
1239                    )));
1240                }
1241            }
1242        }
1243
1244        Err(last_error.unwrap_or_else(|| GriteError::CommandFailed("max retries exceeded".into())))
1245    }
1246}
1247
1248/// Parse a convoy from a Grit issue summary.
1249fn parse_convoy_from_summary(issue: &GriteIssueSummary) -> Result<Convoy, GriteError> {
1250    // Extract convoy ID from labels
1251    let convoy_id = issue
1252        .labels
1253        .iter()
1254        .find_map(|label| label.strip_prefix("convoy:"))
1255        .ok_or_else(|| GriteError::ParseError("missing convoy: label".into()))?
1256        .to_string();
1257
1258    // Extract status from labels
1259    let status = issue
1260        .labels
1261        .iter()
1262        .find_map(|label| ConvoyStatus::from_label(label))
1263        .unwrap_or_default();
1264
1265    Ok(Convoy {
1266        convoy_id,
1267        grite_issue_id: issue.issue_id.clone(),
1268        title: issue.title.clone(),
1269        body: String::new(), // Summary doesn't include body
1270        status,
1271    })
1272}
1273
1274/// Parse a task from a Grit issue summary.
1275fn parse_task_from_summary(issue: &GriteIssueSummary) -> Result<Task, GriteError> {
1276    // Extract task ID from labels
1277    let task_id = issue
1278        .labels
1279        .iter()
1280        .find_map(|label| label.strip_prefix("task:"))
1281        .ok_or_else(|| GriteError::ParseError("missing task: label".into()))?
1282        .to_string();
1283
1284    // Extract convoy ID from labels
1285    let convoy_id = issue
1286        .labels
1287        .iter()
1288        .find_map(|label| label.strip_prefix("convoy:"))
1289        .ok_or_else(|| GriteError::ParseError("missing convoy: label".into()))?
1290        .to_string();
1291
1292    // Extract status from labels
1293    let status = issue
1294        .labels
1295        .iter()
1296        .find_map(|label| TaskStatus::from_label(label))
1297        .unwrap_or_default();
1298
1299    Ok(Task {
1300        task_id,
1301        grite_issue_id: issue.issue_id.clone(),
1302        convoy_id,
1303        title: issue.title.clone(),
1304        body: String::new(), // Summary doesn't include body
1305        status,
1306    })
1307}
1308
1309/// Parse a task from a summary and full issue (includes body).
1310fn parse_task_from_full_issue(
1311    summary: &GriteIssueSummary,
1312    full: &GriteIssue,
1313) -> Result<Task, GriteError> {
1314    // Extract task ID from labels
1315    let task_id = summary
1316        .labels
1317        .iter()
1318        .find_map(|label| label.strip_prefix("task:"))
1319        .ok_or_else(|| GriteError::ParseError("missing task: label".into()))?
1320        .to_string();
1321
1322    // Extract convoy ID from labels
1323    let convoy_id = summary
1324        .labels
1325        .iter()
1326        .find_map(|label| label.strip_prefix("convoy:"))
1327        .ok_or_else(|| GriteError::ParseError("missing convoy: label".into()))?
1328        .to_string();
1329
1330    // Extract status from labels
1331    let status = summary
1332        .labels
1333        .iter()
1334        .find_map(|label| TaskStatus::from_label(label))
1335        .unwrap_or_default();
1336
1337    Ok(Task {
1338        task_id,
1339        grite_issue_id: summary.issue_id.clone(),
1340        convoy_id,
1341        title: full.title.clone(),
1342        body: full.body.clone(),
1343        status,
1344    })
1345}
1346
1347// =============================================================================
1348// Session Comment Helpers
1349// =============================================================================
1350
1351/// Format a session as a comment block per session-event-schema.md.
1352fn format_session_comment(session: &Session) -> String {
1353    let mut lines = vec![
1354        "[session]".to_string(),
1355        format!("state = \"{}\"", session.status),
1356        format!("session_id = \"{}\"", session.session_id),
1357        format!("role = \"{}\"", session.role.as_str()),
1358        format!("session_type = \"{}\"", session.session_type.as_str()),
1359        format!("engine = \"{}\"", session.engine),
1360        format!("worktree = \"{}\"", session.worktree),
1361    ];
1362
1363    if let Some(pid) = session.pid {
1364        lines.push(format!("pid = {}", pid));
1365    } else {
1366        lines.push("pid = null".to_string());
1367    }
1368
1369    lines.push(format!("started_ts = {}", session.started_ts));
1370
1371    if let Some(ts) = session.last_heartbeat_ts {
1372        lines.push(format!("last_heartbeat_ts = {}", ts));
1373    } else {
1374        lines.push("last_heartbeat_ts = null".to_string());
1375    }
1376
1377    match session.exit_code {
1378        Some(code) => lines.push(format!("exit_code = {}", code)),
1379        None => lines.push("exit_code = null".to_string()),
1380    }
1381
1382    match &session.exit_reason {
1383        Some(reason) => lines.push(format!("exit_reason = \"{}\"", reason)),
1384        None => lines.push("exit_reason = null".to_string()),
1385    }
1386
1387    match &session.last_output_ref {
1388        Some(ref_str) => lines.push(format!("last_output_ref = \"{}\"", ref_str)),
1389        None => lines.push("last_output_ref = null".to_string()),
1390    }
1391
1392    lines.push("[/session]".to_string());
1393    lines.join("\n")
1394}
1395
1396/// Parse session comment block from text.
1397///
1398/// Returns None if no valid session block is found.
1399fn parse_session_comment(text: &str) -> Option<SessionCommentData> {
1400    // Find [session] ... [/session] block
1401    let start = text.find("[session]")?;
1402    let end = text.find("[/session]")?;
1403    if end <= start {
1404        return None;
1405    }
1406    let block = &text[start + 9..end];
1407
1408    // Parse key = value pairs (simplified TOML-like)
1409    let mut session_id = None;
1410    let mut state = None;
1411    let mut role = None;
1412    let mut session_type = None;
1413    let mut engine = None;
1414    let mut worktree = String::new();
1415    let mut pid = None;
1416    let mut started_ts = None;
1417    let mut last_heartbeat_ts = None;
1418    let mut exit_code = None;
1419    let mut exit_reason = None;
1420    let mut last_output_ref = None;
1421
1422    for line in block.lines() {
1423        let line = line.trim();
1424        if let Some((key, value)) = line.split_once('=') {
1425            let key = key.trim();
1426            let value = value.trim();
1427            // Remove surrounding quotes if present
1428            let value = value.trim_matches('"');
1429
1430            match key {
1431                "session_id" => session_id = Some(value.to_string()),
1432                "state" => state = SessionStatus::from_label(&format!("session:{}", value)),
1433                "role" => role = SessionRole::from_str(value),
1434                "session_type" => session_type = SessionType::from_str(value),
1435                "engine" => engine = Some(value.to_string()),
1436                "worktree" => worktree = value.to_string(),
1437                "pid" if value != "null" => pid = value.parse().ok(),
1438                "started_ts" => started_ts = value.parse().ok(),
1439                "last_heartbeat_ts" if value != "null" => last_heartbeat_ts = value.parse().ok(),
1440                "exit_code" if value != "null" => exit_code = value.parse().ok(),
1441                "exit_reason" if value != "null" => exit_reason = Some(value.to_string()),
1442                "last_output_ref" if value != "null" => last_output_ref = Some(value.to_string()),
1443                _ => {}
1444            }
1445        }
1446    }
1447
1448    Some(SessionCommentData {
1449        session_id: session_id?,
1450        status: state?,
1451        role: role?,
1452        session_type: session_type?,
1453        engine: engine?,
1454        worktree,
1455        pid,
1456        started_ts: started_ts?,
1457        last_heartbeat_ts,
1458        exit_code,
1459        exit_reason,
1460        last_output_ref,
1461    })
1462}
1463
1464/// Intermediate struct for parsed session comment data.
1465struct SessionCommentData {
1466    session_id: String,
1467    status: SessionStatus,
1468    role: SessionRole,
1469    session_type: SessionType,
1470    engine: String,
1471    worktree: String,
1472    pid: Option<u32>,
1473    started_ts: i64,
1474    last_heartbeat_ts: Option<i64>,
1475    exit_code: Option<i32>,
1476    exit_reason: Option<String>,
1477    last_output_ref: Option<String>,
1478}
1479
1480/// Parse the latest session from a Grit issue (body + comments).
1481fn parse_latest_session_from_issue(
1482    issue: &GriteIssue,
1483    summary: &GriteIssueSummary,
1484) -> Option<Session> {
1485    // Extract task_id from labels
1486    let task_id = summary
1487        .labels
1488        .iter()
1489        .find_map(|label| label.strip_prefix("task:"))?
1490        .to_string();
1491
1492    // Try to parse session from body first, then look for most recent in comments
1493    // For now, just parse from body (comments would need Grite CLI support)
1494    let data = parse_session_comment(&issue.body)?;
1495
1496    Some(Session {
1497        session_id: data.session_id,
1498        task_id,
1499        grite_issue_id: issue.issue_id.clone(),
1500        role: data.role,
1501        session_type: data.session_type,
1502        engine: data.engine,
1503        worktree: data.worktree,
1504        pid: data.pid,
1505        status: data.status,
1506        started_ts: data.started_ts,
1507        last_heartbeat_ts: data.last_heartbeat_ts,
1508        exit_code: data.exit_code,
1509        exit_reason: data.exit_reason,
1510        last_output_ref: data.last_output_ref,
1511    })
1512}
1513
1514/// Parse a specific session by ID from a Grit issue.
1515fn parse_session_by_id_from_issue(
1516    issue: &GriteIssue,
1517    summary: &GriteIssueSummary,
1518    session_id: &str,
1519) -> Option<Session> {
1520    // Extract task_id from labels
1521    let task_id = summary
1522        .labels
1523        .iter()
1524        .find_map(|label| label.strip_prefix("task:"))?
1525        .to_string();
1526
1527    // Parse session from body
1528    let data = parse_session_comment(&issue.body)?;
1529
1530    // Check if this is the session we're looking for
1531    if data.session_id != session_id {
1532        return None;
1533    }
1534
1535    Some(Session {
1536        session_id: data.session_id,
1537        task_id,
1538        grite_issue_id: issue.issue_id.clone(),
1539        role: data.role,
1540        session_type: data.session_type,
1541        engine: data.engine,
1542        worktree: data.worktree,
1543        pid: data.pid,
1544        status: data.status,
1545        started_ts: data.started_ts,
1546        last_heartbeat_ts: data.last_heartbeat_ts,
1547        exit_code: data.exit_code,
1548        exit_reason: data.exit_reason,
1549        last_output_ref: data.last_output_ref,
1550    })
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555    use super::*;
1556
1557    #[test]
1558    fn test_format_parse_session_comment_roundtrip() {
1559        let session = Session {
1560            session_id: "s-20250117-a2f9".to_string(),
1561            task_id: "t-20250117-b3c4".to_string(),
1562            grite_issue_id: "issue-123".to_string(),
1563            role: SessionRole::Witness,
1564            session_type: SessionType::Polecat,
1565            engine: "shell".to_string(),
1566            worktree: ".grite/worktrees/s-20250117-a2f9".to_string(),
1567            pid: Some(12345),
1568            status: SessionStatus::Running,
1569            started_ts: 1700000000000,
1570            last_heartbeat_ts: Some(1700000005000),
1571            exit_code: None,
1572            exit_reason: None,
1573            last_output_ref: None,
1574        };
1575
1576        let comment = format_session_comment(&session);
1577        let parsed = parse_session_comment(&comment).expect("should parse");
1578
1579        assert_eq!(parsed.session_id, session.session_id);
1580        assert_eq!(parsed.role, session.role);
1581        assert_eq!(parsed.session_type, session.session_type);
1582        assert_eq!(parsed.engine, session.engine);
1583        assert_eq!(parsed.worktree, session.worktree);
1584        assert_eq!(parsed.pid, session.pid);
1585        assert_eq!(parsed.status, session.status);
1586        assert_eq!(parsed.started_ts, session.started_ts);
1587        assert_eq!(parsed.last_heartbeat_ts, session.last_heartbeat_ts);
1588        assert_eq!(parsed.exit_code, session.exit_code);
1589        assert_eq!(parsed.exit_reason, session.exit_reason);
1590        assert_eq!(parsed.last_output_ref, session.last_output_ref);
1591    }
1592
1593    #[test]
1594    fn test_format_parse_session_with_exit() {
1595        let session = Session {
1596            session_id: "s-20250117-dead".to_string(),
1597            task_id: "t-20250117-beef".to_string(),
1598            grite_issue_id: "issue-456".to_string(),
1599            role: SessionRole::User,
1600            session_type: SessionType::Crew,
1601            engine: "claude".to_string(),
1602            worktree: "".to_string(),
1603            pid: None,
1604            status: SessionStatus::Exit,
1605            started_ts: 1700000000000,
1606            last_heartbeat_ts: Some(1700000010000),
1607            exit_code: Some(1),
1608            exit_reason: Some("timeout".to_string()),
1609            last_output_ref: Some("sha256:abc123".to_string()),
1610        };
1611
1612        let comment = format_session_comment(&session);
1613        let parsed = parse_session_comment(&comment).expect("should parse");
1614
1615        assert_eq!(parsed.session_id, session.session_id);
1616        assert_eq!(parsed.status, SessionStatus::Exit);
1617        assert_eq!(parsed.exit_code, Some(1));
1618        assert_eq!(parsed.exit_reason, Some("timeout".to_string()));
1619        assert_eq!(parsed.last_output_ref, Some("sha256:abc123".to_string()));
1620    }
1621
1622    #[test]
1623    fn test_parse_session_comment_invalid() {
1624        // No session block
1625        assert!(parse_session_comment("no session here").is_none());
1626
1627        // Empty block
1628        assert!(parse_session_comment("[session][/session]").is_none());
1629
1630        // Missing required fields
1631        assert!(parse_session_comment("[session]\nstate = \"running\"\n[/session]").is_none());
1632    }
1633}