1use chrono::{DateTime, NaiveDate, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct InvocationRecord {
10 pub id: Uuid,
12
13 pub session_id: String,
15
16 pub timestamp: DateTime<Utc>,
18
19 pub duration_ms: Option<i64>,
21
22 pub cwd: String,
24
25 pub cmd: String,
27
28 pub executable: Option<String>,
30
31 pub runner_id: Option<String>,
37
38 pub exit_code: Option<i32>,
40
41 pub status: String,
43
44 pub format_hint: Option<String>,
46
47 pub client_id: String,
49
50 pub hostname: Option<String>,
52
53 pub username: Option<String>,
55
56 pub tag: Option<String>,
58}
59
60pub const BIRD_INVOCATION_UUID_VAR: &str = "BIRD_INVOCATION_UUID";
66
67pub const BIRD_PARENT_CLIENT_VAR: &str = "BIRD_PARENT_CLIENT";
72
73impl InvocationRecord {
74 pub fn new(
79 session_id: impl Into<String>,
80 cmd: impl Into<String>,
81 cwd: impl Into<String>,
82 exit_code: i32,
83 client_id: impl Into<String>,
84 ) -> Self {
85 let cmd = cmd.into();
86
87 let id = if let Ok(uuid_str) = std::env::var(BIRD_INVOCATION_UUID_VAR) {
89 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| Uuid::now_v7())
90 } else {
91 Uuid::now_v7()
92 };
93
94 Self {
95 id,
96 session_id: session_id.into(),
97 timestamp: Utc::now(),
98 duration_ms: None,
99 cwd: cwd.into(),
100 executable: extract_executable(&cmd),
101 cmd,
102 runner_id: None,
103 exit_code: Some(exit_code),
104 status: "completed".to_string(),
105 format_hint: None,
106 client_id: client_id.into(),
107 hostname: gethostname::gethostname().to_str().map(|s| s.to_string()),
108 username: std::env::var("USER").ok(),
109 tag: None,
110 }
111 }
112
113 pub fn with_id(
118 id: Uuid,
119 session_id: impl Into<String>,
120 cmd: impl Into<String>,
121 cwd: impl Into<String>,
122 exit_code: i32,
123 client_id: impl Into<String>,
124 ) -> Self {
125 let cmd = cmd.into();
126 Self {
127 id,
128 session_id: session_id.into(),
129 timestamp: Utc::now(),
130 duration_ms: None,
131 cwd: cwd.into(),
132 executable: extract_executable(&cmd),
133 cmd,
134 runner_id: None,
135 exit_code: Some(exit_code),
136 status: "completed".to_string(),
137 format_hint: None,
138 client_id: client_id.into(),
139 hostname: gethostname::gethostname().to_str().map(|s| s.to_string()),
140 username: std::env::var("USER").ok(),
141 tag: None,
142 }
143 }
144
145 pub fn new_pending(
155 session_id: impl Into<String>,
156 cmd: impl Into<String>,
157 cwd: impl Into<String>,
158 runner_id: impl Into<String>,
159 client_id: impl Into<String>,
160 ) -> Self {
161 let cmd = cmd.into();
162
163 let id = if let Ok(uuid_str) = std::env::var(BIRD_INVOCATION_UUID_VAR) {
165 Uuid::parse_str(&uuid_str).unwrap_or_else(|_| Uuid::now_v7())
166 } else {
167 Uuid::now_v7()
168 };
169
170 Self {
171 id,
172 session_id: session_id.into(),
173 timestamp: Utc::now(),
174 duration_ms: None,
175 cwd: cwd.into(),
176 executable: extract_executable(&cmd),
177 cmd,
178 runner_id: Some(runner_id.into()),
179 exit_code: None,
180 status: "pending".to_string(),
181 format_hint: None,
182 client_id: client_id.into(),
183 hostname: gethostname::gethostname().to_str().map(|s| s.to_string()),
184 username: std::env::var("USER").ok(),
185 tag: None,
186 }
187 }
188
189 pub fn new_pending_local(
193 session_id: impl Into<String>,
194 cmd: impl Into<String>,
195 cwd: impl Into<String>,
196 pid: i32,
197 client_id: impl Into<String>,
198 ) -> Self {
199 Self::new_pending(session_id, cmd, cwd, format!("pid:{}", pid), client_id)
200 }
201
202 pub fn complete(mut self, exit_code: i32, duration_ms: Option<i64>) -> Self {
204 self.exit_code = Some(exit_code);
205 self.duration_ms = duration_ms;
206 self.status = "completed".to_string();
207 self
208 }
209
210 pub fn mark_orphaned(mut self) -> Self {
212 self.status = "orphaned".to_string();
213 self
214 }
215
216 pub fn with_runner_id(mut self, runner_id: impl Into<String>) -> Self {
218 self.runner_id = Some(runner_id.into());
219 self
220 }
221
222 pub fn is_inherited() -> bool {
224 std::env::var(BIRD_INVOCATION_UUID_VAR).is_ok()
225 }
226
227 pub fn parent_client() -> Option<String> {
229 std::env::var(BIRD_PARENT_CLIENT_VAR).ok()
230 }
231
232 pub fn with_duration(mut self, duration_ms: i64) -> Self {
234 self.duration_ms = Some(duration_ms);
235 self
236 }
237
238 pub fn with_format_hint(mut self, hint: impl Into<String>) -> Self {
240 self.format_hint = Some(hint.into());
241 self
242 }
243
244 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
246 self.tag = Some(tag.into());
247 self
248 }
249
250 pub fn date(&self) -> NaiveDate {
252 self.timestamp.date_naive()
253 }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct SessionRecord {
259 pub session_id: String,
261
262 pub client_id: String,
264
265 pub invoker: String,
267
268 pub invoker_pid: u32,
270
271 pub invoker_type: String,
273
274 pub registered_at: DateTime<Utc>,
276
277 pub cwd: Option<String>,
279
280 pub date: NaiveDate,
282}
283
284impl SessionRecord {
285 pub fn new(
287 session_id: impl Into<String>,
288 client_id: impl Into<String>,
289 invoker: impl Into<String>,
290 invoker_pid: u32,
291 invoker_type: impl Into<String>,
292 ) -> Self {
293 let now = Utc::now();
294 Self {
295 session_id: session_id.into(),
296 client_id: client_id.into(),
297 invoker: invoker.into(),
298 invoker_pid,
299 invoker_type: invoker_type.into(),
300 registered_at: now,
301 cwd: std::env::current_dir()
302 .ok()
303 .map(|p| p.display().to_string()),
304 date: now.date_naive(),
305 }
306 }
307}
308
309fn extract_executable(cmd: &str) -> Option<String> {
311 let cmd = cmd.trim();
312
313 let mut parts = cmd.split_whitespace();
315 for part in parts.by_ref() {
316 if !part.contains('=') {
317 let exe = part.rsplit('/').next().unwrap_or(part);
320 return Some(exe.to_string());
321 }
322 }
323
324 None
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct OutputRecord {
330 pub id: Uuid,
332
333 pub invocation_id: Uuid,
335
336 pub stream: String,
338
339 pub content_hash: String,
341
342 pub byte_length: usize,
344
345 pub storage_type: String,
347
348 pub storage_ref: String,
350
351 pub content_type: Option<String>,
353
354 pub date: NaiveDate,
356}
357
358impl OutputRecord {
359 pub fn new_inline(
363 invocation_id: Uuid,
364 stream: impl Into<String>,
365 content: &[u8],
366 date: NaiveDate,
367 ) -> Self {
368 use base64::Engine;
369
370 let content_hash = blake3::hash(content).to_hex().to_string();
371 let byte_length = content.len();
372
373 let b64 = base64::engine::general_purpose::STANDARD.encode(content);
375 let storage_ref = format!("data:application/octet-stream;base64,{}", b64);
376
377 Self {
378 id: Uuid::now_v7(),
379 invocation_id,
380 stream: stream.into(),
381 content_hash,
382 byte_length,
383 storage_type: "inline".to_string(),
384 storage_ref,
385 content_type: Some("text/plain".to_string()),
386 date,
387 }
388 }
389
390 pub fn decode_content(&self) -> Option<Vec<u8>> {
392 use base64::Engine;
393
394 if self.storage_type == "inline" {
395 if let Some(b64_part) = self.storage_ref.split(",").nth(1) {
397 base64::engine::general_purpose::STANDARD.decode(b64_part).ok()
398 } else {
399 None
400 }
401 } else {
402 None
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct EventRecord {
411 pub id: Uuid,
413
414 pub invocation_id: Uuid,
416
417 pub client_id: String,
419
420 pub hostname: Option<String>,
422
423 pub event_type: Option<String>,
425
426 pub severity: Option<String>,
428
429 pub ref_file: Option<String>,
431
432 pub ref_line: Option<i32>,
434
435 pub ref_column: Option<i32>,
437
438 pub message: Option<String>,
440
441 pub error_code: Option<String>,
443
444 pub test_name: Option<String>,
446
447 pub status: Option<String>,
449
450 pub format_used: String,
452
453 pub date: NaiveDate,
455}
456
457impl EventRecord {
458 pub fn new(
460 invocation_id: Uuid,
461 client_id: impl Into<String>,
462 format_used: impl Into<String>,
463 date: NaiveDate,
464 ) -> Self {
465 Self {
466 id: Uuid::now_v7(),
467 invocation_id,
468 client_id: client_id.into(),
469 hostname: gethostname::gethostname().to_str().map(|s| s.to_string()),
470 event_type: None,
471 severity: None,
472 ref_file: None,
473 ref_line: None,
474 ref_column: None,
475 message: None,
476 error_code: None,
477 test_name: None,
478 status: None,
479 format_used: format_used.into(),
480 date,
481 }
482 }
483}
484
485pub const EVENTS_SCHEMA: &str = r#"
487CREATE TABLE events (
488 id UUID PRIMARY KEY,
489 invocation_id UUID NOT NULL,
490 client_id VARCHAR NOT NULL,
491 hostname VARCHAR,
492 event_type VARCHAR,
493 severity VARCHAR,
494 ref_file VARCHAR,
495 ref_line INTEGER,
496 ref_column INTEGER,
497 message VARCHAR,
498 error_code VARCHAR,
499 test_name VARCHAR,
500 status VARCHAR,
501 format_used VARCHAR NOT NULL,
502 date DATE NOT NULL
503);
504"#;
505
506pub const INVOCATIONS_SCHEMA: &str = r#"
508CREATE TABLE invocations (
509 id UUID PRIMARY KEY,
510 session_id VARCHAR NOT NULL,
511 timestamp TIMESTAMP NOT NULL,
512 duration_ms BIGINT,
513 cwd VARCHAR NOT NULL,
514 cmd VARCHAR NOT NULL,
515 executable VARCHAR,
516 runner_id VARCHAR,
517 exit_code INTEGER,
518 status VARCHAR DEFAULT 'completed',
519 format_hint VARCHAR,
520 client_id VARCHAR NOT NULL,
521 hostname VARCHAR,
522 username VARCHAR,
523 tag VARCHAR,
524 date DATE NOT NULL
525);
526"#;
527
528pub const SESSIONS_SCHEMA: &str = r#"
530CREATE TABLE sessions (
531 session_id VARCHAR PRIMARY KEY,
532 client_id VARCHAR NOT NULL,
533 invoker VARCHAR NOT NULL,
534 invoker_pid INTEGER NOT NULL,
535 invoker_type VARCHAR NOT NULL,
536 registered_at TIMESTAMP NOT NULL,
537 cwd VARCHAR,
538 date DATE NOT NULL
539);
540"#;
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn test_extract_executable() {
548 assert_eq!(extract_executable("make test"), Some("make".to_string()));
549 assert_eq!(extract_executable("/usr/bin/gcc -o foo foo.c"), Some("gcc".to_string()));
550 assert_eq!(extract_executable("ENV=val make"), Some("make".to_string()));
551 assert_eq!(extract_executable("CC=gcc CXX=g++ make"), Some("make".to_string()));
552 assert_eq!(extract_executable(""), None);
553 }
554
555 #[test]
556 fn test_invocation_record_new() {
557 let record = InvocationRecord::new(
558 "session-123",
559 "make test",
560 "/home/user/project",
561 0,
562 "user@laptop",
563 );
564
565 assert_eq!(record.session_id, "session-123");
566 assert_eq!(record.cmd, "make test");
567 assert_eq!(record.executable, Some("make".to_string()));
568 assert_eq!(record.exit_code, Some(0));
569 assert_eq!(record.status, "completed");
570 assert!(record.duration_ms.is_none());
571 assert!(record.runner_id.is_none());
572 }
573
574 #[test]
575 fn test_invocation_record_pending() {
576 let record = InvocationRecord::new_pending(
577 "session-123",
578 "make test",
579 "/home/user/project",
580 "pid:12345",
581 "user@laptop",
582 );
583
584 assert_eq!(record.session_id, "session-123");
585 assert_eq!(record.cmd, "make test");
586 assert_eq!(record.runner_id, Some("pid:12345".to_string()));
587 assert_eq!(record.exit_code, None);
588 assert_eq!(record.status, "pending");
589 }
590
591 #[test]
592 fn test_invocation_record_pending_local() {
593 let record = InvocationRecord::new_pending_local(
594 "session-123",
595 "make test",
596 "/home/user/project",
597 12345,
598 "user@laptop",
599 );
600
601 assert_eq!(record.runner_id, Some("pid:12345".to_string()));
602 assert_eq!(record.status, "pending");
603 }
604
605 #[test]
606 fn test_invocation_record_pending_gha() {
607 let record = InvocationRecord::new_pending(
608 "gha-session",
609 "make test",
610 "/github/workspace",
611 "gha:run:123456789",
612 "runner@github",
613 );
614
615 assert_eq!(record.runner_id, Some("gha:run:123456789".to_string()));
616 assert_eq!(record.status, "pending");
617 }
618
619 #[test]
620 fn test_invocation_record_complete() {
621 let record = InvocationRecord::new_pending(
622 "session-123",
623 "make test",
624 "/home/user/project",
625 "pid:12345",
626 "user@laptop",
627 )
628 .complete(0, Some(1500));
629
630 assert_eq!(record.exit_code, Some(0));
631 assert_eq!(record.duration_ms, Some(1500));
632 assert_eq!(record.status, "completed");
633 }
634
635 #[test]
636 fn test_invocation_record_orphaned() {
637 let record = InvocationRecord::new_pending(
638 "session-123",
639 "make test",
640 "/home/user/project",
641 "pid:12345",
642 "user@laptop",
643 )
644 .mark_orphaned();
645
646 assert_eq!(record.exit_code, None);
647 assert_eq!(record.status, "orphaned");
648 }
649
650 #[test]
651 fn test_invocation_record_with_duration() {
652 let record = InvocationRecord::new(
653 "session-123",
654 "make test",
655 "/home/user/project",
656 0,
657 "user@laptop",
658 )
659 .with_duration(1500);
660
661 assert_eq!(record.duration_ms, Some(1500));
662 }
663
664 #[test]
665 fn test_session_record_new() {
666 let record = SessionRecord::new(
667 "zsh-12345",
668 "user@laptop",
669 "zsh",
670 12345,
671 "shell",
672 );
673
674 assert_eq!(record.session_id, "zsh-12345");
675 assert_eq!(record.client_id, "user@laptop");
676 assert_eq!(record.invoker, "zsh");
677 assert_eq!(record.invoker_pid, 12345);
678 assert_eq!(record.invoker_type, "shell");
679 }
680}