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