Skip to content
Merged
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
89 changes: 88 additions & 1 deletion apps/staged/src-tauri/src/doctor.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
//! Tauri command wrappers for the doctor health-check system.

pub use doctor::{CheckStatus, DoctorCheck, DoctorReport, FixType};
pub use doctor::types::{AuthStatus, InstallSource};
pub use doctor::{
AgentVersionInfo, CheckStatus, DoctorCheck, DoctorReport, FixType, RunChecksOptions,
};

/// Run all health checks and return the report.
///
/// This is the cheap, no-network path: it resolves binaries and reports
/// install/auth status but does not probe registries for version freshness.
/// The frontend calls this first for an instant paint, then follows up with
/// [`run_doctor_freshness`] to fill in version/update information.
#[tauri::command]
pub async fn run_doctor() -> DoctorReport {
doctor::run_checks().await
}

/// Run all health checks with version freshness enabled.
///
/// This is the slower second pass: it probes each readout's installed version
/// and looks up the latest version from the relevant registry (npm, brew,
/// crates.io, GitHub releases), populating `installedVersion`, `latestVersion`,
/// `updateAvailable`, and the source-aware `updateCommand`/`updateFixType` on
/// each readout. Hits the network, so it must never block first paint.
#[tauri::command]
pub async fn run_doctor_freshness() -> DoctorReport {
doctor::run_checks_with_options(RunChecksOptions {
check_freshness: true,
offline: false,
// Use the default public registries — Staged installs these agents
// from public npm/brew/crates.io, not an internal mirror.
npm_registry: None,
})
.await
}

/// Run a fix for a doctor check, identified by check ID and fix type.
///
/// The actual shell command is looked up from the static check definitions —
Expand All @@ -16,3 +43,63 @@ pub async fn run_doctor() -> DoctorReport {
pub async fn run_doctor_fix(check_id: String, fix_type: FixType) -> Result<(), String> {
doctor::execute_fix(check_id, fix_type).await
}

/// Run a source-aware update for a single readout (main CLI or ACP bridge).
///
/// Unlike [`run_doctor_fix`], update commands (`UpdateMain`/`UpdateBridge`) are
/// derived per-readout at freshness time rather than living in the static check
/// table, so the executor needs the command passed in as an override.
///
/// **Trust boundary:** we do not execute the frontend-supplied `command`
/// blindly. We re-run freshness, re-derive the expected `updateCommand` for
/// `(check_id, fix_type)` backend-side, and only proceed if the two match. This
/// keeps `run_doctor_update` from becoming an arbitrary-shell-exec hole — the
/// `command` argument is effectively a confirmation of what the UI displayed,
/// validated against the authoritative backend derivation.
#[tauri::command]
pub async fn run_doctor_update(
check_id: String,
fix_type: FixType,
command: String,
) -> Result<(), String> {
let expected = expected_update_command(&check_id, &fix_type).await?;
if expected != command {
return Err(format!(
"Update command mismatch for {check_id}: refusing to run a command \
that does not match the backend-derived update command."
));
}
// Run the backend-derived `expected`, not the frontend-supplied `command`.
// They are equal past the guard above, but executing `expected` makes the
// command that runs provably the one the backend derived — no dependence on
// the equality check surviving future edits.
doctor::execute_fix_with_options(check_id, fix_type, Some(expected), None).await
}

/// Re-run freshness and return the authoritative update command for the given
/// check + slot, or an error if no actionable update is derivable.
async fn expected_update_command(check_id: &str, fix_type: &FixType) -> Result<String, String> {
let report = doctor::run_checks_with_options(RunChecksOptions {
check_freshness: true,
offline: false,
npm_registry: None,
})
.await;

let check = report
.checks
.iter()
.find(|c| c.id == check_id)
.ok_or_else(|| format!("No such check: {check_id}"))?;

// The fix type selects which readout's update applies.
let readout = match fix_type {
FixType::UpdateMain => check.main.as_ref(),
FixType::UpdateBridge => check.bridge.as_ref(),
_ => return Err(format!("{fix_type:?} is not an update fix type")),
};

readout
.and_then(|r| r.update_command.clone())
.ok_or_else(|| format!("No actionable update available for {check_id}"))
}
2 changes: 2 additions & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2313,7 +2313,9 @@ pub fn run() {
review_commands::remove_reference_file,
// Doctor
doctor::run_doctor,
doctor::run_doctor_freshness,
doctor::run_doctor_fix,
doctor::run_doctor_update,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
Expand Down
11 changes: 11 additions & 0 deletions apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3654,12 +3654,23 @@ Begin the note with a markdown H1 heading as the title.\n\n"
let report = doctor::run_checks().await;
Ok(serde_json::to_value(report).unwrap())
}
"run_doctor_freshness" => {
let report = crate::doctor::run_doctor_freshness().await;
Ok(serde_json::to_value(report).unwrap())
}
"run_doctor_fix" => {
let check_id: String = arg(&args, "checkId")?;
let fix_type: doctor::FixType = arg(&args, "fixType")?;
doctor::execute_fix(check_id, fix_type).await?;
Ok(Value::Null)
}
"run_doctor_update" => {
let check_id: String = arg(&args, "checkId")?;
let fix_type: doctor::FixType = arg(&args, "fixType")?;
let command: String = arg(&args, "command")?;
crate::doctor::run_doctor_update(check_id, fix_type, command).await?;
Ok(Value::Null)
}

_ => Err(format!("Unknown command: {command}")),
}
Expand Down
76 changes: 73 additions & 3 deletions apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,33 +1024,103 @@ export function squashCommits(branchId: string, provider?: string): Promise<stri
// Doctor (Health Check)
// =============================================================================

