Merge feature/ticket28-firstrun-detect-hang into develop (#28)

First-run wizard figé : boutons « Save and continue » et « Detect installed
CLIs » grisés à vie.

Cause racine à deux étages, corrigée aux deux :
- backend : `AgentRuntime::detect` était un `fn` synchrone pilotant un
  spawner async via un `block_on` imbriqué — panique sous le runtime Tauri,
  commande IPC sans réponse. Le port passe en `async fn`, la sonde est bornée
  par un timeout, et les profils OpenAI-compatible sont sondés en HTTP.
- frontend : `busy` awaitait `detectProfiles`, si bien qu'une promesse
  pendante grisait les actions sans issue. La détection devient best-effort,
  suivie par un drapeau `detecting` séparé qui n'inhibe rien.

QA vert (exécution réelle) : domain 241, application 81, infrastructure 269,
app-tauri 63+15 ; vitest 569 tests / 59 fichiers ; tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 08:12:00 +02:00
15 changed files with 315 additions and 90 deletions

View File

@ -3992,8 +3992,9 @@ mod mcp_serve_peer_tests {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(
@ -5374,8 +5375,9 @@ mod mcp_e2e_loopback_tests {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -72,14 +72,11 @@ impl DetectProfiles {
&self, &self,
input: DetectProfilesInput, input: DetectProfilesInput,
) -> Result<DetectProfilesOutput, AppError> { ) -> Result<DetectProfilesOutput, AppError> {
let results = input let mut results = Vec::with_capacity(input.candidates.len());
.candidates for profile in input.candidates {
.into_iter() let available = self.runtime.detect(&profile).await.unwrap_or(false);
.map(|profile| { results.push(ProfileAvailability { profile, available });
let available = self.runtime.detect(&profile).unwrap_or(false); }
ProfileAvailability { profile, available }
})
.collect();
Ok(DetectProfilesOutput { results }) Ok(DetectProfilesOutput { results })
} }
} }

View File

@ -318,8 +318,9 @@ impl FakeRuntime {
} }
} }
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -344,8 +344,9 @@ impl FakeRuntime {
} }
} }
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -262,8 +262,9 @@ impl SkillStore for RecordingSkills {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -86,9 +86,11 @@ struct StubRuntime {
by_command: HashMap<String, DetectResult>, by_command: HashMap<String, DetectResult>,
} }
struct YieldingRuntime;
#[async_trait] #[async_trait]
impl AgentRuntime for StubRuntime { impl AgentRuntime for StubRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
match self.by_command.get(&profile.command) { match self.by_command.get(&profile.command) {
Some(DetectResult::Available) => Ok(true), Some(DetectResult::Available) => Ok(true),
Some(DetectResult::Missing) | None => Ok(false), Some(DetectResult::Missing) | None => Ok(false),
@ -107,6 +109,24 @@ impl AgentRuntime for StubRuntime {
} }
} }
#[async_trait]
impl AgentRuntime for YieldingRuntime {
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
tokio::task::yield_now().await;
Ok(true)
}
fn prepare_invocation(
&self,
_profile: &AgentProfile,
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
unreachable!("not used in these tests")
}
}
fn profile(id: u128, name: &str, command: &str) -> AgentProfile { fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
AgentProfile::new( AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(id)), ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
@ -167,6 +187,20 @@ async fn detect_error_degrades_to_unavailable_not_hard_failure() {
); );
} }
#[tokio::test(flavor = "multi_thread")]
async fn detect_execute_awaits_async_runtime_without_nested_runtime_panic() {
let detect = DetectProfiles::new(Arc::new(YieldingRuntime));
let out = detect
.execute(DetectProfilesInput {
candidates: vec![profile(1, "Claude", "claude")],
})
.await
.expect("async detection must complete inside a multi-thread Tokio runtime");
assert_eq!(out.results.len(), 1);
assert!(out.results[0].available);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ConfigureProfiles // ConfigureProfiles
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -284,8 +284,9 @@ impl FakeRuntime {
} }
} }
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -745,13 +745,14 @@ pub struct GraphCommit {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Launch/drive an AI CLI according to an [`AgentProfile`], handling context /// Launch/drive an AI CLI according to an [`AgentProfile`], handling context
/// injection. CPU-bound and synchronous — kept plain `fn`. /// injection.
#[async_trait]
pub trait AgentRuntime: Send + Sync { pub trait AgentRuntime: Send + Sync {
/// Detects whether the profile's command is available. /// Detects whether the profile's command is available.
/// ///
/// # Errors /// # Errors
/// [`RuntimeError`] on detection failure. /// [`RuntimeError`] on detection failure.
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>; async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError>;
/// Builds a [`SpawnSpec`] (command + args + injection plan) for launching /// Builds a [`SpawnSpec`] (command + args + injection plan) for launching
/// the agent in `cwd` with the prepared context. /// the agent in `cwd` with the prepared context.

View File

@ -17,7 +17,7 @@
//! `cwd` (template substitution), and the context-injection plan derived from //! `cwd` (template substitution), and the context-injection plan derived from
//! the profile's [`ContextInjection`]. This is the testable core of L5. //! the profile's [`ContextInjection`]. This is the testable core of L5.
use std::sync::Arc; use std::{sync::Arc, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
@ -25,9 +25,11 @@ use domain::ports::{
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan, AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
SpawnSpec, SpawnSpec,
}; };
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy, StructuredAdapter};
use domain::project::ProjectPath; use domain::project::ProjectPath;
const DETECTION_TIMEOUT: Duration = Duration::from_millis(800);
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only /// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
/// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure. /// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure.
#[derive(Clone)] #[derive(Clone)]
@ -179,14 +181,16 @@ impl CliAgentRuntime {
#[async_trait] #[async_trait]
impl AgentRuntime for CliAgentRuntime { impl AgentRuntime for CliAgentRuntime {
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
if profile.structured_adapter == Some(StructuredAdapter::OpenAiCompatible) {
return Ok(Self::detect_openai_compatible(profile).await);
}
let spec = Self::detection_spec(profile)?; let spec = Self::detection_spec(profile)?;
// The port is synchronous but the spawner is async; block on it. The
// adapter is invoked off the Tauri async runtime (detection is a
// short-lived probe), so a transient runtime here is acceptable and keeps
// the `AgentRuntime` port plain-`fn` as the domain declares it.
let spawner = Arc::clone(&self.spawner); let spawner = Arc::clone(&self.spawner);
let output = futures_block_on(async move { spawner.run(spec).await }) let output = tokio::time::timeout(DETECTION_TIMEOUT, spawner.run(spec))
.await
.map_err(|_| RuntimeError::Detection("detection timed out".to_owned()))?
.map_err(|e| RuntimeError::Detection(e.to_string()))?; .map_err(|e| RuntimeError::Detection(e.to_string()))?;
Ok(output.status.code == Some(0)) Ok(output.status.code == Some(0))
} }
@ -225,13 +229,33 @@ impl AgentRuntime for CliAgentRuntime {
} }
} }
/// Minimal block-on helper that drives a future to completion on a fresh impl CliAgentRuntime {
/// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose async fn detect_openai_compatible(profile: &AgentProfile) -> bool {
/// port signature is synchronous while the [`ProcessSpawner`] it calls is async. let Some(chat) = &profile.chat_http else {
fn futures_block_on<F: std::future::Future>(fut: F) -> F::Output { return false;
tokio::runtime::Builder::new_current_thread() };
.enable_all() let url = openai_compatible_models_endpoint(&chat.endpoint);
let probe = async move {
let client = reqwest::Client::builder()
.timeout(DETECTION_TIMEOUT)
.build() .build()
.expect("failed to build a current-thread runtime for detection") .ok()?;
.block_on(fut) let response = client.get(url).send().await.ok()?;
Some(response.status().is_success())
};
tokio::time::timeout(DETECTION_TIMEOUT, probe)
.await
.ok()
.flatten()
.unwrap_or(false)
}
}
fn openai_compatible_models_endpoint(endpoint: &str) -> String {
let trimmed = endpoint.trim_end_matches('/');
let base = trimmed
.strip_suffix("/chat/completions")
.unwrap_or(trimmed)
.trim_end_matches('/');
format!("{base}/models")
} }

