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
commit 84710995ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 tauri::{AppHandle, Emitter, State};
use tokio::sync::{mpsc, Mutex}; 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::install_script::{self, Pin, ScriptKind, ScriptSource};
use crate::powershell::{self, StreamSink}; use crate::powershell::{self, StreamSink};
use crate::AppState; use crate::AppState;
@ -366,6 +366,7 @@ async fn run_bootstrap(
BootstrapEvent::Log { BootstrapEvent::Log {
stage: None, stage: None,
line: line.to_string(), line: line.to_string(),
stream: LogStream::Stdout,
}, },
); );
// Bump to info-level so the line shows in bootstrap-installer.log // Bump to info-level so the line shows in bootstrap-installer.log
@ -700,6 +701,7 @@ async fn run_install_script(
BootstrapEvent::Log { BootstrapEvent::Log {
stage: stage_for_stdout.clone(), stage: stage_for_stdout.clone(),
line: line.to_string(), line: line.to_string(),
stream: LogStream::Stdout,
}, },
); );
// Tee to the rolling installer log so we have a persistent // Tee to the rolling installer log so we have a persistent
@ -718,7 +720,8 @@ async fn run_install_script(
&app_for_stderr, &app_for_stderr,
BootstrapEvent::Log { BootstrapEvent::Log {
stage: stage_for_stderr.clone(), 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 // stderr-level lines get warn! so they're visually distinct

View File

@ -51,6 +51,16 @@ pub enum StageState {
Failed, 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. /// The single event channel `bootstrap` emits these. `type` discriminates.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")] #[serde(tag = "type", rename_all = "lowercase")]
@ -72,11 +82,14 @@ pub enum BootstrapEvent {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>, 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 { Log {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
stage: Option<String>, stage: Option<String>,
line: String, line: String,
stream: LogStream,
}, },
/// Sent once when all stages complete successfully. /// Sent once when all stages complete successfully.
Complete { Complete {

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@
* { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] } * { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] }
* { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed', * { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed',
* json?, durationMs?, error? } * json?, durationMs?, error? }
* { type: 'log', stage?, line } // raw line from install.ps1 * { type: 'log', stage?, line, stream: 'stdout'|'stderr' } // raw line from install.ps1
* { type: 'complete', marker: <written marker payload> } * { type: 'complete', marker: <written marker payload> }
* { type: 'failed', stage?, error } // bootstrap aborted * { type: 'failed', stage?, error } // bootstrap aborted
* *
@ -229,7 +229,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
while ((nl = stdoutBuf.indexOf('\n')) !== -1) { while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1) stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line }) if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
} }
}) })
@ -241,7 +241,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
while ((nl = stderrBuf.indexOf('\n')) !== -1) { while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '') const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1) stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` }) if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
} }
}) })
@ -253,8 +253,8 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort) if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
// Flush any trailing bytes // Flush any trailing bytes
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf }) if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` }) if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
resolve({ stdout, stderr, code, signal, killed }) resolve({ stdout, stderr, code, signal, killed })
}) })
}) })
@ -299,7 +299,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
while ((nl = stdoutBuf.indexOf('\n')) !== -1) { while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1) stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line }) if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
} }
}) })
@ -311,7 +311,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
while ((nl = stderrBuf.indexOf('\n')) !== -1) { while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '') const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1) stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` }) if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
} }
}) })
@ -322,8 +322,8 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort) if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf }) if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` }) if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
resolve({ stdout, stderr, code, signal, killed }) resolve({ stdout, stderr, code, signal, killed })
}) })
}) })

View File

@ -722,7 +722,7 @@ function broadcastBootstrapEvent(ev) {
error: ev.error ?? null error: ev.error ?? null
} }
} else if (ev.type === 'log') { } else if (ev.type === 'log') {
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line }) bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' })
if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) { if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) {
bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX) bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX)
} }

View File

@ -177,7 +177,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
} }
} }
if (ev.type === 'log') { if (ev.type === 'log') {
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line }) const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line, stream: ev.stream })
while (next.length > 500) next.shift() while (next.length > 500) next.shift()
return { ...state, log: next } return { ...state, log: next }
} }
@ -431,7 +431,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
) : ( ) : (
<> <>
{state.log.map((entry, i) => ( {state.log.map((entry, i) => (
<div key={i} className="whitespace-pre-wrap break-words"> <div
key={i}
className={cn('whitespace-pre-wrap break-words', entry.stream === 'stderr' && 'text-muted-foreground')}
>
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null} {entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
<span>{entry.line}</span> <span>{entry.line}</span>
</div> </div>

View File

@ -222,7 +222,7 @@ export interface DesktopBootstrapState {
manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null
stages: Record<string, DesktopBootstrapStageResult> stages: Record<string, DesktopBootstrapStageResult>
error: string | null error: string | null
log: Array<{ ts: number; stage: string | null; line: string }> log: Array<{ ts: number; stage: string | null; line: string; stream?: 'stdout' | 'stderr' }>
startedAt: number | null startedAt: number | null
completedAt: number | null completedAt: number | null
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
@ -238,7 +238,7 @@ export type DesktopBootstrapEvent =
json?: DesktopBootstrapStageResult['json'] json?: DesktopBootstrapStageResult['json']
error?: string | null error?: string | null
} }
| { type: 'log'; stage?: string | null; line: string } | { type: 'log'; stage?: string | null; line: string; stream?: 'stdout' | 'stderr' }
| { type: 'complete'; marker: Record<string, unknown> } | { type: 'complete'; marker: Record<string, unknown> }
| { type: 'failed'; stage?: string | null; error: string } | { type: 'failed'; stage?: string | null; error: string }
| { | {