g1t/crates/runner/src/report.rs

147 lines4,634 bytesCodeBlame
1//! Reports an attempt's progress to g1t through its public API.
2
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result};
6use serde::Serialize;
7
8/// Entries are sent in batches at most this often.
9const FLUSH_INTERVAL: Duration = Duration::from_millis(1500);
10const FLUSH_SIZE: usize = 50;
11/// Tool output can be enormous; the session keeps the start of it.
12const MAX_ENTRY_CHARS: usize = 8000;
13
14/// One step of the session, as the API accepts it.
15#[derive(Debug, Serialize)]
16pub struct Entry {
17 kind: &'static str,
18 text: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 tool: Option<String>,
21}
22
23impl Entry {
24 pub fn new(kind: &'static str, text: &str) -> Self {
25 let mut chars = text.chars();
26 let mut text: String = chars.by_ref().take(MAX_ENTRY_CHARS).collect();
27 if chars.next().is_some() {
28 text.push_str("\n… (truncated)");
29 }
30 Entry {
31 kind,
32 text,
33 tool: None,
34 }
35 }
36
37 pub fn tool(kind: &'static str, tool: &str, text: &str) -> Self {
38 Entry {
39 tool: Some(tool.to_owned()),
40 ..Entry::new(kind, text)
41 }
42 }
43}
44
45/// Replaces every occurrence of a secret with a marker. An agent can print
46/// its environment or a git remote; whatever it prints is recorded.
47fn redact(text: &str, secrets: &[String]) -> String {
48 secrets.iter().fold(text.to_owned(), |text, secret| {
49 text.replace(secret, "[redacted]")
50 })
51}
52
53pub struct Reporter {
54 api: String,
55 token: String,
56 attempt: String,
57 /// Values that must never reach a session, which is as public as the
58 /// repository: the credentials this process was started with.
59 secrets: Vec<String>,
60 pending: Vec<Entry>,
61 last_flush: Instant,
62}
63
64impl Reporter {
65 pub fn from_env() -> Result<Self> {
66 let var = |name: &str| std::env::var(name).with_context(|| format!("{name} is not set"));
67 let token = var("G1T_TOKEN")?;
68 let secrets = std::iter::once(token.clone())
69 .chain(std::env::var("ANTHROPIC_API_KEY"))
70 .chain(std::env::var("AI_GATEWAY_TOKEN"))
71 .filter(|secret| !secret.is_empty())
72 .collect();
73 Ok(Reporter {
74 api: var("G1T_API")?,
75 token,
76 attempt: var("ATTEMPT_ID")?,
77 secrets,
78 pending: Vec::new(),
79 last_flush: Instant::now(),
80 })
81 }
82
83 fn post(&self, action: &str, body: serde_json::Value) -> Result<()> {
84 ureq::post(&format!(
85 "{}/v1/attempts/{}/{action}",
86 self.api, self.attempt
87 ))
88 .set("authorization", &format!("Bearer {}", self.token))
89 .send_json(body)
90 .with_context(|| format!("{action} request failed"))?;
91 Ok(())
92 }
93
94 /// Queues an entry, sending the batch if it is due.
95 pub fn record(&mut self, mut entry: Entry) {
96 entry.text = redact(&entry.text, &self.secrets);
97 self.pending.push(entry);
98 if self.pending.len() >= FLUSH_SIZE || self.last_flush.elapsed() >= FLUSH_INTERVAL {
99 self.flush();
100 }
101 }
102
103 /// Sends everything queued. A failed send is logged and the entries
104 /// kept, so one bad request does not lose the session or stop the run.
105 pub fn flush(&mut self) {
106 self.last_flush = Instant::now();
107 if self.pending.is_empty() {
108 return;
109 }
110 let body = serde_json::json!({ "entries": self.pending });
111 match self.post("session", body) {
112 Ok(()) => self.pending.clear(),
113 Err(error) => eprintln!("g1t-runner: {error:#}"),
114 }
115 }
116
117 pub fn submit(&self, summary: &str) -> Result<()> {
118 self.post("submit", serde_json::json!({ "summary": summary }))
119 }
120
121 pub fn abandon(&self) -> Result<()> {
122 self.post("abandon", serde_json::json!({}))
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn secrets_are_removed_from_entries() {
132 let secrets = vec!["g1t_abc123".to_owned(), "sk-ant-xyz".to_owned()];
133 let text = "origin https://me:g1t_abc123@g1t.sh/a.git
134KEY=sk-ant-xyz g1t_abc123";
135 let clean = redact(text, &secrets);
136 assert!(!clean.contains("g1t_abc123"));
137 assert!(!clean.contains("sk-ant-xyz"));
138 assert_eq!(clean.matches("[redacted]").count(), 3);
139 }
140
141 #[test]
142 fn long_entries_are_truncated() {
143 let entry = Entry::new("note", &"x".repeat(MAX_ENTRY_CHARS + 10));
144 assert!(entry.text.ends_with("(truncated)"));
145 assert!(Entry::new("note", "short").text == "short");
146 }
147}