1use serde::{Deserialize, Serialize};
20
21use super::gate::{AcceptanceBasis, BuildTestStatus, GateEvidence, MergeVerdict, PolicyDecision};
22use super::harness::{IntegrationBlame, IntegrationResult, Subtask, SubtaskOutcome};
23use super::planner::DecomposeResult;
24
25pub const FOREMAN_SCHEMA_VERSION: u32 = 1;
27
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30pub struct SymbolRefDto {
31 pub file: String,
32 pub symbol: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct DuplicateDto {
38 pub file: String,
39 pub symbol: String,
40 pub kind: String,
41 pub count: usize,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(tag = "status", rename_all = "snake_case")]
47pub enum BuildTestDto {
48 NotConfigured,
49 NotRun {
50 reason: String,
51 },
52 Passed,
53 Failed {
56 code: Option<i32>,
57 output: String,
58 },
59 #[serde(other)]
60 Unknown,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(tag = "kind", rename_all = "snake_case")]
66pub enum AcceptanceBasisDto {
67 Verified,
68 Waived {
69 class: String,
70 reason: String,
71 },
72 #[serde(other)]
73 Unknown,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct GateEvidenceDto {
80 pub subtask: String,
81 pub changed_symbol_count: usize,
82 pub footprint_declared: bool,
83 pub containment_violations: Vec<SymbolRefDto>,
84 pub unparsed_changed_files: Vec<String>,
85 pub semantic_conflicts: Vec<DuplicateDto>,
86 pub build_test: BuildTestDto,
87 pub policy_denied: Option<Vec<String>>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "outcome", rename_all = "snake_case")]
95pub enum MergeVerdictDto {
96 Accepted {
97 basis: AcceptanceBasisDto,
98 evidence: GateEvidenceDto,
99 },
100 Rejected {
101 reasons: Vec<String>,
102 evidence: GateEvidenceDto,
103 },
104 Inconclusive {
105 reasons: Vec<String>,
106 evidence: GateEvidenceDto,
107 },
108 #[serde(other)]
115 Unknown,
116}
117
118impl MergeVerdictDto {
119 pub fn is_accepting(&self) -> bool {
124 matches!(self, MergeVerdictDto::Accepted { .. })
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SubtaskReportDto {
131 pub id: String,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub verdict: Option<MergeVerdictDto>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub error: Option<String>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct ApplyConflictDto {
141 pub subtask_id: String,
142 pub files: Vec<String>,
143 pub detail: String,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct DuplicateBlameDto {
151 pub file: String,
152 pub symbol: String,
153 pub candidate_subtask_ids: Vec<String>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct BuildTestFailureDto {
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub code: Option<i32>,
163 pub output_tail: String,
164 pub candidate_subtask_ids: Vec<String>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct IntegrationBlameDto {
172 pub apply_conflicts: Vec<ApplyConflictDto>,
173 pub duplicate_conflicts: Vec<DuplicateBlameDto>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub build_test: Option<BuildTestFailureDto>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct IntegrationReportDto {
181 pub applied: usize,
182 pub apply_conflicts: Vec<String>,
183 pub integrated_cleanly: bool,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub verdict: Option<MergeVerdictDto>,
186 #[serde(skip_serializing_if = "Option::is_none")]
189 pub blame: Option<IntegrationBlameDto>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct ForemanReport {
195 pub schema_version: u32,
196 pub subtasks: Vec<SubtaskReportDto>,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub integration: Option<IntegrationReportDto>,
199}
200
201impl ForemanReport {
202 pub fn from_run(outcomes: &[SubtaskOutcome], integration: Option<&IntegrationResult>) -> Self {
205 Self {
206 schema_version: FOREMAN_SCHEMA_VERSION,
207 subtasks: outcomes.iter().map(SubtaskReportDto::from).collect(),
208 integration: integration.map(IntegrationReportDto::from),
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct PlanSubtaskDto {
217 pub id: String,
218 pub prompt: String,
219 pub files: Vec<String>,
220 pub writes: Vec<SymbolRefDto>,
221 pub reads: Vec<SymbolRefDto>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct PlanReport {
229 pub schema_version: u32,
230 pub valid: bool,
232 pub prefer_single_session: bool,
234 pub attempts: u32,
235 pub issues: Vec<String>,
237 pub levels: Vec<Vec<String>>,
239 pub subtasks: Vec<PlanSubtaskDto>,
240}
241
242impl From<&DecomposeResult> for PlanReport {
243 fn from(r: &DecomposeResult) -> Self {
244 Self {
245 schema_version: FOREMAN_SCHEMA_VERSION,
246 valid: r.is_valid(),
247 prefer_single_session: r.prefer_single_session,
248 attempts: r.attempts,
249 issues: r.issues.clone(),
250 levels: r.levels.clone(),
251 subtasks: r.subtasks.iter().map(PlanSubtaskDto::from).collect(),
252 }
253 }
254}
255
256impl From<&Subtask> for PlanSubtaskDto {
257 fn from(s: &Subtask) -> Self {
258 let (mut writes, mut reads): (Vec<SymbolRefDto>, Vec<SymbolRefDto>) = match &s.footprint {
261 Some(fp) => (
262 fp.writes
263 .iter()
264 .map(|r| SymbolRefDto {
265 file: r.file.clone(),
266 symbol: r.symbol.clone(),
267 })
268 .collect(),
269 fp.reads
270 .iter()
271 .map(|r| SymbolRefDto {
272 file: r.file.clone(),
273 symbol: r.symbol.clone(),
274 })
275 .collect(),
276 ),
277 None => (Vec::new(), Vec::new()),
278 };
279 writes.sort();
280 reads.sort();
281 Self {
282 id: s.id.clone(),
283 prompt: s.prompt.clone(),
284 files: s.files.clone(),
285 writes,
286 reads,
287 }
288 }
289}
290
291impl From<&BuildTestStatus> for BuildTestDto {
294 fn from(s: &BuildTestStatus) -> Self {
295 match s {
296 BuildTestStatus::NotConfigured => BuildTestDto::NotConfigured,
297 BuildTestStatus::NotRun { reason } => BuildTestDto::NotRun {
298 reason: reason.clone(),
299 },
300 BuildTestStatus::Passed => BuildTestDto::Passed,
301 BuildTestStatus::Failed { code, output } => BuildTestDto::Failed {
302 code: *code,
303 output: output.clone(),
304 },
305 }
306 }
307}
308
309impl From<&AcceptanceBasis> for AcceptanceBasisDto {
310 fn from(b: &AcceptanceBasis) -> Self {
311 match b {
312 AcceptanceBasis::Verified => AcceptanceBasisDto::Verified,
313 AcceptanceBasis::Waived { class, reason } => AcceptanceBasisDto::Waived {
314 class: class.clone(),
315 reason: reason.clone(),
316 },
317 }
318 }
319}
320
321impl From<&GateEvidence> for GateEvidenceDto {
322 fn from(e: &GateEvidence) -> Self {
323 let GateEvidence {
327 subtask,
328 changed_symbols,
329 footprint_declared,
330 containment: _, containment_violations,
332 unparsed_changed_files,
333 duplicates: _, semantic_conflicts,
335 build_test,
336 policy,
337 } = e;
338 Self {
339 subtask: subtask.clone(),
340 changed_symbol_count: changed_symbols.len(),
341 footprint_declared: *footprint_declared,
342 containment_violations: containment_violations
343 .iter()
344 .map(|v| SymbolRefDto {
345 file: v.changed.file.clone(),
346 symbol: v.changed.symbol.clone(),
347 })
348 .collect(),
349 unparsed_changed_files: unparsed_changed_files.clone(),
350 semantic_conflicts: semantic_conflicts
351 .iter()
352 .map(|d| DuplicateDto {
353 file: d.file.clone(),
354 symbol: d.symbol.clone(),
355 kind: d.kind.clone(),
356 count: d.count,
357 })
358 .collect(),
359 build_test: BuildTestDto::from(build_test),
360 policy_denied: match policy {
361 PolicyDecision::Deny { reasons } => Some(reasons.clone()),
362 PolicyDecision::Allow => None,
363 },
364 }
365 }
366}
367
368impl From<&MergeVerdict> for MergeVerdictDto {
369 fn from(v: &MergeVerdict) -> Self {
370 match v {
371 MergeVerdict::Accepted { basis, evidence } => MergeVerdictDto::Accepted {
372 basis: AcceptanceBasisDto::from(basis),
373 evidence: GateEvidenceDto::from(evidence),
374 },
375 MergeVerdict::Rejected { reasons, evidence } => MergeVerdictDto::Rejected {
376 reasons: reasons.clone(),
377 evidence: GateEvidenceDto::from(evidence),
378 },
379 MergeVerdict::Inconclusive { reasons, evidence } => MergeVerdictDto::Inconclusive {
380 reasons: reasons.clone(),
381 evidence: GateEvidenceDto::from(evidence),
382 },
383 }
384 }
385}
386
387impl From<&SubtaskOutcome> for SubtaskReportDto {
388 fn from(o: &SubtaskOutcome) -> Self {
389 Self {
390 id: o.subtask_id.clone(),
391 verdict: o.verdict.as_ref().map(MergeVerdictDto::from),
392 error: o.error.clone(),
393 }
394 }
395}
396
397impl From<&super::harness::ApplyConflict> for ApplyConflictDto {
401 fn from(c: &super::harness::ApplyConflict) -> Self {
402 let super::harness::ApplyConflict {
403 subtask_id,
404 files,
405 detail,
406 } = c;
407 Self {
408 subtask_id: subtask_id.clone(),
409 files: files.clone(),
410 detail: detail.clone(),
411 }
412 }
413}
414
415impl From<&super::harness::DuplicateBlame> for DuplicateBlameDto {
416 fn from(d: &super::harness::DuplicateBlame) -> Self {
417 let super::harness::DuplicateBlame {
418 file,
419 symbol,
420 candidate_subtask_ids,
421 } = d;
422 Self {
423 file: file.clone(),
424 symbol: symbol.clone(),
425 candidate_subtask_ids: candidate_subtask_ids.clone(),
426 }
427 }
428}
429
430impl From<&super::harness::BuildTestFailure> for BuildTestFailureDto {
431 fn from(f: &super::harness::BuildTestFailure) -> Self {
432 let super::harness::BuildTestFailure {
433 code,
434 output_tail,
435 candidate_subtask_ids,
436 } = f;
437 Self {
438 code: *code,
439 output_tail: output_tail.clone(),
440 candidate_subtask_ids: candidate_subtask_ids.clone(),
441 }
442 }
443}
444
445impl From<&IntegrationBlame> for IntegrationBlameDto {
446 fn from(b: &IntegrationBlame) -> Self {
447 let IntegrationBlame {
448 apply_conflicts,
449 duplicate_conflicts,
450 build_test,
451 } = b;
452 Self {
453 apply_conflicts: apply_conflicts.iter().map(ApplyConflictDto::from).collect(),
454 duplicate_conflicts: duplicate_conflicts
455 .iter()
456 .map(DuplicateBlameDto::from)
457 .collect(),
458 build_test: build_test.as_ref().map(BuildTestFailureDto::from),
459 }
460 }
461}
462
463impl From<&IntegrationResult> for IntegrationReportDto {
464 fn from(r: &IntegrationResult) -> Self {
465 Self {
466 applied: r.applied,
467 apply_conflicts: r.apply_conflicts.clone(),
468 integrated_cleanly: r.integrated_cleanly(),
469 verdict: r.verdict.as_ref().map(MergeVerdictDto::from),
470 blame: r.blame.as_ref().map(IntegrationBlameDto::from),
471 }
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn evidence() -> GateEvidenceDto {
480 GateEvidenceDto {
481 subtask: "a".into(),
482 changed_symbol_count: 1,
483 footprint_declared: true,
484 containment_violations: vec![],
485 unparsed_changed_files: vec![],
486 semantic_conflicts: vec![],
487 build_test: BuildTestDto::Passed,
488 policy_denied: None,
489 }
490 }
491
492 #[test]
493 fn verdict_uses_stable_string_discriminants() {
494 let v = MergeVerdictDto::Accepted {
495 basis: AcceptanceBasisDto::Verified,
496 evidence: evidence(),
497 };
498 let json = serde_json::to_value(&v).unwrap();
499 assert_eq!(json["outcome"], "accepted");
500 assert_eq!(json["basis"]["kind"], "verified");
501 assert_eq!(json["evidence"]["build_test"]["status"], "passed");
502 }
503
504 #[test]
505 fn report_carries_schema_version_and_round_trips() {
506 let report = ForemanReport {
507 schema_version: FOREMAN_SCHEMA_VERSION,
508 subtasks: vec![SubtaskReportDto {
509 id: "a".into(),
510 verdict: Some(MergeVerdictDto::Rejected {
511 reasons: vec!["build/test failed".into()],
512 evidence: evidence(),
513 }),
514 error: None,
515 }],
516 integration: Some(IntegrationReportDto {
517 applied: 1,
518 apply_conflicts: vec![],
519 integrated_cleanly: true,
520 verdict: None,
521 blame: None,
522 }),
523 };
524 let json = serde_json::to_string(&report).unwrap();
525 assert!(json.contains("\"schema_version\":1"));
526 let back: ForemanReport = serde_json::from_str(&json).unwrap();
527 assert_eq!(report, back, "DTO round-trips losslessly");
528 }
529
530 #[test]
531 fn failure_output_and_policy_reasons_survive_the_wire() {
532 let mut ev = evidence();
533 ev.build_test = BuildTestDto::Failed {
534 code: Some(101),
535 output: "error[E0308]: mismatched types".into(),
536 };
537 ev.policy_denied = Some(vec!["protected path".into()]);
538 let json = serde_json::to_value(&ev).unwrap();
539 assert_eq!(
540 json["build_test"]["output"],
541 "error[E0308]: mismatched types"
542 );
543 assert_eq!(json["policy_denied"][0], "protected path");
544 }
545
546 #[test]
547 fn is_accepting_is_fail_closed() {
548 let accepted = MergeVerdictDto::Accepted {
549 basis: AcceptanceBasisDto::Verified,
550 evidence: evidence(),
551 };
552 assert!(accepted.is_accepting());
553 assert!(!MergeVerdictDto::Unknown.is_accepting());
554 assert!(!MergeVerdictDto::Inconclusive {
555 reasons: vec![],
556 evidence: evidence()
557 }
558 .is_accepting());
559 }
560
561 #[test]
562 fn unknown_variant_is_forward_compatible() {
563 let json = r#"{"outcome":"quarantined","evidence":{}}"#;
565 let v: MergeVerdictDto = serde_json::from_str(json).unwrap();
566 assert_eq!(
567 v,
568 MergeVerdictDto::Unknown,
569 "unknown outcome degrades, not panics"
570 );
571
572 let bt: BuildTestDto = serde_json::from_str(r#"{"status":"sandboxed"}"#).unwrap();
573 assert_eq!(bt, BuildTestDto::Unknown);
574 }
575}