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
117 changes: 103 additions & 14 deletions apps/staged/src-tauri/src/doctor.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
//! Tauri command wrappers for the doctor health-check system.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};

use serde::Serialize;

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

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DoctorLoginOutput {
pub check_id: String,
pub line: Option<String>,
pub done: bool,
pub error: Option<String>,
}

/// Writers for login fixes currently owned by the UI. This is intentionally
/// only a lifetime map for active subprocesses, not a cache of authentication
/// state; doctor remains the source of truth for whether login is available.
static ACTIVE_LOGINS: OnceLock<Mutex<HashMap<String, FixStdinWriter>>> = OnceLock::new();

fn active_logins() -> &'static Mutex<HashMap<String, FixStdinWriter>> {
ACTIVE_LOGINS.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Environment snapshot for doctor checks and fixes. Shaped through
/// `apply_managed_tools_env` so checks resolve binaries from the same PATH
/// the agent spawn path uses — a bridge Staged manages must never be
Expand Down Expand Up @@ -55,10 +77,17 @@ fn execute_fix_options(
command_override: Option<String>,
env_vars: Vec<(String, String)>,
) -> ExecuteFixOptions {
// Everything else stays at doctor's defaults: Staged's fixes are
// non-interactive, so nothing here feeds a prompt and the child keeps
// inheriting stdin rather than getting a piped one; the standard fix
// timeout is far above any install or login this runs. Spelled with
// `..Default::default()` so a new doctor option doesn't break this
// workspace-excluded crate, which `cargo check` under `crates/` never
// compiles but `staged-ci.yml` does.
ExecuteFixOptions {
command_override,
npm_registry: crate::managed_acp_tools::npm_registry().map(str::to_string),
env: None,
..Default::default()
}
.with_env_snapshot(env_vars)
}
Expand Down Expand Up @@ -106,17 +135,77 @@ async fn run_doctor_report(check_freshness: bool) -> DoctorReport {
report
}

/// 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 —
/// the caller never sends a raw command string. Two families of fixes are
/// native rather than shell commands: the node-runtime fix (re)installs the
/// pinned managed runtime, and install fixes for the managed ACP bridges run
/// the floating managed installer so the bridge lands in
/// `~/.staged/packages/tools` with an absolute-path shim instead of the
/// crate's `npm install -g`. Remaining npm-backed fixes install the managed
/// runtime first, since they run npm from it into the private prefix (the
/// existing "Running…" spinner covers the one-time download).
/// Start an interactive login fix and stream its output to the frontend.
#[tauri::command]
pub async fn start_doctor_login(
app_handle: tauri::AppHandle,
check_id: String,
) -> Result<(), String> {
doctor::agents::lookup_fix_command(&check_id, &FixType::Auth)
.ok_or_else(|| format!("No login fix available for {check_id}"))?;
let env_vars = doctor_env_vars().await;
let (writer, stdin) = FixStdin::pipe();
{
let mut logins = active_logins().lock().unwrap_or_else(|e| e.into_inner());
if logins.contains_key(&check_id) {
return Err(format!("A login is already running for {check_id}"));
}
logins.insert(check_id.clone(), writer);
}

let event_check_id = check_id.clone();
let event_app = app_handle.clone();
tokio::spawn(async move {
let result = doctor::execute_fix_streaming_with_env_options(
check_id.clone(),
FixType::Auth,
ExecuteFixOptions::default()
.with_env_snapshot(env_vars)
.with_stdin(stdin),
move |line| {
crate::web_server::emit_to_all(
&event_app,
"doctor-login-output",
DoctorLoginOutput {
check_id: event_check_id.clone(),
line: Some(line.to_string()),
done: false,
error: None,
},
);
},
)
.await;

active_logins()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&check_id);
crate::web_server::emit_to_all(
&app_handle,
"doctor-login-output",
DoctorLoginOutput {
check_id,
line: None,
done: true,
error: result.err(),
},
);
});
Ok(())
}

#[tauri::command]
pub fn send_doctor_login_code(check_id: String, code: String) -> Result<(), String> {
let writer = active_logins()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&check_id)
.cloned()
.ok_or_else(|| format!("No active login for {check_id}"))?;
writer.send_line(code)
}

