feat(runs): surface diff summary counts

Compute cheap diff stats on checkpoint and terminal events, roll them into run summaries, and use them for the Files Changed tab badge without fetching full file diffs.
This commit is contained in:
Bryan Helmkamp 2026-05-07 17:34:32 -07:00
parent de26face0f
commit 23cb211cce
No known key found for this signature in database
40 changed files with 759 additions and 30 deletions

View file

@ -69,7 +69,7 @@ type RunDetailActionResult = import("./run-detail").RunDetailActionResult;
const h = createElement;
function makeRunSummary(status = "succeeded") {
function makeRunSummary(status = "succeeded", diffSummary: any = null) {
return {
run_id: "run_1",
title: "Run 1",
@ -80,6 +80,7 @@ function makeRunSummary(status = "succeeded") {
duration_ms: null,
elapsed_secs: null,
source_directory: null,
diff_summary: diffSummary,
};
}
@ -105,12 +106,14 @@ async function renderRunDetail({
initialEntry,
status = "succeeded",
questions = [],
diffSummary = null,
}: {
initialEntry: string;
status?: string;
questions?: any[];
diffSummary?: any;
}) {
currentRunSummary = makeRunSummary(status);
currentRunSummary = makeRunSummary(status, diffSummary);
currentQuestions = questions;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@ -154,6 +157,12 @@ function hasClasses(value: unknown, classes: string[]) {
return classes.every((className) => tokens.includes(className));
}
function tabCountBadges(renderer: TestRenderer.ReactTestRenderer) {
return renderer.root.findAll(
(node) => node.type === "span" && hasClasses(node.props.className, ["tabular-nums"]),
);
}
describe("lifecycleActionVisibility", () => {
test("shows cancel for active cancellable states and hides it elsewhere", () => {
expect(lifecycleActionVisibility("submitted").showPrimaryCancel).toBe(true);
@ -330,6 +339,28 @@ describe("RunDetail full-height child routes", () => {
expect(outletWrappers).toHaveLength(1);
});
test("shows the Files Changed tab badge from run summary diff stats", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1/files",
diffSummary: {
files_changed: 7,
additions: 30,
deletions: 11,
},
});
const badges = tabCountBadges(renderer);
expect(badges.map((badge) => badge.children.join(""))).toContain("7");
});
test("hides the Files Changed tab badge when diff stats are absent", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1/files",
});
expect(tabCountBadges(renderer)).toHaveLength(0);
});
test("keeps blocked full-height children clear of the interview dock without an h-72 sibling", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1/files",

View file

@ -149,7 +149,12 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const unarchiveMutation = useUnarchiveRun(params.id);
const interruptMutation = useInterruptRun(params.id);
const { push, dismiss } = useToast();
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
const filesCount = runQuery.data?.diff_summary?.files_changed ?? null;
const tabs = allTabs
.map((tab) =>
tab.name === "Files Changed" ? { ...tab, count: filesCount } : tab,
)
.filter((t) => !t.demoOnly || demoMode);
const lifecycleToastStateRef = useRef<LifecycleToastState>(INITIAL_LIFECYCLE_TOAST_STATE);
const steerBarRef = useRef<SteerBarHandle | null>(null);
const [steerOpen, setSteerOpen] = useState(false);

View file

@ -5652,6 +5652,10 @@ components:
additionalProperties: true
final_patch:
type: ["string", "null"]
diff_summary:
oneOf:
- $ref: "#/components/schemas/DiffSummary"
- type: "null"
pull_request:
type: ["object", "null"]
additionalProperties: true
@ -5727,6 +5731,10 @@ components:
format: int64
superseded_by:
type: ["string", "null"]
diff_summary:
oneOf:
- $ref: "#/components/schemas/DiffSummary"
- type: "null"
ForkRequest:
description: Request body for creating a new run from a source run checkpoint.
@ -6615,6 +6623,27 @@ components:
description: Total lines deleted.
example: 234
DiffSummary:
description: Cheap aggregate file and line counts for a run diff.
type: object
required:
- files_changed
- additions
- deletions
properties:
files_changed:
type: integer
description: Total number of changed files, including binary files.
example: 42
additions:
type: integer
description: Total lines added across text files.
example: 567
deletions:
type: integer
description: Total lines deleted across text files.
example: 234
RunFilesMeta:
description: |
Metadata for a `PaginatedRunFileList` response.

View file

@ -179,6 +179,7 @@ fn main() {
&[],
),
("RunSummary", "fabro_types::RunSummary", &[]),
("DiffSummary", "fabro_types::DiffSummary", &[]),
(
"RepositoryReference",
"fabro_types::RepositoryReference",

View file

@ -30,7 +30,7 @@ pub mod types {
};
pub use fabro_types::{
AuthMethod, BilledTokenCounts, CommandOutputStream, CommandTermination, DiffStats,
DirtyStatus, EventEnvelope, GitContext, IdpIdentity, InterviewOption,
DiffSummary, DirtyStatus, EventEnvelope, GitContext, IdpIdentity, InterviewOption,
InterviewQuestionRecord, PendingInterviewRecord, PreRunPushOutcome, Principal,
QuestionType, RepositoryReference, RunClientProvenance, RunEvent, RunProjection,
RunProvenance, RunServerProvenance, RunSummary, SecretMetadata, SecretType, ServerSettings,

View file

@ -0,0 +1,37 @@
use std::any::{TypeId, type_name};
use fabro_api::types::DiffSummary as ApiDiffSummary;
use fabro_types::DiffSummary;
use serde_json::json;
#[test]
fn diff_summary_reuses_canonical_type() {
assert_same_type::<ApiDiffSummary, DiffSummary>();
}
#[test]
fn diff_summary_serializes_with_required_integer_fields() {
let summary = DiffSummary {
files_changed: 3,
additions: 12,
deletions: 4,
};
assert_eq!(
serde_json::to_value(summary).unwrap(),
json!({
"files_changed": 3,
"additions": 12,
"deletions": 4,
})
);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -6,7 +6,7 @@ use fabro_api::types::{
RepositoryReference as ApiRepositoryReference, RunSummary as ApiRunSummary,
};
use fabro_types::status::{RunStatus, SuccessReason, TerminalStatus};
use fabro_types::{RepositoryReference, RunId, RunSummary};
use fabro_types::{DiffSummary, RepositoryReference, RunId, RunSummary};
use serde_json::json;
#[test]
@ -41,6 +41,11 @@ fn run_summary_json_matches_openapi_shape() {
Some(42_000),
Some(123),
Some(superseded_by),
Some(DiffSummary {
files_changed: 3,
additions: 12,
deletions: 4,
}),
);
assert_eq!(
@ -74,7 +79,12 @@ fn run_summary_json_matches_openapi_shape() {
"duration_ms": 42000,
"elapsed_secs": 42.0,
"total_usd_micros": 123,
"superseded_by": superseded_by.to_string()
"superseded_by": superseded_by.to_string(),
"diff_summary": {
"files_changed": 3,
"additions": 12,
"deletions": 4
}
})
);
}
@ -117,6 +127,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.elapsed_secs, None);
assert_eq!(summary.total_usd_micros, None);
assert_eq!(summary.superseded_by, None);
assert_eq!(summary.diff_summary, None);
}
fn assert_same_type<T: 'static, U: 'static>() {

View file

@ -106,7 +106,7 @@ pub(crate) async fn attach_run_with_client(
}
let stream = client.attach_run_events(run_id, Some(next_seq)).await?;
attach_live_run_with_client(
Box::pin(attach_live_run_with_client(
client,
run_id,
replay_events,
@ -119,7 +119,7 @@ pub(crate) async fn attach_run_with_client(
json_output,
},
printer,
)
))
.await
}

View file

@ -773,6 +773,7 @@ mod tests {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
})),
Some(WorkerTitlePhase::Succeeded)
@ -785,6 +786,7 @@ mod tests {
reason: FailureReason::Cancelled,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
})),
Some(WorkerTitlePhase::Cancelled)
);
@ -796,6 +798,7 @@ mod tests {
reason: FailureReason::Terminated,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
})),
Some(WorkerTitlePhase::Failed)
);

