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:
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
* { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] }
|
||||
* { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed',
|
||||
* 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: 'failed', stage?, error } // bootstrap aborted
|
||||
*
|
||||
@ -229,7 +229,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
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) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
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) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
// Flush any trailing bytes
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
@ -299,7 +299,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
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) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
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) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
|
||||
@ -722,7 +722,7 @@ function broadcastBootstrapEvent(ev) {
|
||||
error: ev.error ?? null
|
||||
}
|
||||
} 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) {
|
||||
bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX)
|
||||
}
|
||||
|
||||
@ -177,7 +177,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
}
|
||||
}
|
||||
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()
|
||||
return { ...state, log: next }
|
||||
}
|
||||
@ -431,7 +431,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
) : (
|
||||
<>
|
||||
{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}
|
||||
<span>{entry.line}</span>
|
||||
</div>
|
||||
|
||||
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
@ -222,7 +222,7 @@ export interface DesktopBootstrapState {
|
||||
manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null
|
||||
stages: Record<string, DesktopBootstrapStageResult>
|
||||
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
|
||||
completedAt: number | null
|
||||
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
|
||||
@ -238,7 +238,7 @@ export type DesktopBootstrapEvent =
|
||||
json?: DesktopBootstrapStageResult['json']
|
||||
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: 'failed'; stage?: string | null; error: string }
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user