1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum DependencyType {
11 Blocks,
13 DependsOn,
15 RelatedTo,
17}
18
19impl DependencyType {
20 pub fn as_str(&self) -> &'static str {
22 match self {
23 DependencyType::Blocks => "blocks",
24 DependencyType::DependsOn => "depends_on",
25 DependencyType::RelatedTo => "related_to",
26 }
27 }
28
29 pub fn from_str(s: &str) -> Option<Self> {
31 match s {
32 "blocks" => Some(DependencyType::Blocks),
33 "depends_on" => Some(DependencyType::DependsOn),
34 "related_to" => Some(DependencyType::RelatedTo),
35 _ => None,
36 }
37 }
38}
39
40impl std::fmt::Display for DependencyType {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 write!(f, "{}", self.as_str())
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TaskDependency {
49 pub issue_id: String,
51 pub dep_type: DependencyType,
53 pub title: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ContextIndexResult {
64 pub indexed: u32,
66 pub skipped: u32,
68 pub total_files: u32,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SymbolMatch {
75 pub symbol: String,
77 pub path: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Symbol {
84 pub name: String,
86 pub kind: String,
88 pub line_start: u32,
90 pub line_end: u32,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FileContext {
97 pub path: String,
99 pub language: String,
101 pub summary: String,
103 pub content_hash: String,
105 pub symbols: Vec<Symbol>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ProjectContextEntry {
112 pub key: String,
114 pub value: String,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct GriteIssue {
125 pub issue_id: String,
126 pub title: String,
127 #[serde(default)]
128 pub body: String,
129 #[serde(default)]
130 pub labels: Vec<String>,
131 #[serde(default)]
132 pub state: String,
133 #[serde(default)]
134 pub updated_ts: i64,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct GriteIssueSummary {
140 pub issue_id: String,
141 pub title: String,
142 #[serde(default)]
143 pub state: String,
144 #[serde(default)]
145 pub labels: Vec<String>,
146 #[serde(default)]
147 pub updated_ts: i64,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum ConvoyStatus {
154 Active,
155 Paused,
156 Complete,
157 Failed,
158}
159
160impl ConvoyStatus {
161 pub fn as_label(&self) -> &'static str {
163 match self {
164 ConvoyStatus::Active => "status:active",
165 ConvoyStatus::Paused => "status:paused",
166 ConvoyStatus::Complete => "status:complete",
167 ConvoyStatus::Failed => "status:failed",
168 }
169 }
170
171 pub fn from_label(label: &str) -> Option<Self> {
173 match label {
174 "status:active" => Some(ConvoyStatus::Active),
175 "status:paused" => Some(ConvoyStatus::Paused),
176 "status:complete" => Some(ConvoyStatus::Complete),
177 "status:failed" => Some(ConvoyStatus::Failed),
178 _ => None,
179 }
180 }
181
182 pub fn all_labels() -> &'static [&'static str] {
184 &[
185 "status:active",
186 "status:paused",
187 "status:complete",
188 "status:failed",
189 ]
190 }
191}
192
193impl Default for ConvoyStatus {
194 fn default() -> Self {
195 ConvoyStatus::Active
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum TaskStatus {
203 Queued,
204 Running,
205 Blocked,
206 NeedsReview,
207 Merged,
208 Dropped,
209}
210
211impl TaskStatus {
212 pub fn as_label(&self) -> &'static str {
214 match self {
215 TaskStatus::Queued => "status:queued",
216 TaskStatus::Running => "status:running",
217 TaskStatus::Blocked => "status:blocked",
218 TaskStatus::NeedsReview => "status:needs-review",
219 TaskStatus::Merged => "status:merged",
220 TaskStatus::Dropped => "status:dropped",
221 }
222 }
223
224 pub fn from_label(label: &str) -> Option<Self> {
226 match label {
227 "status:queued" => Some(TaskStatus::Queued),
228 "status:running" => Some(TaskStatus::Running),
229 "status:blocked" => Some(TaskStatus::Blocked),
230 "status:needs-review" => Some(TaskStatus::NeedsReview),
231 "status:merged" => Some(TaskStatus::Merged),
232 "status:dropped" => Some(TaskStatus::Dropped),
233 _ => None,
234 }
235 }
236
237 pub fn all_labels() -> &'static [&'static str] {
239 &[
240 "status:queued",
241 "status:running",
242 "status:blocked",
243 "status:needs-review",
244 "status:merged",
245 "status:dropped",
246 ]
247 }
248}
249
250impl Default for TaskStatus {
251 fn default() -> Self {
252 TaskStatus::Queued
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
258#[serde(rename_all = "lowercase")]
259pub enum SessionStatus {
260 Spawned,
262 Ready,
264 Running,
266 Handoff,
268 Exit,
270}
271
272impl SessionStatus {
273 pub fn as_label(&self) -> &'static str {
275 match self {
276 SessionStatus::Spawned => "session:spawned",
277 SessionStatus::Ready => "session:ready",
278 SessionStatus::Running => "session:running",
279 SessionStatus::Handoff => "session:handoff",
280 SessionStatus::Exit => "session:exit",
281 }
282 }
283
284 pub fn from_label(label: &str) -> Option<Self> {
286 match label {
287 "session:spawned" => Some(SessionStatus::Spawned),
288 "session:ready" => Some(SessionStatus::Ready),
289 "session:running" => Some(SessionStatus::Running),
290 "session:handoff" => Some(SessionStatus::Handoff),
291 "session:exit" => Some(SessionStatus::Exit),
292 _ => None,
293 }
294 }
295
296 pub fn all_labels() -> &'static [&'static str] {
298 &[
299 "session:spawned",
300 "session:ready",
301 "session:running",
302 "session:handoff",
303 "session:exit",
304 ]
305 }
306}
307
308impl Default for SessionStatus {
309 fn default() -> Self {
310 SessionStatus::Spawned
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
316#[serde(rename_all = "lowercase")]
317pub enum SessionType {
318 Polecat,
320 Crew,
322}
323
324impl SessionType {
325 pub fn as_label(&self) -> &'static str {
327 match self {
328 SessionType::Polecat => "session:polecat",
329 SessionType::Crew => "session:crew",
330 }
331 }
332
333 pub fn from_label(label: &str) -> Option<Self> {
335 match label {
336 "session:polecat" => Some(SessionType::Polecat),
337 "session:crew" => Some(SessionType::Crew),
338 _ => None,
339 }
340 }
341
342 pub fn as_str(&self) -> &'static str {
344 match self {
345 SessionType::Polecat => "polecat",
346 SessionType::Crew => "crew",
347 }
348 }
349
350 pub fn from_str(s: &str) -> Option<Self> {
352 match s {
353 "polecat" => Some(SessionType::Polecat),
354 "crew" => Some(SessionType::Crew),
355 _ => None,
356 }
357 }
358}
359
360impl Default for SessionType {
361 fn default() -> Self {
362 SessionType::Polecat
363 }
364}
365
366impl std::fmt::Display for SessionType {
367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368 write!(f, "{}", self.as_str())
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
374#[serde(rename_all = "lowercase")]
375pub enum SessionRole {
376 Mayor,
378 Witness,
380 Refinery,
382 Deacon,
384 User,
386}
387
388impl SessionRole {
389 pub fn as_str(&self) -> &'static str {
391 match self {
392 SessionRole::Mayor => "mayor",
393 SessionRole::Witness => "witness",
394 SessionRole::Refinery => "refinery",
395 SessionRole::Deacon => "deacon",
396 SessionRole::User => "user",
397 }
398 }
399
400 pub fn from_str(s: &str) -> Option<Self> {
402 match s {
403 "mayor" => Some(SessionRole::Mayor),
404 "witness" => Some(SessionRole::Witness),
405 "refinery" => Some(SessionRole::Refinery),
406 "deacon" => Some(SessionRole::Deacon),
407 "user" => Some(SessionRole::User),
408 _ => None,
409 }
410 }
411}
412
413impl Default for SessionRole {
414 fn default() -> Self {
415 SessionRole::User
416 }
417}
418
419impl std::fmt::Display for SessionRole {
420 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421 write!(f, "{}", self.as_str())
422 }
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct Session {
431 pub session_id: String,
433
434 pub task_id: String,
436
437 pub grite_issue_id: String,
439
440 pub role: SessionRole,
442
443 pub session_type: SessionType,
445
446 pub engine: String,
448
449 #[serde(default)]
451 pub worktree: String,
452
453 pub pid: Option<u32>,
455
456 pub status: SessionStatus,
458
459 pub started_ts: i64,
461
462 pub last_heartbeat_ts: Option<i64>,
464
465 pub exit_code: Option<i32>,
467
468 pub exit_reason: Option<String>,
470
471 pub last_output_ref: Option<String>,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct Convoy {
478 pub convoy_id: String,
480
481 pub grite_issue_id: String,
483
484 pub title: String,
486
487 #[serde(default)]
489 pub body: String,
490
491 pub status: ConvoyStatus,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct Task {
498 pub task_id: String,
500
501 pub grite_issue_id: String,
503
504 pub convoy_id: String,
506
507 pub title: String,
509
510 #[serde(default)]
512 pub body: String,
513
514 pub status: TaskStatus,
516}
517
518impl Task {
519 pub fn parse_paths(&self) -> Vec<String> {
526 for line in self.body.lines() {
527 let trimmed = line.trim();
528 if let Some(paths_str) = trimmed.strip_prefix("Paths:") {
529 return paths_str
530 .split(',')
531 .map(|s| s.trim().to_string())
532 .filter(|s| !s.is_empty())
533 .collect();
534 }
535 }
536 Vec::new()
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn test_task_status_label_roundtrip() {
546 for status in [
547 TaskStatus::Queued,
548 TaskStatus::Running,
549 TaskStatus::Blocked,
550 TaskStatus::NeedsReview,
551 TaskStatus::Merged,
552 TaskStatus::Dropped,
553 ] {
554 let label = status.as_label();
555 let parsed = TaskStatus::from_label(label);
556 assert_eq!(parsed, Some(status));
557 }
558 }
559
560 #[test]
561 fn test_convoy_status_label_roundtrip() {
562 for status in [
563 ConvoyStatus::Active,
564 ConvoyStatus::Paused,
565 ConvoyStatus::Complete,
566 ConvoyStatus::Failed,
567 ] {
568 let label = status.as_label();
569 let parsed = ConvoyStatus::from_label(label);
570 assert_eq!(parsed, Some(status));
571 }
572 }
573
574 #[test]
575 fn test_invalid_label() {
576 assert_eq!(TaskStatus::from_label("invalid"), None);
577 assert_eq!(ConvoyStatus::from_label("status:unknown"), None);
578 }
579
580 #[test]
581 fn test_session_type_label_roundtrip() {
582 for session_type in [SessionType::Polecat, SessionType::Crew] {
583 let label = session_type.as_label();
584 let parsed = SessionType::from_label(label);
585 assert_eq!(parsed, Some(session_type));
586 }
587 }
588
589 #[test]
590 fn test_session_type_str_roundtrip() {
591 for session_type in [SessionType::Polecat, SessionType::Crew] {
592 let s = session_type.as_str();
593 let parsed = SessionType::from_str(s);
594 assert_eq!(parsed, Some(session_type));
595 }
596 }
597
598 #[test]
599 fn test_session_role_roundtrip() {
600 for role in [
601 SessionRole::Mayor,
602 SessionRole::Witness,
603 SessionRole::Refinery,
604 SessionRole::Deacon,
605 SessionRole::User,
606 ] {
607 let s = role.as_str();
608 let parsed = SessionRole::from_str(s);
609 assert_eq!(parsed, Some(role));
610 }
611 }
612
613 #[test]
614 fn test_session_status_label_roundtrip() {
615 for status in [
616 SessionStatus::Spawned,
617 SessionStatus::Ready,
618 SessionStatus::Running,
619 SessionStatus::Handoff,
620 SessionStatus::Exit,
621 ] {
622 let label = status.as_label();
623 let parsed = SessionStatus::from_label(label);
624 assert_eq!(parsed, Some(status));
625 }
626 }
627
628 #[test]
629 fn test_task_parse_paths() {
630 let task = Task {
631 task_id: "t-20250117-test".to_string(),
632 grite_issue_id: "issue-123".to_string(),
633 convoy_id: "c-20250117-test".to_string(),
634 title: "Test task".to_string(),
635 body: "Some description\n\nPaths: src/main.rs, src/lib.rs, tests/\n\nMore text".to_string(),
636 status: TaskStatus::Queued,
637 };
638
639 let paths = task.parse_paths();
640 assert_eq!(paths, vec!["src/main.rs", "src/lib.rs", "tests/"]);
641 }
642
643 #[test]
644 fn test_task_parse_paths_empty() {
645 let task = Task {
646 task_id: "t-20250117-test".to_string(),
647 grite_issue_id: "issue-123".to_string(),
648 convoy_id: "c-20250117-test".to_string(),
649 title: "Test task".to_string(),
650 body: "Some description without paths".to_string(),
651 status: TaskStatus::Queued,
652 };
653
654 let paths = task.parse_paths();
655 assert!(paths.is_empty());
656 }
657
658 #[test]
659 fn test_task_parse_paths_single() {
660 let task = Task {
661 task_id: "t-20250117-test".to_string(),
662 grite_issue_id: "issue-123".to_string(),
663 convoy_id: "c-20250117-test".to_string(),
664 title: "Test task".to_string(),
665 body: "Paths: src/single.rs".to_string(),
666 status: TaskStatus::Queued,
667 };
668
669 let paths = task.parse_paths();
670 assert_eq!(paths, vec!["src/single.rs"]);
671 }
672
673 #[test]
674 fn test_dependency_type_roundtrip() {
675 for dep_type in [
676 DependencyType::Blocks,
677 DependencyType::DependsOn,
678 DependencyType::RelatedTo,
679 ] {
680 let s = dep_type.as_str();
681 let parsed = DependencyType::from_str(s);
682 assert_eq!(parsed, Some(dep_type));
683 }
684 }
685
686 #[test]
687 fn test_dependency_type_invalid() {
688 assert_eq!(DependencyType::from_str("invalid"), None);
689 assert_eq!(DependencyType::from_str(""), None);
690 }
691}