View file

@ -889,6 +889,7 @@ mod runs {
elapsed_secs.and_then(duration_ms_from_secs),
total_usd_micros,
None,
None,
)
}

View file

@ -1983,6 +1983,7 @@ pub(crate) async fn reconcile_incomplete_runs_on_startup(
reason,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await?;
@ -2036,6 +2037,7 @@ async fn persist_shutdown_run_failures(
reason,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await?;
@ -2110,6 +2112,7 @@ async fn persist_cancelled_run_status(state: &AppState, run_id: RunId) -> anyhow
reason: FailureReason::Cancelled,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await
@ -2148,6 +2151,7 @@ async fn fail_run_before_execution(
reason,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await
@ -2425,6 +2429,7 @@ async fn append_worker_exit_failure(
reason,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await
@ -3085,6 +3090,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
reason: FailureReason::LaunchFailed,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await;
@ -3112,6 +3118,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
reason: FailureReason::LaunchFailed,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await;
@ -3142,6 +3149,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
reason: FailureReason::LaunchFailed,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await;
@ -3163,6 +3171,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
reason: FailureReason::LaunchFailed,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await;
@ -3196,6 +3205,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
reason: FailureReason::Terminated,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
},
)
.await;

View file

@ -2668,6 +2668,7 @@ async fn run_billing_dedups_retried_nodes_and_sums_their_durations() {
restart_failure_signatures: std::collections::BTreeMap::new(),
node_visits: std::collections::BTreeMap::from([("verify".to_string(), 2usize)]),
diff: None,
diff_summary: None,
},
)
.await
@ -2796,6 +2797,7 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
restart_failure_signatures: std::collections::BTreeMap::new(),
node_visits: std::collections::BTreeMap::from([("verify".to_string(), 2usize)]),
diff: None,
diff_summary: None,
},
)
.await
@ -3407,6 +3409,7 @@ async fn create_completed_run_ready_for_pull_request(
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: Some(final_patch.to_string()),
diff_summary: None,
billing: None,
},
])
@ -6964,6 +6967,7 @@ async fn archive_and_unarchive_updates_listing_visibility() {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
},
])
@ -8304,6 +8308,7 @@ async fn boards_runs_excludes_archived_by_default() {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
},
workflow_event::Event::RunArchived { actor: None },
@ -8352,6 +8357,7 @@ async fn boards_runs_includes_archived_when_flag_set() {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
},
workflow_event::Event::RunArchived { actor: None },
@ -8371,6 +8377,7 @@ async fn boards_runs_includes_archived_when_flag_set() {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
},
])
@ -8440,6 +8447,7 @@ async fn get_run_exposes_canonical_operator_statuses() {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
},
])
@ -8521,6 +8529,7 @@ async fn boards_runs_maps_statuses_to_columns() {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
},
])

View file

@ -73,6 +73,7 @@ async fn append_completed_run_with_final_patch(
total_usd_micros: None,
final_git_commit_sha: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()),
final_patch: Some(final_patch.to_string()),
diff_summary: None,
billing: None,
},
)

