1use std::path::Path;
14
15use crate::git::{Git, GitError};
16use crate::worktree::{self, Checkpoint};
17
18pub const PREFIX: &str = "refs/speccoder/attempts";
20
21const FS: char = '\u{1f}';
23const RS: char = '\u{1e}';
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Saved {
28 pub task: String,
30 pub outcome: String,
32 pub reason: String,
34 pub run_id: Option<String>,
36 pub pending: Option<serde_json::Value>,
38 pub usage: Option<serde_json::Value>,
40 pub reference: String,
42 pub commit: String,
44}
45
46pub(crate) const IDENTITY: [&str; 6] = [
47 "-c",
48 "user.name=speccoder",
49 "-c",
50 "user.email=speccoder@users.noreply.invalid",
51 "-c",
52 "commit.gpgsign=false",
53];
54
55#[derive(Debug, Clone, Copy)]
57pub struct Record<'a> {
58 pub run: Option<&'a str>,
60 pub outcome: &'a str,
62 pub reason: &'a str,
64 pub pending: Option<&'a serde_json::Value>,
66 pub usage: Option<&'a serde_json::Value>,
68}
69
70#[must_use]
72pub fn usage_trailer(usage: Option<&serde_json::Value>) -> String {
73 let Some(u) = usage.filter(|u| u.is_object()) else {
74 return String::new();
75 };
76 let n = |k: &str| u[k].as_u64().unwrap_or(0);
77 let usd = u["cost_usd"]
79 .as_f64()
80 .map(|c| format!(" usd={c}"))
81 .unwrap_or_default();
82 format!(
83 "Usage: input={} output={} cache_read={} cache_write={}{usd}\n",
84 n("input_tokens"),
85 n("output_tokens"),
86 n("cache_read_tokens"),
87 n("cache_write_tokens")
88 )
89}
90
91#[must_use]
93pub fn parse_usage(value: &str) -> Option<serde_json::Value> {
94 let mut out = serde_json::Map::new();
95 for part in value.split_whitespace() {
96 let (k, v) = part.split_once('=')?;
97 let key = match k {
98 "input" => "input_tokens",
99 "output" => "output_tokens",
100 "cache_read" => "cache_read_tokens",
101 "cache_write" => "cache_write_tokens",
102 "usd" => {
103 out.insert(
104 "cost_usd".to_owned(),
105 serde_json::json!(v.parse::<f64>().ok()?),
106 );
107 continue;
108 }
109 _ => continue,
110 };
111 out.insert(key.to_owned(), serde_json::json!(v.parse::<u64>().ok()?));
112 }
113 (!out.is_empty()).then_some(serde_json::Value::Object(out))
114}
115
116pub fn save(
118 dir: &Path,
119 tmp: &Path,
120 feature: &str,
121 task: &str,
122 record: &Record<'_>,
123) -> Result<Saved, GitError> {
124 let Record {
125 run,
126 outcome,
127 reason,
128 pending,
129 usage,
130 } = *record;
131 let git = Git::at(dir);
132 let tree = worktree::checkpoint(dir, tmp)?;
133 let head = git.line(&["rev-parse", "--verify", "HEAD^{commit}"])?;
134 let (tree, left_out) = without_secrets(&git, tmp, &head, tree)?;
138 let reason = if left_out.is_empty() {
139 reason.to_owned()
140 } else {
141 format!(
142 "{reason} ({} left out: it held a value copied from your checkout)",
143 left_out.join(", ")
144 )
145 };
146 let reason = reason.as_str();
147 let message = format!(
148 "speccoder attempt: {task} {outcome}\n\n{reason}\n\nSpec-Task: {feature}/{task}\nOutcome: {outcome}\n{}{}{}",
149 run.map(|r| format!("Run: {r}\n")).unwrap_or_default(),
150 usage_trailer(usage),
151 pending
153 .map(|p| format!("Pending: {p}\n"))
154 .unwrap_or_default()
155 );
156 let mut args = IDENTITY.to_vec();
157 args.extend(["commit-tree", &tree.0, "-p", &head, "-m", &message]);
158 let commit = git.line(&args)?;
159 let name = run
160 .filter(|r| {
161 r.chars()
162 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
163 })
164 .map_or_else(
165 || format!("{outcome}-{}", commit.get(..12).unwrap_or(&commit)),
166 str::to_owned,
167 );
168 let reference = format!("{PREFIX}/{feature}/{task}/{name}");
169 git.run(&["update-ref", &reference, &commit])?;
170 Ok(Saved {
171 task: task.to_owned(),
172 outcome: outcome.to_owned(),
173 reason: reason.to_owned(),
174 run_id: run.map(str::to_owned),
175 pending: pending.cloned(),
176 usage: usage.cloned(),
177 reference,
178 commit,
179 })
180}
181
182fn without_secrets(
185 git: &Git,
186 tmp: &Path,
187 head: &str,
188 tree: Checkpoint,
189) -> Result<(Checkpoint, Vec<String>), GitError> {
190 let changed = crate::git::nul_fields(&git.run(&[
191 "diff",
192 "--name-only",
193 "-z",
194 "--no-renames",
195 head,
196 &tree.0,
197 ])?);
198 let held: Vec<String> = changed
199 .into_iter()
200 .filter(|f| {
201 git.run(&["diff", "-U0", "--no-color", head, &tree.0, "--", f])
202 .is_ok_and(|p| crate::include::added_secret(&String::from_utf8_lossy(&p)).is_some())
203 })
204 .collect();
205 if held.is_empty() {
206 return Ok((tree, held));
207 }
208 let index = tmp.join(format!("attempt-index-{}", std::process::id()));
209 let _ = std::fs::create_dir_all(tmp);
210 git.run_with_index(&index, &["read-tree", &tree.0], None)?;
211 let mut rm = vec!["update-index", "--force-remove", "--"];
212 rm.extend(held.iter().map(String::as_str));
213 git.run_with_index(&index, &rm, None)?;
214 let out = git.run_with_index(&index, &["write-tree"], None);
215 let _ = std::fs::remove_file(&index);
216 let kept = String::from_utf8_lossy(&out?).trim().to_owned();
217 Ok((Checkpoint(kept), held))
218}
219
220pub fn restore_head(dir: &Path, tmp: &Path) -> Result<(), GitError> {
222 let tree = Git::at(dir).line(&["rev-parse", "HEAD^{tree}"])?;
223 worktree::restore(dir, tmp, &Checkpoint(tree)).map(|_| ())
224}
225
226pub fn list(repo: &Path, feature: &str) -> Vec<Saved> {
228 let format = format!(
229 "--format=%(refname){FS}%(objectname){FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Outcome,valueonly){FS}%(trailers:key=Run,valueonly){FS}%(trailers:key=Pending,valueonly){FS}%(trailers:key=Usage,valueonly){FS}%(contents:body){RS}"
230 );
231 let out = Git::at(repo)
232 .run(&[
233 "for-each-ref",
234 "--sort=-committerdate",
235 &format,
236 &format!("{PREFIX}/{feature}/"),
237 ])
238 .map(|b| String::from_utf8_lossy(&b).into_owned())
239 .unwrap_or_default();
240 out.split(RS)
241 .filter_map(|record| {
242 let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
243 let [reference, commit, task, outcome, run, pending, usage, body] = f[..] else {
244 return None;
245 };
246 let task = task.trim().strip_prefix(&format!("{feature}/"))?.to_owned();
247 let reason = body
249 .split("\n\nSpec-Task:")
250 .next()
251 .unwrap_or_default()
252 .trim()
253 .to_owned();
254 let run = run.trim();
255 Some(Saved {
256 task,
257 outcome: outcome.trim().to_owned(),
258 reason,
259 run_id: (!run.is_empty()).then(|| run.to_owned()),
260 pending: serde_json::from_str(pending.trim()).ok(),
261 usage: parse_usage(usage.trim()),
262 reference: reference.to_owned(),
263 commit: commit.to_owned(),
264 })
265 })
266 .collect()
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn a_file_holding_a_copied_value_is_left_out_of_a_saved_attempt() {
275 let dir = tempfile::tempdir().unwrap();
276 let r = dir.path();
277 let git = |args: &[&str]| {
278 assert!(
279 std::process::Command::new("git")
280 .args([
281 "-c",
282 "user.name=t",
283 "-c",
284 "user.email=t@e",
285 "-c",
286 "commit.gpgsign=false"
287 ])
288 .args(args)
289 .current_dir(r)
290 .status()
291 .unwrap()
292 .success()
293 );
294 };
295 std::fs::write(r.join("a.txt"), "a\n").unwrap();
296 git(&["init", "-q"]);
297 git(&["add", "-A"]);
298 git(&["commit", "-qm", "init"]);
299 crate::secrets::register("SNAP_TOKEN", "attempt-snap-Vb3 value");
300 std::fs::write(r.join("a.txt"), "a\nb\n").unwrap();
301 std::fs::write(r.join("snap.txt"), "token: attempt-snap-Vb3 value\n").unwrap();
302 let tmp = tempfile::tempdir().unwrap();
303 let saved = save(
304 r,
305 tmp.path(),
306 "001-x",
307 "T001",
308 &Record {
309 run: None,
310 outcome: "failed",
311 reason: "red",
312 pending: None,
313 usage: None,
314 },
315 )
316 .unwrap();
317 let g = Git::at(r);
318 let files = g
319 .run(&["ls-tree", "-r", "--name-only", &saved.commit])
320 .unwrap();
321 assert_eq!(String::from_utf8_lossy(&files), "a.txt\n");
322 assert!(
323 saved.reason.contains("snap.txt left out"),
324 "{}",
325 saved.reason
326 );
327 }
328
329 #[test]
330 fn usage_trailers_round_trip_with_a_reported_cost() {
331 let u = serde_json::json!({ "input_tokens": 100, "output_tokens": 5, "cache_read_tokens": 90, "cache_write_tokens": 3, "cost_usd": 0.0125 });
332 let t = usage_trailer(Some(&u));
333 assert_eq!(
334 t,
335 "Usage: input=100 output=5 cache_read=90 cache_write=3 usd=0.0125\n"
336 );
337 let back = parse_usage(t.trim().trim_start_matches("Usage: ")).unwrap_or_default();
338 assert_eq!(back, u);
339 let native = parse_usage("input=1 output=2 cache_read=0 cache_write=0").unwrap_or_default();
340 assert!(native.get("cost_usd").is_none());
341 }
342}