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