Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions cli/src/services/doctor/fixes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,11 @@ pub(super) fn build_manual_fix_results(report: &HookDoctorReport) -> Vec<DoctorF
report
.problems
.iter()
.filter(|problem| problem.fixability != ProblemFixability::AutoFixable)
.filter(|problem| problem.fixability == ProblemFixability::ManualOnly)
.map(|problem| DoctorFixResultRecord {
category: problem.category,
outcome: FixResult::Manual,
detail: match problem.fixability {
ProblemFixability::AutoFixable => {
unreachable!("auto-fixable problems should not be rendered as manual results")
}
ProblemFixability::ManualOnly => {
format!("{} Manual remediation is still required.", problem.summary)
}
},
detail: format!("{} Manual remediation is still required.", problem.summary),
})
.collect()
}
742 changes: 722 additions & 20 deletions cli/src/services/doctor/inspect.rs

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions cli/src/services/doctor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ fn doctor_problem_category(category: HealthCategory) -> ProblemCategory {
HealthCategory::HookRollout => ProblemCategory::HookRollout,
HealthCategory::RepoAssets => ProblemCategory::RepoAssets,
HealthCategory::FilesystemPermissions => ProblemCategory::FilesystemPermissions,
HealthCategory::MutationScopeHealth => ProblemCategory::MutationScopeHealth,
}
}

Expand All @@ -262,6 +263,7 @@ fn health_problem_category(category: ProblemCategory) -> HealthCategory {
ProblemCategory::HookRollout => HealthCategory::HookRollout,
ProblemCategory::RepoAssets => HealthCategory::RepoAssets,
ProblemCategory::FilesystemPermissions => HealthCategory::FilesystemPermissions,
ProblemCategory::MutationScopeHealth => HealthCategory::MutationScopeHealth,
}
}

Expand All @@ -283,13 +285,15 @@ fn doctor_problem_fixability(fixability: HealthFixability) -> ProblemFixability
match fixability {
HealthFixability::AutoFixable => ProblemFixability::AutoFixable,
HealthFixability::ManualOnly => ProblemFixability::ManualOnly,
HealthFixability::NoActionRequired => ProblemFixability::NoActionRequired,
}
}

fn health_problem_fixability(fixability: ProblemFixability) -> HealthFixability {
match fixability {
ProblemFixability::AutoFixable => HealthFixability::AutoFixable,
ProblemFixability::ManualOnly => HealthFixability::ManualOnly,
ProblemFixability::NoActionRequired => HealthFixability::NoActionRequired,
}
}

Expand Down Expand Up @@ -364,6 +368,11 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind {
ProblemKind::AgentTraceDbConnectionFailed
}
HealthProblemKind::AgentTraceDbSchemaNotReady => ProblemKind::AgentTraceDbSchemaNotReady,
HealthProblemKind::MutationScopeHealthRecovering => {
ProblemKind::MutationScopeHealthRecovering
}
HealthProblemKind::MutationScopeHealthBlocked => ProblemKind::MutationScopeHealthBlocked,
HealthProblemKind::MutationScopeHealthInvalid => ProblemKind::MutationScopeHealthInvalid,
}
}

Expand Down Expand Up @@ -438,6 +447,11 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind {
HealthProblemKind::AgentTraceDbConnectionFailed
}
ProblemKind::AgentTraceDbSchemaNotReady => HealthProblemKind::AgentTraceDbSchemaNotReady,
ProblemKind::MutationScopeHealthRecovering => {
HealthProblemKind::MutationScopeHealthRecovering
}
ProblemKind::MutationScopeHealthBlocked => HealthProblemKind::MutationScopeHealthBlocked,
ProblemKind::MutationScopeHealthInvalid => HealthProblemKind::MutationScopeHealthInvalid,
}
}

Expand Down
199 changes: 193 additions & 6 deletions cli/src/services/doctor/render.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
use anyhow::{Context, Result};
use serde_json::json;

use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus;
use crate::services::style::{heading, label, supports_color, value, OwoColorize};