export type DoctorFixType = 'command' | 'bridge' | 'auth' | 'updateMain' | 'updateBridge';

export type DoctorInstallSource =
| 'brew'
| 'npm'
| 'cargo'
| 'mise'
| 'asdf'
| 'curlPipe'
| 'system'
| 'unknown';

/**
* Version + install-source readout for one binary behind an agent check.
*
* An AI-agent check may front two distinct binaries — the agent's own CLI
* (`main`) and its ACP bridge (`bridge`) — each versioned independently. Only
* `installSource` is populated on the cheap path; the freshness pass fills in
* the rest. Note `updateAvailable` is suppressed (null) for self-updating
* tools, so treat null as "no actionable update", never as "up to date".
*/
export interface AgentVersionInfo {
installSource: DoctorInstallSource | null;
installedVersion: string | null;
latestVersion: string | null;
updateAvailable: boolean | null;
selfUpdating: boolean | null;
/** Source-aware update command. Non-null only when an update is actionable. */
updateCommand: string | null;
/** 'updateMain' or 'updateBridge', matching this readout's slot. */
updateFixType: 'updateMain' | 'updateBridge' | null;
}

export interface DoctorCheck {
id: string;
label: string;
status: 'pass' | 'warn' | 'fail';
message: string;
fixUrl: string | null;
fixCommand: string | null;
fixType: 'command' | 'bridge' | null;
fixType: DoctorFixType | null;
path: string | null;
bridgePath: string | null;
rawOutput: string | null;
authStatus: 'authenticated' | 'notAuthenticated' | 'notApplicable' | 'unknown' | null;
/** Flat version fields mirror the bridge readout (else main) for compat. */
installedVersion: string | null;
latestVersion: string | null;
updateAvailable: boolean | null;
installSource: DoctorInstallSource | null;
selfUpdating: boolean | null;
/** Independent readout for the agent's own CLI (e.g. `claude`, `codex`). */
main: AgentVersionInfo | null;
/** Independent readout for the agent's ACP bridge (e.g. `claude-agent-acp`). */
bridge: AgentVersionInfo | null;
}

export interface DoctorReport {
checks: DoctorCheck[];
}

/** Run all system health checks. */
/** Run all system health checks (cheap path — no version freshness). */
export function runDoctor(): Promise<DoctorReport> {
return invokeCommand('run_doctor');
}

/**
* Re-run all checks with version freshness enabled. Slower (hits npm/brew/
* crates.io/GitHub) — call this as a second pass after `runDoctor` so the base
* report paints instantly while version/update info fills in.
*/
export function runDoctorFreshness(): Promise<DoctorReport> {
return invokeCommand('run_doctor_freshness');
}

/** Run a fix for a doctor check, identified by check ID and fix type. */
export function runDoctorFix(checkId: string, fixType: 'command' | 'bridge'): Promise<void> {
export function runDoctorFix(
checkId: string,
fixType: 'command' | 'bridge' | 'auth'
): Promise<void> {
return invokeCommand('run_doctor_fix', { checkId, fixType });
}

/**
* Run a source-aware update for a single readout (main CLI or ACP bridge).
*
* The backend re-derives the expected command for `(checkId, fixType)` and only
* runs it if `command` matches, so this is not a raw-shell-exec path.
*/
export function runDoctorUpdate(
checkId: string,
fixType: 'updateMain' | 'updateBridge',
command: string
): Promise<void> {
return invokeCommand('run_doctor_update', { checkId, fixType, command });
}

// =============================================================================
// PR Status
// =============================================================================
Expand Down
Loading