Skip to main content

scv_protocol/
review.rs

1//! Reviewed background jobs: what SCV itself decided about a job that ran
2//! with an independent reviewer, and what its builder landed. Every value
3//! comes from SCV's own state; reviewer and builder text appears only as
4//! bounded, cleaned fields that [`describe_outcome`] quotes.
5
6use serde::{Deserialize, Serialize};
7
8/// A reviewed job's review outcome and landing status, which clients show
9/// as SCV's own lines apart from the model's summary.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct JobOutcome {
12    /// The job's handle, such as `job-4`.
13    pub job: String,
14    /// How the review ended.
15    pub review: ReviewSummary,
16    /// What the builder landed, apart from the review.
17    pub landing: LandingSummary,
18}
19
20/// How a reviewed job's review ended.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ReviewSummary {
23    /// The outcome; only [`ReviewOutcome::Approved`] means approved.
24    pub outcome: ReviewOutcome,
25    /// Why it ended so, such as `round_limit` or `reviewer_timeout`.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub reason: Option<String>,
28    /// The round it ended in; 0 when no builder turn ran.
29    pub round: u32,
30    /// The call's round limit.
31    pub rounds: u32,
32    /// The agent of the last reviewer attempt; empty when none ran.
33    #[serde(default, skip_serializing_if = "String::is_empty")]
34    pub reviewer: String,
35    /// Why the reviewer is not the first in the order, such as
36    /// `codex unavailable`.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub fallback: Option<String>,
39    /// Each reviewer agent tried in the job, with its latest result.
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub tried: Vec<TriedReviewer>,
42    /// What reviewers that declined said, so the user can be told.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub refusals: Vec<Refusal>,
45    /// The last verdict's summary, as the reviewer wrote it, bounded.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub summary: Option<String>,
48    /// Blocking findings still open.
49    #[serde(default)]
50    pub open_count: u32,
51    /// The first of them.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub open: Vec<OpenFinding>,
54    /// The review journal's ID, such as `rev-1759961234-3fa9c1`.
55    pub journal: String,
56    /// A journal write failed after the outcome was decided, so the journal
57    /// lacks later steps. The outcome stands; a failure before it was
58    /// decided is the outcome `stopped` (`journal_error`) instead.
59    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
60    pub journal_incomplete: bool,
61    /// The job had not stopped when this outcome was stated, so its journal
62    /// was still being written: the outcome is decided, and a
63    /// `background.updated` event follows once the job stops, saying whether
64    /// the journal ended complete.
65    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
66    pub journal_pending: bool,
67}
68
69/// How a reviewed job's review ended.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum ReviewOutcome {
74    /// A reviewer approved the work. The only approval.
75    Approved,
76    /// The last round still had open blocking findings.
77    Unresolved,
78    /// The reviewer said the user must decide.
79    Escalated,
80    /// No usable verdict: no reviewer, or it declined, failed, timed out,
81    /// or stayed malformed.
82    NoVerdict,
83    /// A builder turn did not complete, the job was cancelled, or the
84    /// journal could not be written.
85    Stopped,
86    /// An outcome this client does not know, from a newer server.
87    #[serde(other)]
88    Unknown,
89}
90
91/// One reviewer agent a reviewed job tried.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct TriedReviewer {
94    /// The agent, such as `claude`.
95    pub agent: String,
96    /// How its latest attempt ended.
97    pub result: ReviewerResult,
98}
99
100/// How one reviewer attempt ended.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103#[non_exhaustive]
104pub enum ReviewerResult {
105    /// It gave a verdict.
106    Verdict,
107    /// It was unavailable: missing, signed out, or its provider failed.
108    Unavailable,
109    /// It declined the request.
110    Declined,
111    /// It failed, timed out, was stopped, or its verdict stayed malformed.
112    Failed,
113    /// No conversation slot was free to start it.
114    NoSlot,
115    /// A result this client does not know, from a newer server.
116    #[serde(other)]
117    Unknown,
118}
119
120/// What a reviewer that declined said.
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct Refusal {
123    /// The agent that declined.
124    pub agent: String,
125    /// Its reply, bounded: untrusted delegated-agent output.
126    pub reply: String,
127}
128
129/// A blocking finding still open when the review ended.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct OpenFinding {
132    /// SCV's number for it, `<round>.<n>`.
133    pub id: String,
134    /// Its title, as the reviewer wrote it, bounded.
135    pub title: String,
136    /// Where, as the reviewer wrote it, bounded.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub location: Option<String>,
139}
140
141/// What a reviewed job's builder landed, and who backs that.
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct LandingSummary {
144    /// When the call allowed the builder to land.
145    pub mode: LandMode,
146    /// Where the landing stands.
147    pub status: LandingStatus,
148    /// The last ref commits were reported landed on, such as `origin/main`.
149    #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
150    pub reference: Option<String>,
151    /// Every commit reported landed in the job, in order.
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub commits: Vec<String>,
154    /// Who backs a `landed` status.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub evidence: Option<LandingEvidence>,
157    /// The reviewer agent behind the evidence, when one checked.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub checked_by: Option<String>,
160    /// Commits landed in a round turn, before that round's verdict.
161    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
162    pub landed_before_review: bool,
163    /// The builder reported landing that the call did not allow.
164    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
165    pub unauthorized: bool,
166    /// SCV's own words on the status or evidence.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub reason: Option<String>,
169    /// The builder's own one-line detail, bounded.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub detail: Option<String>,
172}
173
174/// When a reviewed call allows its builder to land.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177#[non_exhaustive]
178pub enum LandMode {
179    /// Not in this job.
180    #[serde(rename = "none")]
181    NoLanding,
182    /// In one extra builder turn after an approving verdict.
183    AfterApproval,
184    /// In each round's builder turn, before that round's review.
185    BeforeReview,
186    /// A mode this client does not know, from a newer server.
187    #[serde(other)]
188    Unknown,
189}
190
191/// Where a reviewed job's landing stands.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194#[non_exhaustive]
195pub enum LandingStatus {
196    /// The call did not allow landing, and none was reported.
197    NotRequested,
198    /// Landing was allowed, but no turn that may land ran.
199    NotAttempted,
200    /// The builder reported commits on a ref.
201    Landed,
202    /// The builder reported it did not land.
203    NotLanded,
204    /// The builder reported a failed attempt; partial state is possible.
205    Failed,
206    /// A turn that may land did not complete, or its report is missing or
207    /// invalid; also any status this client does not know.
208    #[serde(other)]
209    Unknown,
210}
211
212/// Who backs a `landed` status. SCV runs no git, so a landing is only ever
213/// the builder's report or a reviewer's check of it.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216#[non_exhaustive]
217pub enum LandingEvidence {
218    /// No check was due.
219    BuilderReported,
220    /// A reviewer found every landed commit on the ref.
221    ReviewerConfirmed,
222    /// A reviewer found them missing or different.
223    ReviewerDisputed,
224    /// A check was due but produced no usable result.
225    Unconfirmed,
226    /// Evidence this client does not know, from a newer server.
227    #[serde(other)]
228    Unknown,
229}
230
231/// The longest reviewer-written summary quoted in an escalation line.
232const QUOTED_SUMMARY_CHARS: usize = 200;
233/// Open findings an unresolved line lists.
234const LISTED_FINDINGS: usize = 5;
235/// Landed commits a landing line names.
236const LISTED_COMMITS: usize = 3;
237/// The longest builder detail quoted in a landing line.
238const QUOTED_DETAIL_CHARS: usize = 120;
239/// The longest refusal quoted in a report.
240const QUOTED_REFUSAL_CHARS: usize = 300;
241
242/// `outcome` as a report states it, without the job: always a Review line,
243/// then any open findings indented, then what each reviewer that declined
244/// said, quoted and attributed, then a Landing line.
245pub fn describe_outcome(outcome: &JobOutcome) -> String {
246    let (review, findings) = review_lines(&outcome.review);
247    let mut text = review;
248    text.push('\n');
249    for finding in findings {
250        text.push_str(&finding);
251        text.push('\n');
252    }
253    for refusal in &outcome.review.refusals {
254        text.push_str(&format!(
255            "  - reviewer {} declined, saying (untrusted): \"{}\"\n",
256            refusal.agent,
257            shorten(&refusal.reply, QUOTED_REFUSAL_CHARS)
258        ));
259    }
260    text.push_str(&landing_line(&outcome.landing));
261    text.push('\n');
262    text
263}
264
265/// `outcome` as a client shows it, in SCV's words alone: the Review line,
266/// any open findings, and the Landing line, the two prefixed with the job,
267/// such as `job-4 · Review: approved · round 2 of 3 · reviewer claude`.
268/// Reviewers' refusals are left to the report, which quotes them.
269pub fn outcome_notice(outcome: &JobOutcome) -> String {
270    let (review, findings) = review_lines(&outcome.review);
271    let mut text = format!("{} · {review}", outcome.job);
272    for finding in findings {
273        text.push('\n');
274        text.push_str(&finding);
275    }
276    text.push_str(&format!(
277        "\n{} · {}",
278        outcome.job,
279        landing_line(&outcome.landing)
280    ));
281    text
282}
283
284/// The Review line and the open-finding lines under it.
285fn review_lines(review: &ReviewSummary) -> (String, Vec<String>) {
286    let (mut line, findings) = review_line(review);
287    if review.journal_incomplete && review.reason.as_deref() != Some("journal_error") {
288        line.push_str(&format!(
289            " · journal {} INCOMPLETE: a write failed",
290            review.journal
291        ));
292    } else if review.journal_pending {
293        line.push_str(&format!(
294            " · journal {} still open: the job is still stopping, an update follows",
295            review.journal
296        ));
297    }
298    (line, findings)
299}
300
301fn review_line(review: &ReviewSummary) -> (String, Vec<String>) {
302    let of = format!("round {} of {}", review.round, review.rounds);
303    let reviewer = reviewer_name(review);
304    let line = match review.outcome {
305        ReviewOutcome::Approved => format!("Review: approved · {of} · reviewer {reviewer}"),
306        ReviewOutcome::Unresolved => {
307            let noun = if review.open_count == 1 {
308                "finding"
309            } else {
310                "findings"
311            };
312            let mut findings: Vec<String> = review
313                .open
314                .iter()
315                .take(LISTED_FINDINGS)
316                .map(|finding| match &finding.location {
317                    Some(location) => format!("  - \"{}\" ({location})", finding.title),
318                    None => format!("  - \"{}\"", finding.title),
319                })
320                .collect();
321            let listed = u32::try_from(findings.len()).unwrap_or(u32::MAX);
322            if review.open_count > listed {
323                findings.push(format!("  - and {} more", review.open_count - listed));
324            }
325            return (
326                format!(
327                    "Review: NOT approved · unresolved after {} of {} rounds · {} blocking {noun} \
328                     open:",
329                    review.round, review.rounds, review.open_count
330                ),
331                findings,
332            );
333        }
334        ReviewOutcome::Escalated => {
335            let summary = review.summary.as_deref().unwrap_or_default();
336            format!(
337                "Review: NOT approved · escalated in {of} · reviewer {reviewer}: \"{}\"",
338                shorten(summary, QUOTED_SUMMARY_CHARS)
339            )
340        }
341        ReviewOutcome::NoVerdict => format!(
342            "Review: NOT approved · no verdict in {of} · {}",
343            no_verdict_phrase(review, &reviewer)
344        ),
345        ReviewOutcome::Stopped if review.round == 0 => format!(
346            "Review: NOT approved · stopped before round 1 · {}",
347            stopped_phrase(review)
348        ),
349        ReviewOutcome::Stopped => format!(
350            "Review: NOT approved · stopped in {of} · {}",
351            stopped_phrase(review)
352        ),
353        ReviewOutcome::Unknown => {
354            format!("Review: unknown outcome · see journal {}", review.journal)
355        }
356    };
357    (line, Vec::new())
358}
359
360/// `claude`, or `grok (codex unavailable)` when it was not the first choice.
361fn reviewer_name(review: &ReviewSummary) -> String {
362    match &review.fallback {
363        Some(fallback) => format!("{} ({fallback})", review.reviewer),
364        None => review.reviewer.clone(),
365    }
366}
367
368fn no_verdict_phrase(review: &ReviewSummary, reviewer: &str) -> String {
369    match review.reason.as_deref().unwrap_or_default() {
370        "reviewer_timeout" => format!("reviewer {reviewer} timed out"),
371        "reviewer_failed" => format!("reviewer {reviewer} failed"),
372        "reviewer_cancelled" => format!("reviewer {reviewer} was stopped"),
373        "reviewer_declined" => format!("reviewer {reviewer} declined"),
374        "reviewer_unavailable" => format!("reviewer {reviewer} is unavailable"),
375        "no_reviewer_available" => {
376            let tried: Vec<&str> = review
377                .tried
378                .iter()
379                .map(|tried| tried.agent.as_str())
380                .collect();
381            format!("no reviewer available ({})", tried.join(", "))
382        }
383        "no_conversation_slot" => "no room for a reviewer conversation".to_owned(),
384        "malformed_verdict" => format!("the verdict of reviewer {reviewer} stayed malformed"),
385        other => format!("reason {other}"),
386    }
387}
388
389fn stopped_phrase(review: &ReviewSummary) -> String {
390    match review.reason.as_deref().unwrap_or_default() {
391        "builder_failed" => "the builder turn failed".to_owned(),
392        "builder_timeout" => "the builder turn timed out".to_owned(),
393        "builder_declined" => "the builder declined".to_owned(),
394        "builder_cancelled" => "the builder turn was stopped".to_owned(),
395        "builder_no_session" => "the builder returned no conversation to continue".to_owned(),
396        "cancelled" if review.round == 0 => "the job was cancelled while queued".to_owned(),
397        "cancelled" => "the job was cancelled".to_owned(),
398        "journal_error" => "the journal could not be written".to_owned(),
399        other => format!("reason {other}"),
400    }
401}
402
403/// The Landing line.
404fn landing_line(landing: &LandingSummary) -> String {
405    let reason = landing.reason.as_deref().unwrap_or_default();
406    let unauthorized = if landing.unauthorized {
407        " · NOT authorized by this call"
408    } else {
409        ""
410    };
411    match landing.status {
412        LandingStatus::NotRequested => "Landing: not requested".to_owned(),
413        LandingStatus::NotAttempted if reason.is_empty() => "Landing: not attempted".to_owned(),
414        LandingStatus::NotAttempted => format!("Landing: not attempted · {reason}"),
415        LandingStatus::Landed => {
416            let before = if landing.landed_before_review {
417                " before review"
418            } else {
419                ""
420            };
421            format!(
422                "Landing: landed{before} · {} · {}{unauthorized}",
423                commits_on_ref(landing),
424                evidence_phrase(landing)
425            )
426        }
427        LandingStatus::NotLanded => format!(
428            "Landing: not landed · builder-reported{}{}{unauthorized}",
429            quoted_detail(landing),
430            earlier(landing)
431        ),
432        LandingStatus::Failed => format!(
433            "Landing: failed · builder-reported · partial state possible{}{}{unauthorized}",
434            quoted_detail(landing),
435            earlier(landing)
436        ),
437        LandingStatus::Unknown => {
438            let reason = if reason.is_empty() {
439                String::new()
440            } else {
441                format!(" · {reason}")
442            };
443            format!(
444                "Landing: unknown{reason}{} · check before relying on it{unauthorized}",
445                earlier(landing)
446            )
447        }
448    }
449}
450
451/// `4f2a9c1, 9e8d7c6 → origin/main`, at most three commits.
452fn commits_on_ref(landing: &LandingSummary) -> String {
453    let mut commits: Vec<&str> = landing
454        .commits
455        .iter()
456        .take(LISTED_COMMITS)
457        .map(|commit| commit.get(..7).unwrap_or(commit))
458        .collect();
459    let more = landing.commits.len().saturating_sub(LISTED_COMMITS);
460    let more = if more > 0 {
461        format!(" +{more} more")
462    } else {
463        String::new()
464    };
465    if commits.is_empty() {
466        commits.push("no commits named");
467    }
468    format!(
469        "{}{more} → {}",
470        commits.join(", "),
471        landing.reference.as_deref().unwrap_or("an unnamed ref")
472    )
473}
474
475fn evidence_phrase(landing: &LandingSummary) -> String {
476    let checker = landing.checked_by.as_deref().unwrap_or("reviewer");
477    let reason = landing.reason.as_deref().unwrap_or("no reason given");
478    match landing.evidence {
479        Some(LandingEvidence::ReviewerConfirmed) => format!("confirmed by reviewer {checker}"),
480        Some(LandingEvidence::ReviewerDisputed) => {
481            format!("reviewer {checker} could NOT confirm: {reason}")
482        }
483        Some(LandingEvidence::Unconfirmed) => format!("NOT confirmed: {reason}"),
484        Some(LandingEvidence::Unknown) => "evidence unknown".to_owned(),
485        Some(LandingEvidence::BuilderReported) | None => {
486            "builder-reported, not verified".to_owned()
487        }
488    }
489}
490
491/// `: "<detail>"`, the builder's own words, or nothing.
492fn quoted_detail(landing: &LandingSummary) -> String {
493    landing
494        .detail
495        .as_deref()
496        .filter(|detail| !detail.is_empty())
497        .map_or_else(String::new, |detail| {
498            format!(": \"{}\"", shorten(detail, QUOTED_DETAIL_CHARS))
499        })
500}
501
502/// `; earlier: <commits> → <ref>` when commits landed before a later
503/// report that did not land.
504fn earlier(landing: &LandingSummary) -> String {
505    if landing.commits.is_empty() {
506        String::new()
507    } else {
508        format!("; earlier: {}", commits_on_ref(landing))
509    }
510}
511
512/// `text` cut to `limit` characters, with `…` when cut.
513fn shorten(text: &str, limit: usize) -> String {
514    if text.chars().count() <= limit {
515        return text.to_owned();
516    }
517    let mut short: String = text.chars().take(limit.saturating_sub(1)).collect();
518    short.push('…');
519    short
520}
521
522#[cfg(test)]
523mod tests;