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