View file

@ -155,6 +155,7 @@ impl RunProjectionReducer for RunProjection {
self.pending_control = None;
self.conclusion = Some(conclusion_from_completed(props, ts)?);
self.final_patch.clone_from(&props.final_patch);
self.diff_summary = props.diff_summary.or(self.diff_summary);
self.pending_interviews.clear();
}
EventBody::RunFailed(props) => {
@ -167,6 +168,7 @@ impl RunProjectionReducer for RunProjection {
self.pending_control = None;
self.conclusion = Some(conclusion_from_failed(props, ts));
self.final_patch.clone_from(&props.final_patch);
self.diff_summary = props.diff_summary.or(self.diff_summary);
self.pending_interviews.clear();
}
EventBody::RunSupersededBy(props) => {
@ -196,6 +198,7 @@ impl RunProjectionReducer for RunProjection {
}
EventBody::CheckpointCompleted(props) => {
let checkpoint = checkpoint_from_props(props, ts);
self.diff_summary = props.diff_summary.or(self.diff_summary);
if let Some(node_id) = stored.node_id.as_deref() {
let visit = checkpoint
.node_visits
@ -562,6 +565,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
.and_then(|conclusion| conclusion.billing.as_ref())
.and_then(|billing| billing.total_usd_micros),
state.superseded_by,
state.diff_summary,
)
}
@ -1416,6 +1420,7 @@ mod tests {
restart_failure_signatures: BTreeMap::new(),
node_visits: BTreeMap::from([("skip_me".to_string(), 1usize)]),
diff: None,
diff_summary: None,
}),
None,
))
@ -1750,6 +1755,7 @@ mod tests {
reason: FailureReason::WorkflowError,
git_commit_sha: Some("abc123".to_string()),
final_patch: Some(patch.to_string()),
diff_summary: None,
}),
None,
))
@ -1758,6 +1764,109 @@ mod tests {
assert_eq!(state.final_patch.as_deref(), Some(patch));
}
#[test]
fn patch_bearing_events_roll_up_diff_summary_without_blanking_prior_value() {
let mut state = RunProjection::default();
state
.apply_event(&test_raw_event(
1,
"checkpoint.completed",
&json!({
"status": "running",
"current_node": "build",
"completed_nodes": ["build"],
"diff_summary": {
"files_changed": 2,
"additions": 10,
"deletions": 3
}
}),
Some("build"),
))
.unwrap();
assert_eq!(
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff_summary"],
json!({
"files_changed": 2,
"additions": 10,
"deletions": 3
})
);
state
.apply_event(&test_raw_event(
2,
"checkpoint.completed",
&json!({
"status": "running",
"current_node": "review",
"completed_nodes": ["build", "review"]
}),
Some("review"),
))
.unwrap();
assert_eq!(
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff_summary"]
["files_changed"],
2
);
state
.apply_event(&test_raw_event(
3,
"run.completed",
&json!({
"duration_ms": 42,
"artifact_count": 0,
"status": "succeeded",
"reason": "completed",
"diff_summary": {
"files_changed": 4,
"additions": 18,
"deletions": 7
}
}),
None,
))
.unwrap();
assert_eq!(
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff_summary"],
json!({
"files_changed": 4,
"additions": 18,
"deletions": 7
})
);
let mut failed_state = RunProjection::default();
failed_state
.apply_event(&test_raw_event(
1,
"run.failed",
&json!({
"error": "boom",
"duration_ms": 42,
"reason": "workflow_error",
"diff_summary": {
"files_changed": 5,
"additions": 20,
"deletions": 8
}
}),
None,
))
.unwrap();
assert_eq!(
serde_json::to_value(build_summary(&failed_state, &fixtures::RUN_1)).unwrap()["diff_summary"],
json!({
"files_changed": 5,
"additions": 20,
"deletions": 8
})
);
}
#[test]
fn run_failed_projection_renders_causes() {
let mut state = RunProjection::default();
@ -1774,6 +1883,7 @@ mod tests {
reason: FailureReason::WorkflowError,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
}),
None,
))
@ -1803,6 +1913,7 @@ mod tests {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
}),
None,
@ -1866,6 +1977,7 @@ mod tests {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
}),
None,
@ -1996,6 +2108,7 @@ mod tests {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
}),
None,

View file

@ -5,3 +5,10 @@ pub struct DiffStats {
pub additions: i64,
pub deletions: i64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffSummary {
pub files_changed: i64,
pub additions: i64,
pub deletions: i64,
}

View file

@ -42,6 +42,7 @@ mod tests {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
}),
};
@ -85,6 +86,7 @@ mod tests {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
}),
};

View file

