Skip to content

Commit 9ddf82e

Browse files
authored
feat(cli): canonicalize CLI and bootstrap boundary errors (#417)
## Summary - add stable process-boundary stderr reporting for CLI/MCP helper failures - canonicalize MCP bridge/team stdio/team guide boundary errors - add bootstrap startup error codes and preserve safe failure sources through server startup ## Verification - cargo test -p aionui-app commands::bridge - cargo test -p aionui-app commands::team_stdio - cargo test -p aionui-app commands::team_guide - cargo test -p aionui-app bootstrap - cargo test -p aionui-app commands::server - cargo clippy -p aionui-app --tests -- -D warnings - cargo fmt --all -- --check - just push -u origin boii/error-model-canonicalize
1 parent bc4b7d9 commit 9ddf82e

34 files changed

Lines changed: 2167 additions & 399 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/aionui-app/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ base64.workspace = true
6262
git2.workspace = true
6363
http-body-util.workspace = true
6464
rust_xlsxwriter.workspace = true
65+
sqlx = { workspace = true, features = ["migrate"] }
6566
tempfile = "3"
6667
tower = { workspace = true, features = ["util"] }
6768
tokio-tungstenite.workspace = true

crates/aionui-app/src/bootstrap/environment.rs

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
use std::time::Instant;
44

5-
use anyhow::Result;
65
use tracing::info;
76

87
use aionui_app::AppConfig;
@@ -13,6 +12,7 @@ use crate::cli::Cli;
1312
use super::builtin_skills::materialize_builtin_skills;
1413
use super::tracing_init::{LogGuards, init_tracing};
1514
use super::work_dir::resolve_work_dir;
15+
use super::{BootstrapError, BootstrapErrorCode};
1616

1717
/// Resolved environment needed by all non-MCP subcommands.
1818
pub struct ServerEnvironment {
@@ -25,9 +25,9 @@ pub struct ServerEnvironment {
2525
///
2626
/// Cheap, synchronous, no IO beyond creating the log directory.
2727
/// All subcommands that need logging and config should call this first.
28-
pub fn init_environment(cli: &Cli, merged_path: &str) -> Result<ServerEnvironment> {
28+
pub fn init_environment(cli: &Cli, merged_path: &str) -> Result<ServerEnvironment, BootstrapError> {
2929
let log_dir = cli.log_dir.clone().unwrap_or_else(|| cli.data_dir.join("logs"));
30-
let log_guard = init_tracing(&log_dir, cli.log_level.as_deref());
30+
let log_guard = init_tracing(&log_dir, cli.log_level.as_deref())?;
3131

3232
info!(
3333
path_segments = merged_path.split(if cfg!(windows) { ';' } else { ':' }).count(),
@@ -66,20 +66,58 @@ pub fn init_environment(cli: &Cli, merged_path: &str) -> Result<ServerEnvironmen
6666
///
6767
/// Requires only `data_dir`. Subcommands that need persistent state
6868
/// (database, skill files) should call this after `init_environment`.
69-
pub async fn init_data_layer(config: &AppConfig) -> Result<Database> {
69+
pub async fn init_data_layer(config: &AppConfig) -> Result<Database, BootstrapError> {
7070
let boot = Instant::now();
7171

72-
materialize_builtin_skills(&config.data_dir).await?;
72+
materialize_builtin_skills(&config.data_dir).await.map_err(|e| {
73+
BootstrapError::new(
74+
BootstrapErrorCode::DataInitFailed,
75+
"data.builtin_skills",
76+
"failed to initialize application data",
77+
)
78+
.with_source(e)
79+
.with_field("dataDir", config.data_dir.display().to_string())
80+
})?;
7381
info!(
7482
elapsed_ms = boot.elapsed().as_millis(),
7583
"startup: builtin skills materialized"
7684
);
7785

7886
let db_path = config.database_path();
79-
aionui_db::maybe_copy_legacy_database(&db_path)?;
87+
aionui_db::maybe_copy_legacy_database(&db_path).map_err(|e| {
88+
BootstrapError::new(
89+
BootstrapErrorCode::DataInitFailed,
90+
"data.legacy_db",
91+
"failed to initialize application data",
92+
)
93+
.with_source(e)
94+
.with_field("databasePath", db_path.display().to_string())
95+
})?;
8096
info!("Initializing database at {}", db_path.display());
81-
let database = aionui_db::init_database(&db_path).await?;
97+
let database = aionui_db::init_database_staged(&db_path).await.map_err(|e| {
98+
let stage = e.stage();
99+
BootstrapError::new(
100+
BootstrapErrorCode::DataInitFailed,
101+
stage,
102+
"failed to initialize application data",
103+
)
104+
.with_source(e.into_source())
105+
.with_field("databasePath", db_path.display().to_string())
106+
})?;
82107
info!(elapsed_ms = boot.elapsed().as_millis(), "startup: database initialized");
83108

84109
Ok(database)
85110
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
#[test]
115+
fn database_stage_comes_from_db_boundary_error() {
116+
let err = aionui_db::DatabaseInitError::new(
117+
"database.migration",
118+
aionui_db::DbError::Migration(sqlx::migrate::MigrateError::VersionMismatch(42)),
119+
);
120+
121+
assert_eq!(err.stage(), "database.migration");
122+
}
123+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
use std::process::ExitCode;
2+
3+
use crate::process_report::{ExitKind, ProcessReport};
4+
5+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6+
pub(crate) enum BootstrapErrorCode {
7+
ConfigInvalid,
8+
RuntimeInitFailed,
9+
LoggingInitFailed,
10+
BindFailed,
11+
DataInitFailed,
12+
ServiceInitFailed,
13+
ServerFailed,
14+
ShutdownFailed,
15+
}
16+
17+
impl BootstrapErrorCode {
18+
pub(crate) fn as_str(self) -> &'static str {
19+
match self {
20+
Self::ConfigInvalid => "BOOTSTRAP_CONFIG_INVALID",
21+
Self::RuntimeInitFailed => "BOOTSTRAP_RUNTIME_INIT_FAILED",
22+
Self::LoggingInitFailed => "BOOTSTRAP_LOGGING_INIT_FAILED",
23+
Self::BindFailed => "BOOTSTRAP_BIND_FAILED",
24+
Self::DataInitFailed => "BOOTSTRAP_DATA_INIT_FAILED",
25+
Self::ServiceInitFailed => "BOOTSTRAP_SERVICE_INIT_FAILED",
26+
Self::ServerFailed => "BOOTSTRAP_SERVER_FAILED",
27+
Self::ShutdownFailed => "BOOTSTRAP_SHUTDOWN_FAILED",
28+
}
29+
}
30+
31+
pub(crate) fn exit_kind(self) -> ExitKind {
32+
match self {
33+
Self::ConfigInvalid => ExitKind::Config,
34+
Self::BindFailed => ExitKind::Unavailable,
35+
Self::RuntimeInitFailed
36+
| Self::LoggingInitFailed
37+
| Self::DataInitFailed
38+
| Self::ServiceInitFailed
39+
| Self::ServerFailed
40+
| Self::ShutdownFailed => ExitKind::Internal,
41+
}
42+
}
43+
}
44+
45+
#[derive(Debug)]
46+
pub(crate) struct BootstrapError {
47+
code: BootstrapErrorCode,
48+
stage: &'static str,
49+
message: &'static str,
50+
source: Option<anyhow::Error>,
51+
fields: Vec<(&'static str, String)>,
52+
}
53+
54+
impl BootstrapError {
55+
pub(crate) fn new(code: BootstrapErrorCode, stage: &'static str, message: &'static str) -> Self {
56+
Self {
57+
code,
58+
stage,
59+
message,
60+
source: None,
61+
fields: Vec::new(),
62+
}
63+
}
64+
65+
pub(crate) fn with_source(mut self, source: impl Into<anyhow::Error>) -> Self {
66+
self.source = Some(source.into());
67+
self
68+
}
69+
70+
pub(crate) fn with_field(mut self, key: &'static str, value: impl Into<String>) -> Self {
71+
self.fields.push((key, value.into()));
72+
self
73+
}
74+
75+
#[cfg(test)]
76+
pub(crate) fn code(&self) -> BootstrapErrorCode {
77+
self.code
78+
}
79+
80+
#[cfg(test)]
81+
pub(crate) fn stage(&self) -> &'static str {
82+
self.stage
83+
}
84+
85+
pub(crate) fn exit_code(&self) -> ExitCode {
86+
self.code.exit_kind().exit_code()
87+
}
88+
89+
pub(crate) fn stderr_line(&self) -> String {
90+
let mut fields = vec![("stage", self.stage.to_owned())];
91+
fields.extend(self.fields.clone());
92+
ProcessReport {
93+
code: self.code.as_str(),
94+
message: self.message,
95+
exit_kind: self.code.exit_kind(),
96+
fields,
97+
}
98+
.stderr_line()
99+
}
100+
101+
pub(crate) fn log_source(&self) {
102+
if self.code == BootstrapErrorCode::LoggingInitFailed {
103+
// Logging setup failed before a tracing subscriber was available.
104+
// Keep raw source private on the error object; public stderr remains
105+
// the stable boundary line only.
106+
return;
107+
}
108+
if let Some(source) = &self.source {
109+
tracing::error!(
110+
code = self.code.as_str(),
111+
stage = self.stage,
112+
error = %source,
113+
"bootstrap boundary failure"
114+
);
115+
}
116+
}
117+
}
118+
119+
impl std::fmt::Display for BootstrapError {
120+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121+
write!(f, "{}: {}", self.code.as_str(), self.message)
122+
}
123+
}
124+
125+
impl std::error::Error for BootstrapError {
126+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
127+
self.source
128+
.as_ref()
129+
.map(|source| source.as_ref() as &(dyn std::error::Error + 'static))
130+
}
131+
}
132+
133+
#[cfg(test)]
134+
mod tests {
135+
use super::*;
136+
use std::process::ExitCode;
137+
138+
#[test]
139+
fn bootstrap_error_renders_code_stage_and_exit() {
140+
let err = BootstrapError::new(
141+
BootstrapErrorCode::BindFailed,
142+
"bind.listener",
143+
"failed to bind HTTP listener",
144+
)
145+
.with_field("port", "13400");
146+
147+
assert_eq!(err.code(), BootstrapErrorCode::BindFailed);
148+
assert_eq!(err.stage(), "bind.listener");
149+
assert_eq!(err.exit_code(), ExitCode::from(3));
150+
assert_eq!(
151+
err.stderr_line(),
152+
"BOOTSTRAP_BIND_FAILED stage=bind.listener port=13400: failed to bind HTTP listener"
153+
);
154+
}
155+
156+
#[test]
157+
fn config_invalid_uses_exit_2() {
158+
let err = BootstrapError::new(
159+
BootstrapErrorCode::ConfigInvalid,
160+
"config.parse",
161+
"invalid application configuration",
162+
);
163+
164+
assert_eq!(err.exit_code(), ExitCode::from(2));
165+
}
166+
167+
#[test]
168+
fn source_is_preserved_but_not_rendered_to_stderr() {
169+
let err = BootstrapError::new(
170+
BootstrapErrorCode::DataInitFailed,
171+
"data.open",
172+
"failed to initialize data layer",
173+
)
174+
.with_source(anyhow::anyhow!("secret disk path /tmp/secret.db"));
175+
176+
let stderr = err.stderr_line();
177+
assert!(!stderr.contains("secret"));
178+
assert!(!stderr.contains("/tmp/secret.db"));
179+
180+
let source = std::error::Error::source(&err).expect("source should be preserved");
181+
assert!(source.to_string().contains("secret disk path /tmp/secret.db"));
182+
}
183+
184+
#[test]
185+
fn logging_init_source_remains_private_when_tracing_is_unavailable() {
186+
let err = BootstrapError::new(
187+
BootstrapErrorCode::LoggingInitFailed,
188+
"logging.init",
189+
"failed to initialize logging",
190+
)
191+
.with_source(anyhow::anyhow!("secret log path /tmp/aion.log"));
192+
193+
err.log_source();
194+
195+
let stderr = err.stderr_line();
196+
assert!(stderr.contains("BOOTSTRAP_LOGGING_INIT_FAILED"));
197+
assert!(!stderr.contains("secret log path"));
198+
assert!(!stderr.contains("/tmp/aion.log"));
199+
assert!(
200+
std::error::Error::source(&err)
201+
.expect("source should be preserved")
202+
.to_string()
203+
.contains("secret log path")
204+
);
205+
}
206+
}

crates/aionui-app/src/bootstrap/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
77
mod builtin_skills;
88
mod environment;
9+
mod error;
910
mod tracing_init;
1011
mod work_dir;
1112

1213
pub use environment::{ServerEnvironment, init_data_layer, init_environment};
14+
pub(crate) use error::{BootstrapError, BootstrapErrorCode};

0 commit comments

Comments
 (0)