View File

@ -18,7 +18,9 @@ use domain::ports::{
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError, AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec, ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
}; };
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::profile::{
AgentProfile, ContextInjection, HttpChatConfig, SessionStrategy, StructuredAdapter,
};
use domain::project::ProjectPath; use domain::project::ProjectPath;
use domain::MarkdownDoc; use domain::MarkdownDoc;
use infrastructure::CliAgentRuntime; use infrastructure::CliAgentRuntime;
@ -297,60 +299,85 @@ fn detection_spec_falls_back_to_command_version() {
// detect (mocked spawner) // detect (mocked spawner)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[test] #[tokio::test]
fn detect_true_on_exit_zero() { async fn detect_true_on_exit_zero() {
let rt = runtime_with(Ok(Output { let rt = runtime_with(Ok(Output {
status: ExitStatus { code: Some(0) }, status: ExitStatus { code: Some(0) },
stdout: Vec::new(), stdout: Vec::new(),
stderr: Vec::new(), stderr: Vec::new(),
})); }));
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(rt.detect(&p).unwrap()); assert!(rt.detect(&p).await.unwrap());
} }
#[test] #[tokio::test]
fn detect_false_on_nonzero_exit() { async fn detect_false_on_nonzero_exit() {
let rt = runtime_with(Ok(Output { let rt = runtime_with(Ok(Output {
status: ExitStatus { code: Some(127) }, status: ExitStatus { code: Some(127) },
stdout: Vec::new(), stdout: Vec::new(),
stderr: Vec::new(), stderr: Vec::new(),
})); }));
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(!rt.detect(&p).unwrap()); assert!(!rt.detect(&p).await.unwrap());
} }
#[test] #[tokio::test]
fn detect_false_on_signal_terminated() { async fn detect_false_on_signal_terminated() {
let rt = runtime_with(Ok(Output { let rt = runtime_with(Ok(Output {
status: ExitStatus { code: None }, status: ExitStatus { code: None },
stdout: Vec::new(), stdout: Vec::new(),
stderr: Vec::new(), stderr: Vec::new(),
})); }));
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
assert!(!rt.detect(&p).unwrap()); assert!(!rt.detect(&p).await.unwrap());
} }
#[test] #[tokio::test]
fn detect_propagates_spawner_error() { async fn detect_propagates_spawner_error() {
let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned()))); let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned())));
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
let err = rt.detect(&p).expect_err("spawner error surfaces"); let err = rt.detect(&p).await.expect_err("spawner error surfaces");
assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}"); assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}");
} }
#[test] #[tokio::test]
fn detect_runs_the_detection_spec_command() { async fn detect_runs_the_detection_spec_command() {
let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None))); let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
let rt = CliAgentRuntime::new(recorder.clone()); let rt = CliAgentRuntime::new(recorder.clone());
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
rt.detect(&p).unwrap(); rt.detect(&p).await.unwrap();
let spec = recorder.0.lock().unwrap().clone().expect("spec recorded"); let spec = recorder.0.lock().unwrap().clone().expect("spec recorded");
assert_eq!(spec.command, "mycli"); assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["probe", "--json"]); assert_eq!(spec.args, vec!["probe", "--json"]);
} }
#[tokio::test]
async fn detect_openai_compatible_uses_http_endpoint_not_cli_spawner() {
let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
let rt = CliAgentRuntime::new(recorder.clone());
let p = profile(ContextInjection::stdin(), "{projectRoot}")
.with_structured_adapter(StructuredAdapter::OpenAiCompatible)
.with_chat_http(
HttpChatConfig::new(
"http://127.0.0.1:1/v1",
"model",
None,
Some(1000),
Some(1000),
None,
)
.unwrap(),
);
assert!(!rt.detect(&p).await.unwrap());
assert!(
recorder.0.lock().unwrap().is_none(),
"OpenAI-compatible detection must not spawn the profile command"
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// prepare_invocation — session args (T3 truth table) // prepare_invocation — session args (T3 truth table)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -227,8 +227,9 @@ impl domain::ports::MemoryRecall for FakeRecall {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -226,8 +226,9 @@ impl domain::ports::MemoryRecall for FakeRecall {
} }
struct FakeRuntime; struct FakeRuntime;
#[async_trait]
impl AgentRuntime for FakeRuntime { impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> { async fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true) Ok(true)
} }
fn prepare_invocation( fn prepare_invocation(

View File

@ -6,6 +6,7 @@
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { import {
act,
render, render,
screen, screen,
waitFor, waitFor,
@ -13,9 +14,11 @@ import {
} from "@testing-library/react"; } from "@testing-library/react";
import { MockProfileGateway } from "@/adapters/mock"; import { MockProfileGateway } from "@/adapters/mock";
import type { ProfileAvailability } from "@/domain";
import type { Gateways } from "@/ports"; import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { FirstRunWizard } from "./FirstRunWizard"; import { FirstRunWizard } from "./FirstRunWizard";
import { DETECT_TIMEOUT_MS } from "./useFirstRun";
function renderWizard( function renderWizard(
profile: MockProfileGateway = new MockProfileGateway(), profile: MockProfileGateway = new MockProfileGateway(),
@ -301,6 +304,69 @@ describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)",
}); });
}); });
// Ticket #28 — the wizard must survive a detection that never answers. When the
// backend `detect_profiles` command panics, the `invoke` promise is neither
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.
// `busy` must therefore never be tied to detection, or both buttons stay greyed
// out for good and the first run cannot be completed.
describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
/** A gateway whose `detectProfiles` promise stays pending forever. */
class HangingDetectGateway extends MockProfileGateway {
override detectProfiles(): Promise<ProfileAvailability[]> {
return new Promise<ProfileAvailability[]>(() => {});
}
}
it("keeps Save and Detect enabled once firstRunState answered", async () => {
renderWizard(new HangingDetectGateway());
await waitForLoaded();
// The rows are rendered, so the blocking load is over: both actions are live.
const save = screen.getByRole("button", { name: "Save and continue" });
const detect = screen.getByRole("button", { name: "Detect installed CLIs" });
expect((save as HTMLButtonElement).disabled).toBe(false);
expect((detect as HTMLButtonElement).disabled).toBe(false);
// Detection is merely reported as in flight, never as a blocking state.
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
// Re-probing by hand must not freeze them either.
fireEvent.click(detect);
expect((save as HTMLButtonElement).disabled).toBe(false);
expect((detect as HTMLButtonElement).disabled).toBe(false);
});
it("still finishes the first run while detection hangs", async () => {
const profile = new HangingDetectGateway();
const { onDone } = renderWizard(profile, vi.fn());
await waitForLoaded();
// Nothing got pre-checked (detection never answered): tick a profile by hand.
fireEvent.click(screen.getByLabelText("use Claude Code"));
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
const saved = await profile.listProfiles();
expect(saved.map((p) => p.command)).toEqual(["claude"]);
});
it("drops the detecting flag on timeout even if the promise never settles", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
renderWizard(new HangingDetectGateway());
await waitForLoaded();
expect(screen.getByRole("status").textContent).toMatch(/detecting/i);
await act(async () => {
vi.advanceTimersByTime(DETECT_TIMEOUT_MS + 1);
});
expect(screen.queryByRole("status")).toBeNull();
} finally {
vi.useRealTimers();
}
});
});
describe("FirstRunWizard reopening after the first run (forceOpen)", () => { describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
/** A gateway whose first run is already done (profiles configured). */ /** A gateway whose first run is already done (profiles configured). */
async function configuredGateway() { async function configuredGateway() {

View File

@ -78,6 +78,8 @@ export function FirstRunWizard({
)} )}
<Toolbar> <Toolbar>
{/* `detecting` deliberately does NOT disable this button (ticket #28):
detection is best-effort and may never answer. */}
<Button <Button
onClick={() => void vm.detect()} onClick={() => void vm.detect()}
disabled={vm.busy} disabled={vm.busy}
@ -85,6 +87,11 @@ export function FirstRunWizard({
> >
Detect installed CLIs Detect installed CLIs
</Button> </Button>
{vm.detecting && (
<span role="status" className="text-xs text-muted">
Detecting
</span>
)}
</Toolbar> </Toolbar>
<ul className="flex list-none flex-col gap-3 p-0"> <ul className="flex list-none flex-col gap-3 p-0">

View File

@ -3,9 +3,17 @@
* (L5). Owns the wizard state (selectable reference candidates + selection + * (L5). Owns the wizard state (selectable reference candidates + selection +
* availability) and the actions. Consumes the {@link ProfileGateway} * availability) and the actions. Consumes the {@link ProfileGateway}
* exclusively — never `invoke()` — so the feature is testable with a mock. * exclusively — never `invoke()` — so the feature is testable with a mock.
*
* **Invariant (ticket #28)**: `busy` gates only the requests the wizard cannot
* render without (`firstRunState`) or that mutate state (`configureProfiles`).
* It NEVER depends on `detectProfiles`: CLI detection is a detached, best-effort
* step tracked by the separate {@link FirstRunViewModel.detecting} flag, bounded
* by {@link DETECT_TIMEOUT_MS}. A detection that panics backend-side (leaving the
* `invoke` promise forever pending) must not freeze "Save and continue" /
* "Detect installed CLIs".
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { import type {
AgentProfile, AgentProfile,
@ -15,6 +23,9 @@ import type {
} from "@/domain"; } from "@/domain";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
/** How long the UI waits for a detection round before giving up on it. */
export const DETECT_TIMEOUT_MS = 3_000;
/** One row in the wizard: a candidate profile, selected state, availability. */ /** One row in the wizard: a candidate profile, selected state, availability. */
export interface WizardEntry { export interface WizardEntry {
profile: AgentProfile; profile: AgentProfile;
@ -31,8 +42,10 @@ export interface FirstRunViewModel {
entries: WizardEntry[]; entries: WizardEntry[];
/** Last error message, or null. */ /** Last error message, or null. */
error: string | null; error: string | null;
/** Whether a request is in flight. */ /** Whether a blocking request is in flight (load / save). Never detection. */
busy: boolean; busy: boolean;
/** Whether a best-effort detection round is in flight. Never disables actions. */
detecting: boolean;
/** Toggles selection of a candidate by id. */ /** Toggles selection of a candidate by id. */
toggle: (id: string) => void; toggle: (id: string) => void;
/** Replaces a candidate's profile (edited command/args/injection). */ /** Replaces a candidate's profile (edited command/args/injection). */
@ -60,41 +73,101 @@ export function useFirstRun(): FirstRunViewModel {
const [entries, setEntries] = useState<WizardEntry[]>([]); const [entries, setEntries] = useState<WizardEntry[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [detecting, setDetecting] = useState(false);
/** Monotonic id of the latest detection round; older rounds are ignored. */
const detectRun = useRef(0);
const detectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
// Unmount: invalidate any in-flight round (its promise may never settle).
detectRun.current += 1;
if (detectTimer.current !== null) clearTimeout(detectTimer.current);
},
[],
);
/**
* Best-effort detection round, detached from `busy`. A round is abandoned
* after {@link DETECT_TIMEOUT_MS} — the underlying promise may stay pending
* forever (backend panic ⇒ no IPC response), so `detecting` is released by a
* timer, never by the promise alone. `preselect` ticks the installed CLIs (the
* auto-detection on open); the manual button only refreshes availability.
*/
const runDetection = useCallback(
async (
candidates: AgentProfile[],
opts: { preselect: boolean; reportError: boolean },
) => {
const run = detectRun.current + 1;
detectRun.current = run;
const stale = () => detectRun.current !== run;
setDetecting(true);
if (detectTimer.current !== null) clearTimeout(detectTimer.current);
detectTimer.current = setTimeout(() => {
if (!stale()) setDetecting(false);
}, DETECT_TIMEOUT_MS);
try {
const results: ProfileAvailability[] =
await profile.detectProfiles(candidates);
if (stale()) return;
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
setEntries((prev) =>
prev.map((e) => {
const available = byId.get(e.profile.id) ?? e.available;
return {
...e,
available,
selected: opts.preselect ? available === true : e.selected,
};
}),
);
} catch (e) {
// Detection unavailable: rows stay as they are (the user can tick them
// by hand and re-run). Only the explicit button surfaces the failure.
if (stale()) return;
if (opts.reportError) setError(describe(e));
} finally {
if (!stale()) {
if (detectTimer.current !== null) clearTimeout(detectTimer.current);
detectTimer.current = null;
setDetecting(false);
}
}
},
[profile],
);
const reload = useCallback(async () => { const reload = useCallback(async () => {
setBusy(true); setBusy(true);
setError(null); setError(null);
let refs: AgentProfile[] | null = null;
try { try {
const state: FirstRunState = await profile.firstRunState(); const state: FirstRunState = await profile.firstRunState();
setIsFirstRun(state.isFirstRun); setIsFirstRun(state.isFirstRun);
const refs = state.referenceProfiles; refs = state.referenceProfiles;
// Show the rows immediately (nothing pre-checked yet). // Show the rows immediately (nothing pre-checked yet).
setEntries( setEntries(
refs.map((p) => ({ profile: p, selected: false, available: null })), refs.map((p) => ({ profile: p, selected: false, available: null })),
); );
// Auto-detect once on open: only the CLIs actually installed end up
// pre-checked. The manual "Detect installed CLIs" button stays available to
// re-probe. If detection is unavailable, rows stay unchecked (the user can
// still tick them by hand and re-run detection).
try {
const results = await profile.detectProfiles(refs);
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
setEntries(
refs.map((p) => {
const available = byId.get(p.id) ?? null;
return { profile: p, selected: available === true, available };
}),
);
} catch {
/* detection unavailable: leave rows unchecked */
}
} catch (e) { } catch (e) {
setError(describe(e)); setError(describe(e));
setIsFirstRun(false); setIsFirstRun(false);
} finally { } finally {
// Released as soon as the rows are rendered — detection is not awaited.
setBusy(false); setBusy(false);
} }
}, [profile]);
// Auto-detect once on open, detached: only the CLIs actually installed end
// up pre-checked. The manual "Detect installed CLIs" button stays available
// to re-probe, whatever happens to this round.
if (refs !== null) {
void runDetection(refs, { preselect: true, reportError: false });
}
}, [profile, runDetection]);
useEffect(() => { useEffect(() => {
void reload(); void reload();
@ -119,25 +192,12 @@ export function useFirstRun(): FirstRunViewModel {
}, []); }, []);
const detect = useCallback(async () => { const detect = useCallback(async () => {
setBusy(true);
setError(null); setError(null);
try { await runDetection(
const candidates = entries.map((e) => e.profile); entries.map((e) => e.profile),
const results: ProfileAvailability[] = { preselect: false, reportError: true },
await profile.detectProfiles(candidates);
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
setEntries((prev) =>
prev.map((e) => ({
...e,
available: byId.get(e.profile.id) ?? e.available,
})),
); );
} catch (e) { }, [runDetection, entries]);
setError(describe(e));
} finally {
setBusy(false);
}
}, [profile, entries]);
const finish = useCallback(async () => { const finish = useCallback(async () => {
setBusy(true); setBusy(true);
@ -158,6 +218,7 @@ export function useFirstRun(): FirstRunViewModel {
entries, entries,
error, error,
busy, busy,
detecting,
toggle, toggle,
updateProfile, updateProfile,
remove, remove,