@ -45,7 +45,7 @@ pub use checkpoint::Checkpoint;
pub use command_output::{CommandOutputStream, CommandTermination};
pub use conclusion::{Conclusion, StageSummary};
pub use dense::{ServerSettings, UserSettings, WorkflowSettings};
pub use diff::DiffStats;
pub use diff::{DiffStats, DiffSummary};
pub use event_envelope::EventEnvelope;
pub use failure_signature::FailureSignature;
pub use graph::{

View file

@ -981,6 +981,74 @@ mod tests {
));
}
#[test]
fn patch_bearing_events_round_trip_diff_summary() {
for (event_name, properties) in [
(
"checkpoint.completed",
json!({
"status": "running",
"current_node": "build",
"completed_nodes": ["build"],
"diff_summary": {
"files_changed": 2,
"additions": 10,
"deletions": 3
}
}),
),
(
"run.completed",
json!({
"duration_ms": 42,
"artifact_count": 0,
"status": "succeeded",
"reason": "completed",
"diff_summary": {
"files_changed": 2,
"additions": 10,
"deletions": 3
}
}),
),
(
"run.failed",
json!({
"error": "boom",
"duration_ms": 42,
"reason": "workflow_error",
"diff_summary": {
"files_changed": 2,
"additions": 10,
"deletions": 3
}
}),
),
] {
let line = json!({
"id": format!("evt_{event_name}"),
"ts": "2026-04-04T12:00:00Z",
"run_id": fixtures::RUN_1,
"event": event_name,
"node_id": "build",
"properties": properties
});
let parsed = RunEvent::from_value(line).unwrap();
let serialized = parsed.to_value().unwrap();
assert_eq!(
serialized["properties"]["diff_summary"],
json!({
"files_changed": 2,
"additions": 10,
"deletions": 3
}),
"{event_name} should preserve diff_summary"
);
}
}
#[test]
fn run_submitted_round_trip_preserves_definition_blob() {
let line = json!({

View file

@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};
use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel};
use crate::status::{BlockedReason, FailureReason, SuccessReason};
use crate::{
ForkSourceRef, GitContext, Graph, RunBlobId, RunControlAction, RunId, RunProvenance,
WorkflowSettings,
DiffSummary, ForkSourceRef, GitContext, Graph, RunBlobId, RunControlAction, RunId,
RunProvenance, WorkflowSettings,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -139,6 +139,8 @@ pub struct RunCompletedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<DiffSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing: Option<BilledTokenCounts>,
}
@ -155,6 +157,8 @@ pub struct RunFailedProps {
// pre-change events replay with `final_patch: None` via serde default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<DiffSummary>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::ExecOutputTail;
use crate::{BilledModelUsage, FailureDetail, Outcome, StageOutcome};
use crate::{BilledModelUsage, DiffSummary, FailureDetail, Outcome, StageOutcome};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageStartedProps {
@ -114,6 +114,8 @@ pub struct CheckpointCompletedProps {
pub node_visits: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<DiffSummary>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -4,9 +4,9 @@ use std::num::NonZeroU32;
use chrono::{DateTime, Utc};
use crate::{
BilledModelUsage, Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition,
PullRequestRecord, Retro, RunControlAction, RunId, RunSpec, RunStatus, SandboxRecord,
StageCompletion, StageId, StageState, StartRecord,
BilledModelUsage, Checkpoint, Conclusion, DiffSummary, InterviewQuestionRecord,
InvalidTransition, PullRequestRecord, Retro, RunControlAction, RunId, RunSpec, RunStatus,
SandboxRecord, StageCompletion, StageId, StageState, StartRecord,
};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
@ -27,6 +27,8 @@ pub struct RunProjection {
pub retro_response: Option<String>,
pub sandbox: Option<SandboxRecord>,
pub final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<DiffSummary>,
pub pull_request: Option<PullRequestRecord>,
pub superseded_by: Option<RunId>,
pub pending_interviews: BTreeMap<String, PendingInterviewRecord>,

View file

@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use fabro_util::text::strip_goal_decoration;
use serde::{Deserialize, Serialize};
use crate::{RepositoryReference, RunControlAction, RunId, RunStatus};
use crate::{DiffSummary, RepositoryReference, RunControlAction, RunId, RunStatus};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSummary {
@ -39,6 +39,8 @@ pub struct RunSummary {
pub total_usd_micros: Option<i64>,
#[serde(default)]
pub superseded_by: Option<RunId>,
#[serde(default)]
pub diff_summary: Option<DiffSummary>,
}
impl RunSummary {
@ -62,6 +64,7 @@ impl RunSummary {
duration_ms: Option<u64>,
total_usd_micros: Option<i64>,
superseded_by: Option<RunId>,
diff_summary: Option<DiffSummary>,
) -> Self {
let title = truncate_goal(&goal);
let repository = RepositoryReference {
@ -90,6 +93,7 @@ impl RunSummary {
elapsed_secs,
total_usd_micros,
superseded_by,
diff_summary,
}
}
}
@ -161,6 +165,7 @@ mod tests {
use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use serde_json::json;
use super::RunSummary;
use crate::{BlockedReason, RepositoryReference, RunControlAction, RunStatus, fixtures};
@ -185,6 +190,7 @@ mod tests {
Some(42),
Some(123),
Some(fixtures::RUN_2),
None,
);
assert_eq!(summary.title, "ship it");
@ -214,6 +220,35 @@ mod tests {
assert_eq!(parsed, summary);
}
#[test]
fn summary_round_trips_diff_summary() {
let summary: RunSummary = serde_json::from_value(json!({
"run_id": fixtures::RUN_1,
"goal": "ship it",
"title": "ship it",
"labels": {},
"status": { "kind": "running" },
"repository": { "name": "fabro" },
"created_at": fixtures::RUN_1.created_at(),
"diff_summary": {
"files_changed": 3,
"additions": 12,
"deletions": 4
}
}))
.unwrap();
let value = serde_json::to_value(&summary).unwrap();
assert_eq!(
value["diff_summary"],
json!({
"files_changed": 3,
"additions": 12,
"deletions": 4
})
);
}
#[test]
fn summary_falls_back_to_source_directory_then_unknown() {
let source_only = RunSummary::new(
@ -232,6 +267,7 @@ mod tests {
None,
None,
None,
None,
);
assert_eq!(source_only.repository.name, "local-checkout");
assert_eq!(source_only.last_event_at, None);
@ -252,6 +288,7 @@ mod tests {
None,
None,
None,
None,
);
assert_eq!(unknown.repository.name, "unknown");
}

View file

@ -159,6 +159,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
total_usd_micros,
final_git_commit_sha,
final_patch,
diff_summary,
billing,
} => EventBody::RunCompleted(fabro_types::RunCompletedProps {
duration_ms: *duration_ms,
@ -168,6 +169,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
total_usd_micros: *total_usd_micros,
final_git_commit_sha: final_git_commit_sha.clone(),
final_patch: final_patch.clone(),
diff_summary: *diff_summary,
billing: billing.clone(),
}),
Event::WorkflowRunFailed {
@ -176,6 +178,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
reason,
git_commit_sha,
final_patch,
diff_summary,
} => EventBody::RunFailed(fabro_types::RunFailedProps {
error: error.to_string(),
causes: error.causes(),
@ -183,6 +186,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
reason: *reason,
git_commit_sha: git_commit_sha.clone(),
final_patch: final_patch.clone(),
diff_summary: *diff_summary,
}),
Event::RunNotice {
level,
@ -428,6 +432,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
restart_failure_signatures,
node_visits,
diff,
diff_summary,
..
} => EventBody::CheckpointCompleted(fabro_types::CheckpointCompletedProps {
status: status.clone(),
@ -442,6 +447,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
restart_failure_signatures: restart_failure_signatures.clone(),
node_visits: node_visits.clone(),
diff: diff.clone(),
diff_summary: *diff_summary,
}),
Event::CheckpointFailed {
error,
@ -1458,6 +1464,7 @@ mod tests {
reason: FailureReason::WorkflowError,
git_commit_sha: Some("abc123".to_string()),
final_patch: None,
diff_summary: None,
});
assert_eq!(stored.event_name(), "run.failed");
@ -1475,6 +1482,7 @@ mod tests {
reason: FailureReason::WorkflowError,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
});
let properties = stored.properties().unwrap();

View file

@ -1,9 +1,9 @@
use std::collections::BTreeMap;
use ::fabro_types::{
BilledTokenCounts, BlockedReason, CommandTermination, FailureReason, ForkSourceRef, GitContext,
ParallelBranchId, Principal, PullRequestRecord, RunBlobId, RunId, RunNoticeLevel,
RunProvenance, StageId, SuccessReason, run_event as fabro_types,
BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason,
ForkSourceRef, GitContext, ParallelBranchId, Principal, PullRequestRecord, RunBlobId, RunId,
RunNoticeLevel, RunProvenance, StageId, SuccessReason, run_event as fabro_types,
};
use fabro_agent::{AgentEvent, SandboxEvent};
use serde::{Deserialize, Serialize};
@ -123,6 +123,8 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
diff_summary: Option<DiffSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
billing: Option<BilledTokenCounts>,
},
WorkflowRunFailed {
@ -133,6 +135,8 @@ pub enum Event {
git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
diff_summary: Option<DiffSummary>,
},
RunNotice {
level: RunNoticeLevel,
@ -321,6 +325,8 @@ pub enum Event {
node_visits: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
diff: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
diff_summary: Option<DiffSummary>,
},
CheckpointFailed {
node_id: String,

View file

@ -545,6 +545,7 @@ mod tests {
restart_failure_signatures: std::collections::BTreeMap::new(),
node_visits: std::collections::BTreeMap::from([("work".into(), 2)]),
diff: Some("diff --git a/story.txt b/story.txt".into()),
diff_summary: None,
})
.await
.unwrap();

View file

@ -369,6 +369,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
let git_sha = git_result.as_ref().and_then(|r| r.commit_sha.clone());
let diff = git_result.as_ref().and_then(|r| r.diff.clone());
let diff_summary = git_result.as_ref().and_then(|r| r.diff_summary);
let (loop_failure_signatures, restart_failure_signatures) =
snapshot_failure_signatures(&self.circuit_breaker);
let context_values = artifact::durable_context_snapshot(&state.context);
@ -400,6 +401,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
.into_iter()
.collect::<BTreeMap<_, _>>(),
diff,
diff_summary,
},
&scope,
);

View file

@ -8,8 +8,8 @@ use fabro_core::lifecycle::RunLifecycle;
use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use fabro_dump::RunDump;
use fabro_types::RunId;
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
use fabro_types::{DiffSummary, RunId};
use fabro_util::error::collect_causes;
use fabro_util::time::elapsed_ms;
@ -21,7 +21,9 @@ use crate::outcome::BilledModelUsage;
use crate::run_metadata::{MetadataSnapshot, RunMetadataRuntime, RunMetadataWriterHandle};
use crate::run_options::RunOptions;
use crate::runtime_store::RunStoreHandle;
use crate::sandbox_git::{checked_git_checkpoint, git_diff};
use crate::sandbox_git::{
checked_git_checkpoint, git_diff, list_diff_numstat, summarize_diff_numstat,
};
use crate::sandbox_git_runtime::SandboxGitRuntime;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
@ -61,6 +63,7 @@ pub(crate) struct GitCheckpointResult {
pub commit_sha: Option<String>,
pub push_results: Vec<PushResult>,
pub diff: Option<String>,
pub diff_summary: Option<DiffSummary>,
}
#[derive(Debug, Clone)]
@ -279,6 +282,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
commit_sha: Some(sha.clone()),
push_results: Vec::new(),
diff: None,
diff_summary: None,
};
// Push run branch (skip in dry-run mode)
@ -326,7 +330,21 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
.and_then(|g| g.base_sha.clone())
});
if let Some(prev) = prev.filter(|p| p != &sha) {
match git_diff(&*self.sandbox, &prev).await {
let summary_base = self
.run_options
.git
.as_ref()
.and_then(|git| git.base_sha.clone());
let (patch_result, numstat_result) =
tokio::join!(git_diff(&*self.sandbox, &prev), async {
match summary_base.as_deref() {
Some(base) if base != sha => {
Some(list_diff_numstat(&*self.sandbox, base, &sha).await)
}
_ => None,
}
},);
match patch_result {
Ok(patch) if !patch.is_empty() => {
git_result.diff = Some(patch);
}
@ -342,6 +360,22 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
);
}
}
match numstat_result {
Some(Ok(numstat)) => {
git_result.diff_summary = Some(summarize_diff_numstat(&numstat));
}
Some(Err(err)) => {
let exec_output_tail =
fabro_sandbox::default_redacted_output_tail(&err);
self.emitter.notice_with_tail(
RunNoticeLevel::Warn,
RunNoticeCode::GitDiffFailed,
format!("[node: {node_id}] git diff stats failed: {err}"),
exec_output_tail,
);
}
None => {}
}
}
// Update shared state
@ -586,6 +620,39 @@ mod tests {
assert!(commit.status.success());
}
#[expect(
clippy::disallowed_methods,
reason = "metadata event tests use synchronous git commands to set up temporary repositories"
)]
fn git_commit_all(repo: &Path, msg: &str) -> String {
let add = std::process::Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
assert!(add.status.success());
let commit = std::process::Command::new("git")
.args(["commit", "-m", msg])
.current_dir(repo)
.output()
.unwrap();
assert!(
commit.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&commit.stderr)
);
let rev_parse = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
assert!(rev_parse.status.success());
String::from_utf8(rev_parse.stdout)
.unwrap()
.trim()
.to_string()
}
fn workflow_graph() -> WorkflowGraph {
let mut graph = Graph::new("metadata");
let mut start = Node::new("start");
@ -954,6 +1021,76 @@ mod tests {
);
}
#[tokio::test]
async fn checkpoint_git_result_includes_diff_summary() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
tokio::fs::write(repo.join("notes.txt"), "one\n")
.await
.unwrap();
let base = git_commit_all(repo, "base");
tokio::fs::write(repo.join("notes.txt"), "one\ntwo\n")
.await
.unwrap();
let mut options = run_options(repo, "fabro/metadata/run").as_ref().clone();
options.git = Some(GitCheckpointOptions {
base_sha: Some(base),
run_branch: None,
meta_branch: None,
});
let lifecycle = git_lifecycle_with_writer(
repo,
Arc::new(Emitter::new(fixtures::RUN_1)),
RunStoreHandle::local(run_store(fixtures::RUN_1).await),
Arc::new(options),
Arc::new(RunMetadataRuntime::new()),
None,
);
let graph = workflow_graph();
let node = graph.get_node("build").unwrap();
let mut state = ExecutionState::new(&graph).unwrap();
state.increment_visits("build");
let result = WfNodeResult::new(Outcome::success(), Duration::from_millis(10), 1, 1);
lifecycle
.on_checkpoint(&node, &result, Some("exit"), &state)
.await
.unwrap();
let git_result = lifecycle
.checkpoint_git_result
.lock()
.unwrap()
.clone()
.unwrap();
let diff_summary = git_result.diff_summary.expect("diff summary");
assert_eq!(diff_summary.files_changed, 1);
assert_eq!(diff_summary.additions, 1);
assert_eq!(diff_summary.deletions, 0);
tokio::fs::write(repo.join("notes.txt"), "one\ntwo\nthree\n")
.await
.unwrap();
state.increment_visits("build");
lifecycle
.on_checkpoint(&node, &result, Some("exit"), &state)
.await
.unwrap();
let git_result = lifecycle
.checkpoint_git_result
.lock()
.unwrap()
.clone()
.unwrap();
let diff_summary = git_result.diff_summary.expect("diff summary");
assert_eq!(diff_summary.files_changed, 1);
assert_eq!(diff_summary.additions, 2);
assert_eq!(diff_summary.deletions, 0);
}
#[tokio::test]
async fn degraded_metadata_runtime_skips_snapshot_events() {
let repo_dir = tempfile::tempdir().unwrap();

View file

@ -159,6 +159,7 @@ mod tests {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
})
.await
@ -173,6 +174,7 @@ mod tests {
reason: FailureReason::WorkflowError,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
})
.await
.unwrap();