use super::types::{
fix_result_outcome, problem_category, problem_fixability, problem_severity,
DoctorDisplayDetail, DoctorDisplayNode, DoctorDisplayNodeKind, DoctorDisplayStatus,
HookContentState, HookDoctorReport, HookFileHealth, HookPathSource, IntegrationArea,
IntegrationChildHealth, IntegrationContentState, IntegrationGroupHealth, IntegrationGroupKey,
IntegrationTarget, PostCommitAutoSyncState, ProblemKind, ProblemSeverity, Readiness,
fix_result_outcome, mutation_scope_health_status, mutation_scope_target_id, problem_category,
problem_fixability, problem_severity, DoctorDisplayDetail, DoctorDisplayNode,
DoctorDisplayNodeKind, DoctorDisplayStatus, HookContentState, HookDoctorReport, HookFileHealth,
HookPathSource, IntegrationArea, IntegrationChildHealth, IntegrationContentState,
IntegrationGroupHealth, IntegrationGroupKey, IntegrationTarget, MutationScopeHealthRow,
PostCommitAutoSyncState, ProblemKind, ProblemSeverity, Readiness,
};
use super::{DoctorExecution, DoctorFormat, DoctorMode, DoctorRequest, NAME};

Expand Down Expand Up @@ -139,6 +141,10 @@ fn format_report_with_color_policy(report: &HookDoctorReport, color_enabled: boo
let node = integration_group_node(&group, report);
render_display_node(&mut lines, &node, color_enabled, 4, true);
}
if let Some(row) = mutation_scope_health_row_for_target(report, target) {
let node = mutation_scope_health_node(row);
render_display_node(&mut lines, &node, color_enabled, 4, true);
}
}
}

Expand Down Expand Up @@ -625,9 +631,48 @@ fn render_display_detail(lines: &mut Vec<String>, detail: &DoctorDisplayDetail,
lines.push(format!("{prefix}Problem: {summary}"));
lines.push(format!("{prefix}Remediation: {remediation}"));
}
DoctorDisplayDetail::MutationScopeHealth { reason, detail } => {
lines.push(format!("{prefix}Reason: {reason}"));
if let Some(detail) = detail {
lines.push(format!("{prefix}Detail: {detail}"));
}
}
}
}

fn mutation_scope_health_row_for_target(
report: &HookDoctorReport,
target: IntegrationTarget,
) -> Option<&MutationScopeHealthRow> {
report
.mutation_scope_health
.iter()
.find(|row| row.target == target)
}

fn mutation_scope_health_display_status(status: MutationScopeHealthStatus) -> DoctorDisplayStatus {
match status {
MutationScopeHealthStatus::Healthy => DoctorDisplayStatus::Pass,
MutationScopeHealthStatus::Recovering => DoctorDisplayStatus::Warn,
MutationScopeHealthStatus::Blocked | MutationScopeHealthStatus::Invalid => {
DoctorDisplayStatus::Fail
}
}
}

fn mutation_scope_health_node(row: &MutationScopeHealthRow) -> DoctorDisplayNode {
DoctorDisplayNode::branch_with_status(
DoctorDisplayNodeKind::Asset,
"Agent tracing",
mutation_scope_health_display_status(row.status),
vec![DoctorDisplayDetail::MutationScopeHealth {
reason: row.reason.clone(),
detail: row.detail.clone(),
}],
Vec::new(),
)
}

