pr_01m47d24b0e6n91zwymwxg0vpx/crates/runner/src/report.rs

146 lines4,580 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 .filter(|secret| !secret.is_empty())
71 .collect();
72 Ok(Reporter {
73 api: var("G1T_API")?,
74 token,
75 attempt: var("ATTEMPT_ID")?,
76 secrets,
77 pending: Vec::new(),
78 last_flush: Instant::now(),
79 })
80 }
81
82 fn post(&self, action: &str, body: serde_json::Value) -> Result<()> {
83 ureq::post(&format!(
84 "{}/v1/attempts/{}/{action}",
85 self.api, self.attempt
86 ))
87 .set("authorization", &format!("Bearer {}", self.token))
88 .send_json(body)
89 .with_context(|| format!("{action} request failed"))?;
90 Ok(())
91 }
92
93 /// Queues an entry, sending the batch if it is due.
94 pub fn record(&mut self, mut entry: Entry) {
95 entry.text = redact(&entry.text, &self.secrets);
96 self.pending.push(entry);
97 if self.pending.len() >= FLUSH_SIZE || self.last_flush.elapsed() >= FLUSH_INTERVAL {
98 self.flush();
99 }
100 }
101
102 /// Sends everything queued. A failed send is logged and the entries
103 /// kept, so one bad request does not lose the session or stop the run.
104 pub fn flush(&mut self) {
105 self.last_flush = Instant::now();
106 if self.pending.is_empty() {
107 return;
108 }
109 let body = serde_json::json!({ "entries": self.pending });
110 match self.post("session", body) {
111 Ok(()) => self.pending.clear(),
112 Err(error) => eprintln!("g1t-runner: {error:#}"),
113 }
114 }
115
116 pub fn submit(&self, summary: &str) -> Result<()> {
117 self.post("submit", serde_json::json!({ "summary": summary }))
118 }
119
120 pub fn abandon(&self) -> Result<()> {
121 self.post("abandon", serde_json::json!({}))
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn secrets_are_removed_from_entries() {
131 let secrets = vec!["g1t_abc123".to_owned(), "sk-ant-xyz".to_owned()];
132 let text = "origin https://me:g1t_abc123@g1t.sh/a.git
133KEY=sk-ant-xyz g1t_abc123";
134 let clean = redact(text, &secrets);
135 assert!(!clean.contains("g1t_abc123"));
136 assert!(!clean.contains("sk-ant-xyz"));
137 assert_eq!(clean.matches("[redacted]").count(), 3);
138 }
139
140 #[test]
141 fn long_entries_are_truncated() {
142 let entry = Entry::new("note", &"x".repeat(MAX_ENTRY_CHARS + 10));
143 assert!(entry.text.ends_with("(truncated)"));
144 assert!(Entry::new("note", "short").text == "short");
145 }
146}