View file

@ -289,6 +289,7 @@ fn checkpoint_completed_event(checkpoint: &Checkpoint) -> Event {
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
diff_summary: None,
}
}
@ -412,6 +413,7 @@ mod tests {
restart_failure_signatures: BTreeMap::new(),
node_visits,
diff: None,
diff_summary: None,
})
.await
.unwrap();

View file

@ -274,6 +274,7 @@ async fn persist_terminal_engine_failure(
reason,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
})
.await
{
@ -899,6 +900,7 @@ impl Drop for DetachedRunBootstrapGuard {
reason,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
})
.await;
});
@ -964,6 +966,7 @@ impl Drop for DetachedRunCompletionGuard {
reason,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
})
.await;
let _ = append_event_to_sink(&event_sink, &run_id, &Event::RunNotice {
@ -994,6 +997,7 @@ async fn persist_detached_failure(
reason,
git_commit_sha: None,
final_patch: None,
diff_summary: None,
})
.await
{
@ -1177,6 +1181,7 @@ mod tests {
restart_failure_signatures: HashMap::new().into_iter().collect(),
node_visits: HashMap::new().into_iter().collect(),
diff: None,
diff_summary: None,
});
}
});
@ -1389,6 +1394,7 @@ mod tests {
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
diff_summary: None,
},
)
.await
@ -1479,6 +1485,7 @@ mod tests {
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
diff_summary: None,
})
.await
.unwrap();
@ -1496,6 +1503,7 @@ mod tests {
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
})
.await