#[tauri::command]
pub async fn run_doctor_fix(check_id: String, fix_type: FixType) -> Result<(), String> {
if check_id == NODE_RUNTIME_CHECK_ID {
Expand Down
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 @@ -2375,6 +2375,8 @@ pub fn run() {
doctor::run_doctor,
doctor::run_doctor_freshness,
doctor::run_doctor_fix,
doctor::start_doctor_login,
doctor::send_doctor_login_code,
doctor::run_doctor_update,
])
.build(tauri::generate_context!())
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 @@ -3760,6 +3760,17 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result<Val
let report = crate::doctor::run_doctor_freshness().await;
Ok(serde_json::to_value(report).unwrap())
}
"start_doctor_login" => {
let check_id: String = arg(&args, "checkId")?;
crate::doctor::start_doctor_login(app_handle.clone(), check_id).await?;
Ok(Value::Null)
}
"send_doctor_login_code" => {
let check_id: String = arg(&args, "checkId")?;
let code: String = arg(&args, "code")?;
crate::doctor::send_doctor_login_code(check_id, code)?;
Ok(Value::Null)
}
"run_doctor_fix" => {
let check_id: String = arg(&args, "checkId")?;
let fix_type: doctor::FixType = arg(&args, "fixType")?;
Expand Down
17 changes: 17 additions & 0 deletions apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,23 @@ export function runDoctorFix(
return invokeCommand('run_doctor_fix', { checkId, fixType });
}

/** Start an interactive login fix. Output is delivered through doctor-fix-output events. */
export function startDoctorLogin(checkId: string): Promise<void> {
return invokeCommand('start_doctor_login', { checkId });
}

/** Submit a line to an interactive doctor login started by startDoctorLogin. */
export function sendDoctorLoginCode(checkId: string, code: string): Promise<void> {
return invokeCommand('send_doctor_login_code', { checkId, code });
}

export interface DoctorLoginOutput {
checkId: string;
line: string | null;
done: boolean;
error: string | null;
}

/**
* Run a source-aware update for a single readout (main CLI or ACP bridge).
*
Expand Down
111 changes: 111 additions & 0 deletions apps/staged/src/lib/features/sessions/SessionChatPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,17 @@
sendQueuedSessionMessage,
type AcpConfigDiscovery,
type AcpConfigSelector,
type DoctorLoginOutput,
} from '../../api/commands';
import { listenToEvent, type UnlistenFn } from '../../transport';
import { openSettings } from '../layout/navigation.svelte';
import { doctorState, runChecks } from '../doctor/doctor.svelte';
import {
canOfferLogin,
doctorCheckForProvider,
isAuthCodePrompt,
isAuthenticationError,
} from './authRecovery';
import AcpFixedConfigPicker from '../agents/AcpFixedConfigPicker.svelte';
import { agentState } from '../agents/agent.svelte';
import {
Expand Down Expand Up @@ -200,6 +209,13 @@
let unlistenStatus: UnlistenFn | null = null;
let statusEventVersion = 0;
let closed = false;
let loginRunning = $state(false);
let loginError = $state<string | null>(null);
let loginCodePrompt = $state(false);
let loginCode = $state('');
let loginOutputUnlisten: UnlistenFn | null = null;
let loginCheck = $derived(doctorCheckForProvider(session?.provider, doctorState.report));
let canLogin = $derived(canOfferLogin(loginCheck));

let inputText = $state('');
let queuedMessages = $state<QueuedSessionMessage[]>([]);
Expand Down Expand Up @@ -565,6 +581,7 @@
closed = true;
stopPolling();
unlistenStatus?.();
loginOutputUnlisten?.();
});

// This pane can be mounted once and reused across opens (the `active` prop toggles
Expand Down Expand Up @@ -635,6 +652,45 @@
};
});

async function startLogin() {
if (!session?.provider || !canLogin || loginRunning) return;
loginRunning = true;
loginError = null;
loginCodePrompt = false;
try {
const { startDoctorLogin } = await import('../../api/commands');
loginOutputUnlisten?.();
const unlisten = listenToEvent<DoctorLoginOutput>('doctor-login-output', (output) => {
if (output.checkId !== `ai-agent-${session?.provider}`) return;
if (output.line && isAuthCodePrompt(output.line)) loginCodePrompt = true;
if (output.done) {
loginRunning = false;
unlisten();
loginOutputUnlisten = null;
if (output.error) loginError = output.error;
else void runChecks();
}
});
loginOutputUnlisten = unlisten;
await startDoctorLogin(`ai-agent-${session.provider}`);
} catch (e) {
loginRunning = false;
loginError = e instanceof Error ? e.message : String(e);
}
}

async function submitLoginCode() {
if (!session?.provider || !loginCode.trim()) return;
try {
const { sendDoctorLoginCode } = await import('../../api/commands');
await sendDoctorLoginCode(`ai-agent-${session.provider}`, loginCode.trim());
loginCode = '';
loginCodePrompt = false;
} catch (e) {
loginError = e instanceof Error ? e.message : String(e);
}
}

function isComposerFocused(): boolean {
return document.activeElement === inputEl;
}
Expand Down Expand Up @@ -2051,10 +2107,47 @@
was killed from outside with a recorded reason (e.g. a Pikchr child
session whose generate_pikchr call timed out) and reads as an error. -->
{#if (session?.status === 'error' || session?.status === 'cancelled') && session.errorMessage}
{@const authError = isAuthenticationError(session.errorMessage)}
<Alert.Root variant="destructive" class="mt-3">
<AlertCircle />
<Alert.Description>{session.errorMessage}</Alert.Description>
{#if authError}
<Alert.Action>
<div class="auth-actions">
<Button variant="outline" size="xs" onclick={() => openSettings('doctor')}>
Fix
</Button>
{#if canLogin}
<Button variant="outline" size="xs" disabled={loginRunning} onclick={startLogin}>
{loginRunning ? 'Logging in…' : 'Log in'}
</Button>
{/if}
</div>
</Alert.Action>
{/if}
</Alert.Root>
{#if loginError}
<p class="text-destructive text-sm">{loginError}</p>
{/if}
{#if loginCodePrompt}
<div class="login-code-row">
<input
class="login-code-input"
aria-label="Authentication code"
placeholder="Paste authentication code"
bind:value={loginCode}
onkeydown={(event) => event.key === 'Enter' && submitLoginCode()}
/>
<Button
variant="outline"
size="xs"
onclick={submitLoginCode}
disabled={!loginCode.trim()}
>
Submit code
</Button>
</div>
{/if}
{:else if session && session.status !== 'running' && session.status !== 'queued'}
{#if isResumableReason(session.completionReason)}
{@const isWarning =
Expand Down Expand Up @@ -2664,6 +2757,24 @@

/* ----- Input wrapper + queue popover ----------------------------------- */

.auth-actions,
.login-code-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}

.login-code-input {
min-width: 180px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
padding: 4px 8px;
font-size: var(--size-xs);
}

.input-wrapper {
flex-shrink: 0;
}
Expand Down
Loading