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
19const EXPECTED_GRIT_SCHEMA_VERSION: u32 = 1;
21
22#[derive(Debug, Deserialize)]
24struct JsonResponse<T> {
25 #[serde(default)]
26 schema_version: Option<u32>,
27 #[serde(default)]
28 #[allow(dead_code)] 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
41fn bytes_to_hex(bytes: &[u8]) -> String {
43 bytes.iter().map(|b| format!("{:02x}", b)).collect()
44}
45
46#[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#[derive(Debug, Deserialize)]
65struct IssueCreateResponse {
66 issue_id: IssueIdFormat,
67 #[allow(dead_code)]
68 event_id: Option<String>,
69}
70
71#[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#[derive(Debug, Deserialize)]
100struct IssueListResponse {
101 issues: Vec<RawIssueSummary>,
102}
103
104#[derive(Debug, Deserialize)]
106struct IssueShowResponse {
107 issue: RawIssue,
108 #[allow(dead_code)]
109 events: Option<Vec<serde_json::Value>>,
110}
111
112#[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#[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#[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#[derive(Debug, Clone)]
171pub struct LockResult {
172 pub acquired: bool,
174 pub resource: String,
176 pub holder: Option<String>,
178 pub expires_unix_ms: Option<i64>,
180}
181
182#[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#[derive(Debug, Deserialize)]
194struct DepListEntry {
195 issue_id: String,
196 dep_type: String,
197 title: String,
198}
199
200#[derive(Debug, Deserialize)]
202struct DepTopoResponse {
203 issues: Vec<RawIssueSummary>,
204 #[allow(dead_code)]
205 order: String,
206}
207
208#[derive(Debug, Deserialize)]
210struct ContextIndexResponse {
211 indexed: u32,
212 skipped: u32,
213 total_files: u32,
214}
215
216#[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#[derive(Debug, Deserialize)]
228struct ContextQueryMatch {
229 symbol: String,
230 path: String,
231}
232
233#[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#[derive(Debug, Deserialize)]
247struct ContextSymbol {
248 name: String,
249 kind: String,
250 line_start: u32,
251 line_end: u32,
252}
253
254#[derive(Debug, Deserialize)]
256struct ContextProjectSingleResponse {
257 key: String,
258 value: String,
259}
260
261#[derive(Debug, Deserialize)]
263struct ContextProjectListResponse {
264 entries: Vec<ContextProjectEntry>,
265 #[allow(dead_code)]
266 count: u32,
267}
268
269#[derive(Debug, Deserialize)]
271struct ContextProjectEntry {
272 key: String,
273 value: String,
274}
275
276#[derive(Clone)]
278pub struct GriteClient {
279 repo_root: PathBuf,
280}
281
282impl GriteClient {
283 pub fn new(repo_root: impl Into<PathBuf>) -> Self {
285 Self {
286 repo_root: repo_root.into(),
287 }
288 }
289
290 pub fn is_initialized(&self, git_dir: &Path) -> bool {
292 git_dir.join("grite").exists()
293 }
294
295 pub fn repo_root(&self) -> &Path {
297 &self.repo_root
298 }
299
300 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 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 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 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 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 let _ = self.run_json_direct::<serde_json::Value>(&args);
369 }
370 Ok(())
371 }
372
373 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 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 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 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 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 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 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 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 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 let full_issue = self.issue_show(&summary.issue_id)?;
513
514 parse_task_from_full_issue(&summary, &full_issue)
515 }
516
517 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 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 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 let old_labels: Vec<&str> = TaskStatus::all_labels().to_vec();
548 self.issue_label_remove(&task.grite_issue_id, &old_labels)?;
549
550 self.issue_label_add(&task.grite_issue_id, &[new_status.as_label()])?;
552
553 Ok(())
554 }
555
556 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 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 let task = self.task_get(task_id)?;
594
595 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 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 let comment_body = format_session_comment(&session);
624 self.issue_comment(&task.grite_issue_id, &comment_body)?;
625
626 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 pub fn session_list(&self, task_id: Option<&str>) -> Result<Vec<Session>, GriteError> {
643 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 let issues = self.issue_list(&labels, Some("open"))?;
656
657 let mut sessions = Vec::new();
658 for issue in issues {
659 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 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 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 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 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 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 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 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 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 pub fn session_heartbeat(&self, session_id: &str) -> Result<(), GriteError> {
754 let session = self.session_get(session_id)?;
755
756 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 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 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 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 pub fn lock_acquire(&self, resource: &str, ttl_ms: i64) -> Result<LockResult, GriteError> {
817 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, resource: response.resource,
827 holder: Some(response.owner),
828 expires_unix_ms: response.expires_unix_ms,
829 })
830 }
831
832 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 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 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 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 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 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 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 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 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 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 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 fn should_skip_daemon() -> bool {
1055 std::env::var("GRITE_NO_DAEMON").is_ok()
1056 }
1057
1058 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 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 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 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 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 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 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 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 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 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 if let Some(ok) = json_value.get("ok").and_then(|v| v.as_bool()) {
1199 if !ok {
1200 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 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 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 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
1248fn parse_convoy_from_summary(issue: &GriteIssueSummary) -> Result<Convoy, GriteError> {
1250 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 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(), status,
1271 })
1272}
1273
1274fn parse_task_from_summary(issue: &GriteIssueSummary) -> Result<Task, GriteError> {
1276 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 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 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(), status,
1306 })
1307}
1308
1309fn parse_task_from_full_issue(
1311 summary: &GriteIssueSummary,
1312 full: &GriteIssue,
1313) -> Result<Task, GriteError> {
1314 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 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 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
1347fn 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
1396fn parse_session_comment(text: &str) -> Option<SessionCommentData> {
1400 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 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 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
1464struct 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
1480fn parse_latest_session_from_issue(
1482 issue: &GriteIssue,
1483 summary: &GriteIssueSummary,
1484) -> Option<Session> {
1485 let task_id = summary
1487 .labels
1488 .iter()
1489 .find_map(|label| label.strip_prefix("task:"))?
1490 .to_string();
1491
1492 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
1514fn parse_session_by_id_from_issue(
1516 issue: &GriteIssue,
1517 summary: &GriteIssueSummary,
1518 session_id: &str,
1519) -> Option<Session> {
1520 let task_id = summary
1522 .labels
1523 .iter()
1524 .find_map(|label| label.strip_prefix("task:"))?
1525 .to_string();
1526
1527 let data = parse_session_comment(&issue.body)?;
1529
1530 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 assert!(parse_session_comment("no session here").is_none());
1626
1627 assert!(parse_session_comment("[session][/session]").is_none());
1629
1630 assert!(parse_session_comment("[session]\nstate = \"running\"\n[/session]").is_none());
1632 }
1633}