View file

@ -4,7 +4,7 @@ use std::time::Instant;
use fabro_dump::RunDump;
use fabro_hooks::{HookContext, HookEvent};
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
use fabro_types::{BilledTokenCounts, EventBody, RunProjection};
use fabro_types::{BilledTokenCounts, DiffSummary, EventBody, RunProjection};
use fabro_util::error::collect_causes;
use fabro_util::time::elapsed_ms;
@ -17,7 +17,7 @@ use crate::run_metadata::MetadataSnapshot;
use crate::run_options::RunOptions;
use crate::run_status::{FailureReason, RunStatus, SuccessReason};
use crate::runtime_store::RunStoreHandle;
use crate::sandbox_git::git_diff_with_timeout;
use crate::sandbox_git::{git_diff_with_timeout, list_diff_numstat, summarize_diff_numstat};
use crate::services::RunServices;
use crate::{ProjectionBillingRollup, billing_rollup_from_projection};
@ -391,13 +391,20 @@ async fn compute_final_patch(
run_options: &RunOptions,
services: &RunServices,
status: StageOutcome,
) -> Option<String> {
let base_sha = run_options.git.as_ref().and_then(|g| g.base_sha.clone())?;
) -> (Option<String>, Option<DiffSummary>) {
let Some(base_sha) = run_options.git.as_ref().and_then(|g| g.base_sha.clone()) else {
return (None, None);
};
let timeout_ms = match status {
StageOutcome::Succeeded | StageOutcome::PartiallySucceeded => 30_000,
_ => 10_000,
};
match git_diff_with_timeout(&*services.sandbox, &base_sha, timeout_ms).await {
let to_sha = "HEAD";
let (patch_result, numstat_result) = tokio::join!(
git_diff_with_timeout(&*services.sandbox, &base_sha, timeout_ms),
list_diff_numstat(&*services.sandbox, &base_sha, to_sha),
);
let final_patch = match patch_result {
Ok(patch) if !patch.is_empty() => Some(patch),
Ok(_) => None,
Err(err) => {
@ -408,7 +415,19 @@ async fn compute_final_patch(
);
None
}
}
};
let diff_summary = match numstat_result {
Ok(numstat) => Some(summarize_diff_numstat(&numstat)),
Err(err) => {
services.emitter.notice(
RunNoticeLevel::Warn,
RunNoticeCode::GitDiffFailed,
format!("final diff stats failed: {err}"),
);
None
}
};
(final_patch, diff_summary)
}
pub(crate) fn billing_from_projection(projection: &RunProjection) -> Option<BilledTokenCounts> {
@ -421,6 +440,7 @@ pub(crate) fn build_terminal_event(
artifact_count: usize,
final_git_commit_sha: Option<String>,
final_patch: Option<String>,
diff_summary: Option<DiffSummary>,
billing: Option<BilledTokenCounts>,
) -> Event {
if matches!(outcome, Err(Error::Cancelled)) {
@ -430,6 +450,7 @@ pub(crate) fn build_terminal_event(
reason: FailureReason::Cancelled,
git_commit_sha: final_git_commit_sha,
final_patch,
diff_summary,
};
}
@ -455,6 +476,7 @@ pub(crate) fn build_terminal_event(
total_usd_micros,
final_git_commit_sha,
final_patch,
diff_summary,
billing,
};
}
@ -473,6 +495,7 @@ pub(crate) fn build_terminal_event(
reason: FailureReason::WorkflowError,
git_commit_sha: final_git_commit_sha,
final_patch,
diff_summary,
}
}
@ -542,7 +565,7 @@ pub async fn finalize(retroed: Retroed, options: &FinalizeOptions) -> Result<Con
options.last_git_sha.clone(),
);
let (final_patch, ()) = tokio::join!(
let ((final_patch, diff_summary), ()) = tokio::join!(
compute_final_patch(&run_options, &services, final_status),
write_finalize_commit(&run_options, &services, &conclusion),
);
@ -561,6 +584,7 @@ pub async fn finalize(retroed: Retroed, options: &FinalizeOptions) -> Result<Con
artifact_count,
options.last_git_sha.clone(),
final_patch,
diff_summary,
conclusion.billing.clone(),
);
services.emitter.emit(&terminal_event);
@ -724,6 +748,39 @@ mod tests {
assert!(commit.status.success());
}
#[expect(
clippy::disallowed_methods,
reason = "metadata event tests use synchronous git commands to set up temporary repositories"
)]
fn git_commit_all(repo: &Path, msg: &str) -> String {
let add = std::process::Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
assert!(add.status.success());
let commit = std::process::Command::new("git")
.args(["commit", "-m", msg])
.current_dir(repo)
.output()
.unwrap();
assert!(
commit.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&commit.stderr)
);
let rev_parse = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
assert!(rev_parse.status.success());
String::from_utf8(rev_parse.stdout)
.unwrap()
.trim()
.to_string()
}
fn record_events(emitter: &Arc<Emitter>) -> Arc<std::sync::Mutex<Vec<RunEvent>>> {
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured = Arc::clone(&events);
@ -1160,6 +1217,71 @@ mod tests {
]);
}
#[tokio::test]
async fn finalize_terminal_event_includes_diff_summary() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
tokio::fs::write(repo.join("notes.txt"), "one\n")
.await
.unwrap();
let base = git_commit_all(repo, "base");
tokio::fs::write(repo.join("notes.txt"), "one\ntwo\nthree\n")
.await
.unwrap();
let head = git_commit_all(repo, "head");
let run_store = seeded_run_store().await;
let emitter = Arc::new(Emitter::new(test_run_id()));
let events = record_events(&emitter);
let services = test_services(
RunStoreHandle::local(run_store),
Arc::clone(&emitter),
Arc::new(fabro_agent::LocalSandbox::new(repo.to_path_buf())),
Arc::new(RunMetadataRuntime::new()),
None,
);
let mut run_options = test_git_run_options(repo, "fabro/metadata/run");
run_options.git = Some(GitCheckpointOptions {
base_sha: Some(base),
run_branch: None,
meta_branch: None,
});
let retroed = Retroed {
graph: Graph::new("test"),
outcome: Ok(Outcome::success()),
run_options,
duration_ms: 5,
services,
retro: None,
};
finalize(retroed, &FinalizeOptions {
run_dir: repo.to_path_buf(),
run_id: test_run_id(),
workflow_name: "test".to_string(),
preserve_sandbox: true,
last_git_sha: Some(head),
})
.await
.unwrap();
let events = events.lock().unwrap();
let run_completed = events
.iter()
.find(|event| event.event_name() == "run.completed")
.expect("run.completed event");
let properties = run_completed.properties().unwrap();
assert_eq!(
properties["diff_summary"],
serde_json::json!({
"files_changed": 1,
"additions": 2,
"deletions": 0
})
);
}
struct FailingStateStore;
#[async_trait]

