Merge pull request #38312 from NousResearch/bb/installer-stderr-log-label

fix(installer): stop mislabeling stdout-style progress as stderr
This commit is contained in:
brooklyn!
2026-06-03 12:17:35 -05:00
committed by GitHub
9 changed files with 50 additions and 29 deletions

View File

@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use tokio::sync::{mpsc, Mutex};
use crate::events::{BootstrapEvent, Manifest, StageState};
use crate::events::{BootstrapEvent, LogStream, Manifest, StageState};
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
use crate::powershell::{self, StreamSink};
use crate::AppState;
@ -366,6 +366,7 @@ async fn run_bootstrap(
BootstrapEvent::Log {
stage: None,
line: line.to_string(),
stream: LogStream::Stdout,
},
);
// Bump to info-level so the line shows in bootstrap-installer.log
@ -700,6 +701,7 @@ async fn run_install_script(
BootstrapEvent::Log {
stage: stage_for_stdout.clone(),
line: line.to_string(),
stream: LogStream::Stdout,
},
);
// Tee to the rolling installer log so we have a persistent
@ -718,7 +720,8 @@ async fn run_install_script(
&app_for_stderr,
BootstrapEvent::Log {
stage: stage_for_stderr.clone(),
line: format!("stderr: {line}"),
line: line.to_string(),
stream: LogStream::Stderr,
},
);
// stderr-level lines get warn! so they're visually distinct

View File

@ -51,6 +51,16 @@ pub enum StageState {
Failed,
}
/// Which pipe a raw log line came from. Reported as structured metadata so
/// the UI can style stderr subtly rather than mislabeling it as an error:
/// uv/pip/git/npm write normal progress to stderr by design.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LogStream {
Stdout,
Stderr,
}
/// The single event channel `bootstrap` emits these. `type` discriminates.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
@ -72,11 +82,14 @@ pub enum BootstrapEvent {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
/// Raw stdout/stderr line from install.ps1 (or our wrapper).
/// Raw stdout/stderr line from install.ps1 (or our wrapper). `stream`
/// tells the UI which pipe it came from so stderr can be styled subtly
/// instead of being mislabeled as an error.
Log {
#[serde(skip_serializing_if = "Option::is_none")]
stage: Option<String>,
line: String,
stream: LogStream,
},
/// Sent once when all stages complete successfully.
Complete {

View File

@ -31,7 +31,7 @@ use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use crate::events::{BootstrapEvent, StageInfo, StageState};
use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
/// `hermes update` exit code meaning "another hermes process is holding the
/// venv shim open / dirty precondition" — see _cmd_update_impl in
@ -342,6 +342,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_log(
&app,
None,
LogStream::Stdout,
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
);
}
@ -356,7 +357,7 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some("update"), "[update] waiting for Hermes to exit…");
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
@ -366,6 +367,7 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] timed out waiting for Hermes to exit; proceeding anyway",
);
return;
@ -429,22 +431,22 @@ async fn run_streamed(
loop {
tokio::select! {
line = out.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &l),
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),
Ok(None) => break,
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
},
line = err.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")),
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l),
Ok(None) => {}
Err(e) => { tracing::warn!("stderr read error: {e}"); }
},
}
}
while let Ok(Some(l)) = out.next_line().await {
emit_log(app, stage_owned.as_deref(), &l);
emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l);
}
while let Ok(Some(l)) = err.next_line().await {
emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}"));
emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);
}
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
@ -736,7 +738,7 @@ fn emit_stage(
);
}
fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
fn emit_log(app: &AppHandle, stage: Option<&str>, stream: LogStream, line: &str) {
match stage {
Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"),
None => tracing::info!(target: "bootstrap.log", "{line}"),
@ -746,6 +748,7 @@ fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
BootstrapEvent::Log {
stage: stage.map(|s| s.to_string()),
line: line.to_string(),
stream,
},
);
}

View File

@ -115,9 +115,7 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
key={idx}
className={clsx(
'whitespace-pre-wrap',
entry.line.startsWith('stderr:')
? 'text-destructive'
: 'text-foreground/70'
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
)}
>
{entry.line}

View File

@ -42,7 +42,7 @@ export interface BootstrapStateModel {
currentStage: string | null
installRoot: string | null
error: string | null
logs: Array<{ stage?: string; line: string }>
logs: Array<{ stage?: string; line: string; stream?: 'stdout' | 'stderr' }>
}
const INITIAL: BootstrapStateModel = {
@ -106,6 +106,7 @@ interface BootstrapLogEvent {
type: 'log'
stage?: string
line: string
stream?: 'stdout' | 'stderr'
}
interface BootstrapCompleteEvent {
@ -192,7 +193,7 @@ export async function initialize(): Promise<void> {
break
}
case 'log': {
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }]
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
// during a long install (playwright chromium download is ~10k lines).
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs