1use std::path::Path;
15
16use serde::Serialize;
17
18use crate::config::AgentKind;
19use crate::run::{GraftAdoption, GraftRejection, GraftStrength, RunState};
20use crate::verdict::{self, FixReport};
21
22const MAX_ARTIFACT_BYTES: u64 = 4 * 1024 * 1024;
24const MAX_PROSE: usize = 20_000;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28pub struct Unavailable {
29 pub what: &'static str,
31 pub reason: String,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct Recovered {
38 pub requested: Vec<GraftStrength>,
40 pub adoption: Option<GraftAdoption>,
42 pub unavailable: Vec<Unavailable>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Standing {
49 Adopted,
51 Rejected(String),
53 Unreported,
55}
56
57fn clip(s: &str) -> String {
58 s.chars().take(MAX_PROSE).collect()
59}
60
61pub fn adoption_of(text: &str) -> (Option<GraftAdoption>, Option<String>) {
64 match verdict::extract_json::<FixReport>(text) {
65 Ok(r) => {
66 let reasoning = text
67 .rfind("```json")
68 .map_or(text, |i| &text[..i])
69 .trim()
70 .to_owned();
71 (
72 Some(GraftAdoption {
73 addressed: r.addressed,
74 rejected: r
75 .rejected
76 .into_iter()
77 .map(|x| GraftRejection {
78 id: x.id,
79 why: x.why,
80 })
81 .collect(),
82 notes: clip(&r.notes),
83 reasoning: clip(&reasoning),
84 }),
85 None,
86 )
87 }
88 Err(e) => {
89 let why = e.to_string();
90 let first: String = why.lines().next().unwrap_or("").chars().take(200).collect();
91 (None, Some(format!("no usable report: {first}")))
92 }
93 }
94}
95
96pub fn parse_prompt(prompt: &str) -> Vec<GraftStrength> {
99 let mut out = Vec::new();
100 let mut label: Option<char> = None;
101 let mut n = 0;
102 for line in prompt.lines() {
103 if let Some(rest) = line.strip_prefix("## From candidate ") {
104 label = rest.chars().next();
105 n = 0;
106 } else if line.starts_with("# ") && !line.starts_with("# Strengths") {
107 label = None;
108 } else if let (Some(l), Some(item)) = (label, line.strip_prefix("- ")) {
109 n += 1;
110 let tagged = item
111 .strip_prefix('[')
112 .and_then(|r| r.split_once("] "))
113 .filter(|(id, _)| !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()));
114 let (id, text) = match tagged {
115 Some((id, text)) => (id.to_owned(), text),
116 None => (format!("{l}{n}"), item),
117 };
118 out.push(GraftStrength {
119 id,
120 source: l,
121 text: text.trim().to_owned(),
122 });
123 }
124 }
125 out
126}
127
128fn squash(s: &str) -> String {
129 s.chars()
130 .filter(|c| c.is_alphanumeric())
131 .flat_map(char::to_lowercase)
132 .collect()
133}
134
135fn refers(entry: &str, s: &GraftStrength) -> bool {
137 let first = entry
138 .trim_start_matches(['[', '(', ' ', '`'])
139 .split(|c: char| !c.is_alphanumeric())
140 .next()
141 .unwrap_or("");
142 if first.eq_ignore_ascii_case(&s.id) {
143 return true;
144 }
145 let bare = entry.trim_matches(|c: char| !c.is_alphanumeric());
148 let mut chars = bare.chars();
149 if let (Some(c), None) = (chars.next(), chars.next()) {
150 if c.eq_ignore_ascii_case(&s.source) {
151 return true;
152 }
153 }
154 let (e, t) = (squash(entry), squash(&s.text));
155 e.len() >= 12 && t.len() >= 12 && (e.contains(&t) || t.contains(&e))
156}
157
158pub fn standing(s: &GraftStrength, a: &GraftAdoption) -> Standing {
161 if let Some(r) = a.rejected.iter().find(|r| refers(&r.id, s)) {
162 return Standing::Rejected(r.why.clone());
163 }
164 if a.addressed.iter().any(|x| refers(x, s)) {
165 Standing::Adopted
166 } else {
167 Standing::Unreported
168 }
169}
170
171fn read_capped(path: &Path) -> Result<String, &'static str> {
172 let meta = std::fs::metadata(path).map_err(|_| "artifact_missing")?;
173 if meta.len() > MAX_ARTIFACT_BYTES {
174 return Err("unreadable_output");
175 }
176 std::fs::read_to_string(path).map_err(|_| "unreadable_output")
177}
178
179pub fn details(
181 kind: Option<AgentKind>,
182 prompt: Result<&str, &'static str>,
183 out: Result<&str, &'static str>,
184) -> Recovered {
185 let mut rec = Recovered::default();
186 match prompt {
187 Ok(p) => {
188 rec.requested = parse_prompt(p);
189 if rec.requested.is_empty() {
190 rec.unavailable.push(Unavailable {
191 what: "requested",
192 reason: "the stored prompt lists no strengths".to_owned(),
193 });
194 }
195 }
196 Err(why) => rec.unavailable.push(Unavailable {
197 what: "requested",
198 reason: why.to_owned(),
199 }),
200 }
201 match (kind, out) {
202 (_, Err(why)) => rec.unavailable.push(Unavailable {
203 what: "adoption",
204 reason: why.to_owned(),
205 }),
206 (None, Ok(_)) => rec.unavailable.push(Unavailable {
207 what: "adoption",
208 reason: "the graft agent is no longer in the roster, so its output cannot be read"
209 .to_owned(),
210 }),
211 (Some(k), Ok(raw)) => {
212 let (adoption, err) = adoption_of(&crate::agent::answer_text(k, raw));
213 match adoption {
214 Some(a) => rec.adoption = Some(a),
215 None => rec.unavailable.push(Unavailable {
216 what: "adoption",
217 reason: err.unwrap_or_else(|| "unreadable_output".to_owned()),
218 }),
219 }
220 }
221 }
222 rec
223}
224
225pub fn recover(state: &RunState, artifacts: &Path) -> Recovered {
229 let Some(g) = &state.graft else {
230 return Recovered::default();
231 };
232 let need_requested = g.requested.is_empty();
233 let need_adoption = g.adoption.is_none() && g.adoption_error.is_none();
234 if !need_requested && !need_adoption {
235 return Recovered::default();
236 }
237 let last_to = state
238 .handovers
239 .iter()
240 .rfind(|h| h.node == "graft")
241 .map(|h| h.to.as_str());
242 let stem = match (&g.agent, last_to) {
243 (None, _) => None,
244 (Some(a), Some(to)) => (a == to).then(|| format!("graft-{a}")),
245 (Some(_), None) => Some("graft".to_owned()),
246 };
247 let prompt = read_capped(&artifacts.join("graft.prompt.md"));
248 let out = match &stem {
249 Some(s) => read_capped(&artifacts.join(format!("{s}.out"))),
250 None => Err("artifact_missing"),
251 };
252 let kind = g
253 .agent
254 .as_deref()
255 .and_then(|a| state.config.agents.iter().find(|s| s.id == a))
256 .map(|s| s.kind);
257 let mut rec = details(
258 kind,
259 prompt.as_deref().map_err(|e| *e),
260 out.as_deref().map_err(|e| *e),
261 );
262 if !need_requested {
263 rec.requested.clear();
264 rec.unavailable.retain(|u| u.what != "requested");
265 }
266 if !need_adoption {
267 rec.adoption = None;
268 rec.unavailable.retain(|u| u.what != "adoption");
269 }
270 rec
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 const PROMPT: &str = "# Graft the runner-up strengths\n\nx\n\n# Strengths to port\n\n## From candidate A (branch `b`, read-only)\n\n- [A1] Build Persona directly in the test\n- [A2] Cache the lookup\n\n## From candidate C (branch `c`, read-only)\n\n- plain legacy item\n\n# Rules\n\n- not a strength\n";
278
279 #[test]
280 fn prompt_items_get_ids_tagged_or_numbered() {
281 let v = parse_prompt(PROMPT);
282 let ids: Vec<_> = v.iter().map(|s| (s.id.as_str(), s.source)).collect();
283 assert_eq!(ids, [("A1", 'A'), ("A2", 'A'), ("C1", 'C')]);
284 assert_eq!(v[0].text, "Build Persona directly in the test");
285 }
286
287 #[test]
288 fn a_reply_without_a_report_holds_no_adoption() {
289 let (a, e) = adoption_of("I did some things.");
290 assert!(a.is_none());
291 assert!(e.unwrap().starts_with("no usable report"));
292 }
293
294 #[test]
295 fn requested_is_not_adopted_unless_the_report_says_so() {
296 let reply = "Ported the test change.\n\n```json\n{\"addressed\":[\"A1\"],\"rejected\":[{\"id\":\"A2\",\"why\":\"too big\"}],\"notes\":\"n\"}\n```";
297 let (a, _) = adoption_of(reply);
298 let a = a.unwrap();
299 assert_eq!(a.reasoning, "Ported the test change.");
300 let v = parse_prompt(PROMPT);
301 assert_eq!(standing(&v[0], &a), Standing::Adopted);
302 assert_eq!(standing(&v[1], &a), Standing::Rejected("too big".into()));
303 assert_eq!(standing(&v[2], &a), Standing::Unreported);
304 }
305
306 #[test]
307 fn a_strength_is_matched_by_its_text_when_the_id_is_missing() {
308 let a = GraftAdoption {
309 addressed: vec!["Build Persona directly in the test".to_owned()],
310 ..GraftAdoption::default()
311 };
312 let v = parse_prompt(PROMPT);
313 assert_eq!(standing(&v[0], &a), Standing::Adopted);
314 assert_eq!(standing(&v[1], &a), Standing::Unreported);
315 }
316
317 #[test]
318 fn a_bare_candidate_label_covers_that_sources_strengths() {
319 let a = GraftAdoption {
320 addressed: vec!["A".to_owned()],
321 ..GraftAdoption::default()
322 };
323 let v = parse_prompt(PROMPT);
324 assert_eq!(standing(&v[0], &a), Standing::Adopted);
325 assert_eq!(standing(&v[1], &a), Standing::Adopted);
326 assert_eq!(standing(&v[2], &a), Standing::Unreported);
327 let (a, _) = adoption_of(
328 "done\n\n```json\n{\"addressed\":[\"A\"],\"rejected\":[],\"notes\":\"\"}\n```",
329 );
330 assert_eq!(standing(&v[0], &a.unwrap()), Standing::Adopted);
331 }
332
333 #[test]
334 fn a_contradictory_report_does_not_read_as_adopted() {
335 let a = GraftAdoption {
336 addressed: vec!["A1".into()],
337 rejected: vec![GraftRejection {
338 id: "A1".into(),
339 why: "no".into(),
340 }],
341 ..GraftAdoption::default()
342 };
343 assert_eq!(
344 standing(&parse_prompt(PROMPT)[0], &a),
345 Standing::Rejected("no".into())
346 );
347 }
348
349 #[test]
350 fn details_extracts_the_answer_through_the_cli_shape_not_the_raw_stream() {
351 let inner = "why\n\n```json\n{\"addressed\":[\"A1\"],\"notes\":\"ok\"}\n```";
352 let out = serde_json::json!({"result": inner, "session_id": "s"}).to_string();
353 let r = details(Some(AgentKind::Claude), Ok(PROMPT), Ok(&out));
354 assert_eq!(r.requested.len(), 3);
355 assert_eq!(r.adoption.unwrap().addressed, ["A1"]);
356 assert!(r.unavailable.is_empty());
357 }
358
359 #[test]
360 fn missing_and_unreadable_pieces_are_named() {
361 let r = details(
362 Some(AgentKind::Claude),
363 Err("artifact_missing"),
364 Ok("not json at all"),
365 );
366 assert!(r.requested.is_empty());
367 let whats: Vec<_> = r.unavailable.iter().map(|u| u.what).collect();
368 assert_eq!(whats, ["requested", "adoption"]);
369 let r = details(None, Ok(PROMPT), Ok("x"));
370 assert!(r.adoption.is_none());
371 assert_eq!(r.unavailable[0].what, "adoption");
372 }
373 fn legacy_state(agent: &str, handover_to: Option<&str>) -> RunState {
374 crate::run::pin_test_home();
375 let mut s = RunState::new(
376 std::path::PathBuf::from("/repo/magi"),
377 "main".to_owned(),
378 "0123456789abcdef".to_owned(),
379 "task".to_owned(),
380 crate::config::Config::default(),
381 );
382 s.graft = Some(crate::run::GraftRecord {
383 pre: "p".into(),
384 post: Some("q".into()),
385 lines: Some(1),
386 limit: Some(40),
387 agent: Some(agent.into()),
388 outcome: crate::run::GraftOutcome::Applied,
389 requested: Vec::new(),
390 adoption: None,
391 adoption_error: None,
392 });
393 if let Some(to) = handover_to {
394 s.handovers.push(crate::run::Handover {
395 at: jiff::Timestamp::now(),
396 node: "graft".into(),
397 seat: "graft".into(),
398 from: "x".into(),
399 to: to.into(),
400 reason: "r".into(),
401 });
402 }
403 s
404 }
405
406 #[test]
407 fn recover_reads_the_handover_target_output_and_nothing_else() {
408 let dir = tempfile::tempdir().unwrap();
409 std::fs::write(dir.path().join("graft.prompt.md"), PROMPT).unwrap();
410 let inner = "ok\n\n```json\n{\"addressed\":[\"A1\"],\"notes\":\"n\"}\n```";
411 std::fs::write(
412 dir.path().join("graft-astra.out"),
413 serde_json::json!({"result": inner}).to_string(),
414 )
415 .unwrap();
416 std::fs::write(dir.path().join("graft.out"), "{\"result\":\"stale\"}").unwrap();
418 let mut s = legacy_state("astra", Some("astra"));
419 s.config.agents =
420 vec![toml::from_str("id = 'astra'\nkind = 'claude'").expect("agent spec")];
421 let r = recover(&s, dir.path());
422 assert_eq!(r.requested.len(), 3);
423 assert_eq!(r.adoption.unwrap().addressed, ["A1"]);
424 }
425
426 #[test]
427 fn recover_refuses_an_output_it_cannot_attribute() {
428 let dir = tempfile::tempdir().unwrap();
429 std::fs::write(dir.path().join("graft.prompt.md"), PROMPT).unwrap();
430 std::fs::write(dir.path().join("graft.out"), "{}").unwrap();
431 let s = legacy_state("sol", Some("astra"));
433 let r = recover(&s, dir.path());
434 assert!(r.adoption.is_none());
435 assert!(r.unavailable.iter().any(|u| u.what == "adoption"));
436 let empty = tempfile::tempdir().unwrap();
438 let r = recover(&legacy_state("sol", None), empty.path());
439 assert_eq!(r.unavailable.len(), 2);
440 assert_eq!(r.unavailable[0].reason, "artifact_missing");
441 }
442}