View file

@ -1709,6 +1709,7 @@ mod tests {
final_patch: Some(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n".to_string(),
),
diff_summary: None,
billing: None,
})
.await
@ -1997,6 +1998,7 @@ mod tests {
final_patch: Some(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n".to_string(),
),
diff_summary: None,
billing: None,
})
.await

View file

@ -294,6 +294,7 @@ mod tests {
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
diff_summary: None,
})
.await
.unwrap();

View file

@ -564,7 +564,7 @@ fn classify_entry(
})
}
pub use fabro_types::DiffStats;
pub use fabro_types::{DiffStats, DiffSummary};
/// Output of `git diff --numstat`: which paths are binary, plus per-path
/// `+/-` line totals for text files in the range. Both pieces come from a
@ -577,6 +577,27 @@ pub struct DiffNumstat {
pub line_stats_by_path: HashMap<String, DiffStats>,
}
pub fn summarize_diff_numstat(numstat: &DiffNumstat) -> DiffSummary {
let text_files = i64::try_from(numstat.line_stats_by_path.len()).unwrap_or(i64::MAX);
let binary_files = i64::try_from(numstat.binary_paths.len()).unwrap_or(i64::MAX);
let (additions, deletions) =
numstat
.line_stats_by_path
.values()
.fold((0_i64, 0_i64), |(adds, dels), stats| {
(
adds.saturating_add(stats.additions),
dels.saturating_add(stats.deletions),
)
});
DiffSummary {
files_changed: text_files.saturating_add(binary_files),
additions,
deletions,
}
}
/// Run `git diff --numstat` once and return both the set of binary paths and
/// text-file `+/-` totals. The single call replaces the previous binary-only
/// helper.

