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:
commit
84710995ef
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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
|
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 }
|
||||||
| {
|
| {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user