fn integration_targets_for_text(report: &HookDoctorReport) -> Vec<IntegrationTarget> {
[
IntegrationTarget::ClaudeCode,
Expand Down Expand Up @@ -659,7 +704,7 @@ fn groups_for_target(
groups
}

fn integration_target_label(target: IntegrationTarget) -> &'static str {
pub(super) fn integration_target_label(target: IntegrationTarget) -> &'static str {
match target {
IntegrationTarget::ClaudeCode => "Claude Code",
IntegrationTarget::OpenCode => "OpenCode",
Expand Down Expand Up @@ -721,6 +766,19 @@ fn integration_area_order(target: IntegrationTarget, area: IntegrationArea) -> u
}
}

fn mutation_scope_health_json(rows: &[MutationScopeHealthRow]) -> Vec<serde_json::Value> {
rows.iter()
.map(|row| {
json!({
"target": mutation_scope_target_id(row.target),
"status": mutation_scope_health_status(row.status),
"reason": row.reason,
"detail": row.detail,
})
})
.collect::<Vec<_>>()
}

fn render_report_json(execution: &DoctorExecution) -> Result<String> {
let report = &execution.report;
let hooks = report
Expand Down Expand Up @@ -750,6 +808,8 @@ fn render_report_json(execution: &DoctorExecution) -> Result<String> {
})
.collect::<Vec<_>>();

let mutation_scope_health = mutation_scope_health_json(&report.mutation_scope_health);

let payload = json!({
"status": "ok",
"command": NAME,
Expand Down Expand Up @@ -797,6 +857,7 @@ fn render_report_json(execution: &DoctorExecution) -> Result<String> {
},
"config_paths": config_paths,
"hooks": hooks,
"mutation_scope_health": mutation_scope_health,
"problems": report.problems.iter().map(|problem| json!({
"category": problem_category(problem.category),
"severity": problem_severity(problem.severity),
Expand Down Expand Up @@ -852,3 +913,129 @@ fn hook_content_state(state: HookContentState) -> &'static str {
HookContentState::Unknown => "unknown",
}
}

#[cfg(test)]
mod tests {
use super::{
mutation_scope_health_node, mutation_scope_health_status, mutation_scope_target_id,
render_display_node, DoctorDisplayStatus, IntegrationTarget, MutationScopeHealthRow,
MutationScopeHealthStatus,
};

fn row(
target: IntegrationTarget,
status: MutationScopeHealthStatus,
reason: &str,
detail: Option<&str>,
) -> MutationScopeHealthRow {
MutationScopeHealthRow {
target,
status,
reason: reason.to_string(),
detail: detail.map(str::to_string),
}
}

fn rendered_lines(row: &MutationScopeHealthRow) -> Vec<String> {
let node = mutation_scope_health_node(row);
let mut lines = Vec::new();
render_display_node(&mut lines, &node, false, 4, true);
lines
}

#[test]
fn healthy_row_collapses_to_a_single_pass_line() {
let row = row(
IntegrationTarget::ClaudeCode,
MutationScopeHealthStatus::Healthy,
"no persisted recovery problem",
None,
);

assert_eq!(
rendered_lines(&row),
vec![" [PASS] Agent tracing".to_string()]
);
}

#[test]
fn recovering_row_expands_with_warn_and_reason() {
let row = row(
IntegrationTarget::Codex,
MutationScopeHealthStatus::Recovering,
"pending recovery with no unresolved attempts",
None,
);
let lines = rendered_lines(&row);

assert_eq!(lines[0], " [WARN] Agent tracing");
assert!(lines
.iter()
.any(|line| line.contains("Reason: pending recovery with no unresolved attempts")));
}

#[test]
fn blocked_row_expands_with_fail_reason_and_detail() {
let row = row(
IntegrationTarget::ClaudeCode,
MutationScopeHealthStatus::Blocked,
"stale attempts remain after a failed abandon",
Some("2 stale attempts"),
);
let lines = rendered_lines(&row);

assert_eq!(lines[0], " [FAIL] Agent tracing");
assert!(lines
.iter()
.any(|line| line.contains("Reason: stale attempts remain after a failed abandon")));
assert!(lines
.iter()
.any(|line| line.contains("Detail: 2 stale attempts")));
}

#[test]
fn invalid_row_maps_to_fail() {
let row = row(
IntegrationTarget::Pi,
MutationScopeHealthStatus::Invalid,
"state file failed to parse",
Some("unexpected EOF"),
);

assert_eq!(
mutation_scope_health_node(&row).status,
DoctorDisplayStatus::Fail
);
}

#[test]
fn json_slugs_are_stable() {
assert_eq!(
mutation_scope_target_id(IntegrationTarget::ClaudeCode),
"claude"
);
assert_eq!(
mutation_scope_target_id(IntegrationTarget::OpenCode),
"opencode"
);
assert_eq!(mutation_scope_target_id(IntegrationTarget::Pi), "pi");
assert_eq!(mutation_scope_target_id(IntegrationTarget::Codex), "codex");

assert_eq!(
mutation_scope_health_status(MutationScopeHealthStatus::Healthy),
"healthy"
);
assert_eq!(
mutation_scope_health_status(MutationScopeHealthStatus::Recovering),
"recovering"
);
assert_eq!(
mutation_scope_health_status(MutationScopeHealthStatus::Blocked),
"blocked"
);
assert_eq!(
mutation_scope_health_status(MutationScopeHealthStatus::Invalid),
"invalid"
);
}
}
Loading
Loading