View file

@ -43,6 +43,7 @@ async fn execute_and_emit_terminal(initialized: InitializedState) -> Executed {
0,
None,
None,
None,
billing,
);
executed.engine.run.emitter.emit(&event);

View file

@ -65,6 +65,7 @@ models/diagnostics-report.ts
models/diagnostics-section.ts
models/diff-file.ts
models/diff-stats.ts
models/diff-summary.ts
models/dirty-status.ts
models/discord-integration-settings.ts
models/disk-usage-response.ts

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Cheap aggregate file and line counts for a run diff.
*/
export interface DiffSummary {
/**
* Total number of changed files, including binary files.
*/
'files_changed': number;
/**
* Total lines added across text files.
*/
'additions': number;
/**
* Total lines deleted across text files.
*/
'deletions': number;
}

View file

@ -45,6 +45,7 @@ export * from './diagnostics-report';
export * from './diagnostics-section';
export * from './diff-file';
export * from './diff-stats';
export * from './diff-summary';
export * from './dirty-status';
export * from './discord-integration-settings';
export * from './disk-usage-response';

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { DiffSummary } from './diff-summary';
// May contain unused imports in some cases
// @ts-ignore
import type { PendingInterviewRecord } from './pending-interview-record';
@ -57,6 +60,7 @@ export interface RunProjection {
'retro_response'?: string | null;
'sandbox'?: { [key: string]: any; } | null;
'final_patch'?: string | null;
'diff_summary'?: DiffSummary | null;
'pull_request'?: { [key: string]: any; } | null;
'superseded_by'?: string | null;
'pending_interviews'?: { [key: string]: PendingInterviewRecord; };

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { DiffSummary } from './diff-summary';
// May contain unused imports in some cases
// @ts-ignore
import type { RepositoryReference } from './repository-reference';
@ -46,6 +49,7 @@ export interface RunSummary {
'elapsed_secs'?: number | null;
'total_usd_micros'?: number | null;
'superseded_by'?: string | null;
'diff_summary'?: DiffSummary | null;
}