Skip to content

Commit b465a03

Browse files
feat(pai-engine): wire composition root for domain crates (#103)
* feat(core): add EventBus, HardcodedFlowRunner, and SessionManager flow path Implement bounded tokio mpsc EventBus, FlowRunner/InferencePort traits, HardcodedFlowRunner for InferenceEcho, and SessionManager::handle_flow_request with state transitions. Wire stub inference in pai-engine. Closes #85 * style: rustfmt for core EventBus/FlowRunner files * chore: add local rustfmt guardrails (pre-commit, docs, Cursor) - Add optional pre-commit hook matching CI fmt check for engine/**/*.rs - Track .vscode/settings.json for format-on-save with rust-analyzer - Document fmt check in AGENTS.md, CONTRIBUTING.md, standards DoD, DoD rule - Add rust-engine-ci.mdc for agents editing Rust under engine/ * feat(pai-engine): wire composition root for domain crates - Add bootstrap module: config load, domain crate visibility logs, SIGTERM+Ctrl-C shutdown - Log initialization and shutdown on tracing target pai_engine::bootstrap - Link optional domain crates (common, vision, audio, inference, api, peripherals) per features Refs: #86 * docs(architecture): document M0.1 composition root skeleton for #86 - Add Current engine skeleton section: bootstrap, stub inference, follow-ups - Link mock inference follow-up to issue #87 - Confirms DoD documentation alignment for PR #103 * test(pai-engine): add unit tests for bootstrap helpers Addresses CodeRabbit review on PR #103: cover load_config branches, log_domain_stack_init under default features, and wait_for_shutdown_signal with a short timeout so tests do not hang. Adds tempfile as a dev-dependency for config file tests. Co-authored-by: RicciP <rplunger@users.noreply.github.com> * fix(pai-engine): handle ctrl_c() errors in shutdown wait Match io::Result from tokio::signal::ctrl_c() on Unix and non-Unix: log a warning on Err instead of discarding (select branch) or panicking (expect). Addresses CodeRabbit review 4093473199 on PR #103. Co-authored-by: RicciP <rplunger@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: RicciP <rplunger@users.noreply.github.com>
1 parent 79b8470 commit b465a03

6 files changed

Lines changed: 227 additions & 18 deletions

File tree

docs/src/content/docs/architecture/composition-root.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ The flow in `main.rs` is intentionally strict:
2020

2121
So: *main.rs does init and wiring only.* It does not contain business logic or orchestration rules; those live in [Core](/architecture/modules/core/) and the domain crates.
2222

23+
## Current engine skeleton (M0.1)
24+
25+
The live binary tracks **[issue #86](https://github.com/aurintex/pai-os/issues/86)** (composition root) with a deliberate **skeleton** step:
26+
27+
- **`pai-engine/src/bootstrap.rs`**: loads optional config from a CLI path when the file exists; logs which optional domain crates are linked for the active [feature profile](/architecture/workspace-and-build/#feature-flag-matrix-capabilities-vs-profiles); waits for shutdown on Ctrl-C and SIGTERM (Unix).
28+
- **`main.rs`**: wires [Core](/architecture/modules/core/) (`EventBus`, `SessionManager`, [`HardcodedFlowRunner`](/architecture/modules/core/#mvp-flows-flows-module)) with a small **stub** [`InferencePort`](https://github.com/aurintex/pai-os/blob/main/engine/crates/core/src/ports/inference.rs) implementation until the inference crate exposes a mock adapter.
29+
30+
All seven workspace domain crates (`common`, `pai-core`, `vision`, `audio`, `inference`, `api`, `peripherals`) are **dependencies** of `pai-engine` when the selected features enable them; the skeleton **resolves** those dependencies and emits structured bootstrap logs. **Concrete mock adapters** inside each domain crate and **injection of every port into Core** are incremental follow-ups (for example mock inference in the inference crate), not blockers for closing the skeleton milestone. Track per-crate mocks and port wiring in follow-up issues (for example [#87](https://github.com/aurintex/pai-os/issues/87) for mock inference).
31+
32+
The sketches below remain the **target** pattern once adapters exist.
33+
2334
## Where main.rs lives
2435

2536
The Composition Root is not a separate domain crate. It is the **executable** that composes the domain crates:

engine/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.

engine/crates/core/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,3 @@ tracing = "0.1"
1616
default = []
1717
core_sysinfo = []
1818
core_mock = []
19-

engine/pai-engine/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ serde = { version = "1.0", features = ["derive"] }
3939
serde_json = "1.0"
4040

4141
[dev-dependencies]
42+
tempfile = "3"
4243
tokio-test = "0.4"
4344

4445
[features]

engine/pai-engine/src/bootstrap.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//! Composition root helpers: domain crate visibility, config load, shutdown signals.
2+
//!
3+
//! Concrete adapters for vision/audio/api/… are feature-gated in their crates; this module
4+
//! ensures each domain crate is part of the engine binary and emits structured bootstrap logs.
5+
6+
use anyhow::{Context, Result};
7+
use std::path::Path;
8+
use tracing::{info, warn};
9+
10+
/// Load optional TOML/JSON config from disk when the path exists; otherwise continue with defaults.
11+
pub fn load_config(path: Option<&Path>) -> Result<()> {
12+
match path {
13+
Some(p) if p.exists() => {
14+
let _cfg = config::Config::builder()
15+
.add_source(config::File::from(p))
16+
.build()
17+
.with_context(|| format!("failed to load config from {}", p.display()))?;
18+
info!(
19+
target: "pai_engine::config",
20+
"loaded configuration from {}",
21+
p.display()
22+
);
23+
}
24+
Some(p) => {
25+
info!(
26+
target: "pai_engine::config",
27+
"config path {} not found; using defaults",
28+
p.display()
29+
);
30+
}
31+
None => {
32+
info!(
33+
target: "pai_engine::config",
34+
"no configuration file; using built-in defaults"
35+
);
36+
}
37+
}
38+
Ok(())
39+
}
40+
41+
/// Emit structured logs and resolve optional domain crate dependencies for the active feature set.
42+
pub fn log_domain_stack_init() {
43+
use common as _;
44+
45+
info!(
46+
target: "pai_engine::bootstrap",
47+
"common: shared domain types and ports (linked)"
48+
);
49+
info!(
50+
target: "pai_engine::bootstrap",
51+
"pai-core: SessionManager, EventBus, FlowRunner (active)"
52+
);
53+
54+
#[cfg(any(
55+
feature = "vision_mock",
56+
feature = "vision_v4l2",
57+
feature = "vision_rga",
58+
feature = "vision_image"
59+
))]
60+
{
61+
use vision as _;
62+
info!(target: "pai_engine::bootstrap", "vision: crate linked");
63+
}
64+
65+
#[cfg(any(
66+
feature = "audio_mock",
67+
feature = "audio_cpal",
68+
feature = "audio_webrtc"
69+
))]
70+
{
71+
use audio as _;
72+
info!(
73+
target: "pai_engine::bootstrap",
74+
"audio: crate linked"
75+
);
76+
}
77+
78+
#[cfg(any(
79+
feature = "infer_mock",
80+
feature = "infer_llamacpp_cpu",
81+
feature = "infer_rknn",
82+
feature = "infer_rkllm",
83+
feature = "infer_sherpa",
84+
feature = "infer_hailo",
85+
feature = "infer_mcp_client"
86+
))]
87+
{
88+
use inference as _;
89+
info!(
90+
target: "pai_engine::bootstrap",
91+
"inference: crate linked"
92+
);
93+
}
94+
95+
#[cfg(any(
96+
feature = "api_mock",
97+
feature = "api_grpc_uds",
98+
feature = "api_grpc_tcp",
99+
feature = "api_http",
100+
feature = "api_mcp_server"
101+
))]
102+
{
103+
use api as _;
104+
info!(target: "pai_engine::bootstrap", "api: crate linked");
105+
}
106+
107+
#[cfg(any(
108+
feature = "periph_mock",
109+
feature = "periph_desktop",
110+
feature = "periph_desktop_hid",
111+
feature = "periph_gpio",
112+
feature = "periph_evdev",
113+
feature = "periph_usb_hid"
114+
))]
115+
{
116+
use peripherals as _;
117+
info!(
118+
target: "pai_engine::bootstrap",
119+
"peripherals: crate linked"
120+
);
121+
}
122+
}
123+
124+
/// Block until Ctrl-C or SIGTERM (Unix). Used as the engine main-loop shutdown trigger.
125+
pub async fn wait_for_shutdown_signal() {
126+
#[cfg(unix)]
127+
{
128+
use tokio::signal::unix::{signal, SignalKind};
129+
let mut sigterm = signal(SignalKind::terminate()).expect("register SIGTERM");
130+
tokio::select! {
131+
res = tokio::signal::ctrl_c() => {
132+
if let Err(err) = res {
133+
warn!(
134+
target: "pai_engine::bootstrap",
135+
error = %err,
136+
"failed to listen for Ctrl-C; continuing shutdown wait on other signals"
137+
);
138+
}
139+
}
140+
_ = sigterm.recv() => {},
141+
}
142+
}
143+
#[cfg(not(unix))]
144+
{
145+
if let Err(err) = tokio::signal::ctrl_c().await {
146+
warn!(
147+
target: "pai_engine::bootstrap",
148+
error = %err,
149+
"failed to listen for Ctrl-C; exiting shutdown wait"
150+
);
151+
}
152+
}
153+
}
154+
155+
#[cfg(test)]
156+
mod tests {
157+
use super::*;
158+
use std::time::Duration;
159+
160+
#[test]
161+
fn load_config_none_ok() {
162+
load_config(None).expect("built-in defaults path");
163+
}
164+
165+
#[test]
166+
fn load_config_missing_file_ok() {
167+
let path = Path::new("/nonexistent/pai-engine-config-does-not-exist.toml");
168+
load_config(Some(path)).expect("defaults when missing");
169+
}
170+
171+
#[test]
172+
fn load_config_existing_file_ok() {
173+
let dir = tempfile::tempdir().expect("tempdir");
174+
let path = dir.path().join("app.toml");
175+
std::fs::write(&path, "key = \"value\"\n").expect("write config");
176+
load_config(Some(path.as_path())).expect("load existing");
177+
}
178+
179+
#[test]
180+
fn log_domain_stack_init_runs_without_panic() {
181+
log_domain_stack_init();
182+
}
183+
184+
#[tokio::test]
185+
async fn wait_for_shutdown_signal_does_not_complete_without_signal() {
186+
let result =
187+
tokio::time::timeout(Duration::from_millis(50), wait_for_shutdown_signal()).await;
188+
assert!(
189+
result.is_err(),
190+
"expected timeout before any shutdown signal"
191+
);
192+
}
193+
}

engine/pai-engine/src/main.rs

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
mod bootstrap;
2+
13
use anyhow::Result;
4+
use bootstrap::{load_config, log_domain_stack_init, wait_for_shutdown_signal};
25
use clap::{ArgAction, Parser};
36
use pai_core::adapters::HardcodedFlowRunner;
47
use pai_core::domain::{EventBus, SessionManager};
58
use pai_core::ports::{InferenceError, InferencePort};
69
use std::path::PathBuf;
710
use std::sync::Arc;
8-
use tracing::{debug, error, info};
11+
use tracing::{debug, info};
912
use tracing_subscriber::FmtSubscriber;
1013

1114
/// Command line arguments for the paiOS engine.
@@ -23,10 +26,8 @@ struct Args {
2326

2427
#[tokio::main]
2528
async fn main() -> Result<()> {
26-
// 1. Parse command line arguments
2729
let args = Args::parse();
2830

29-
// 2. Initialize logging (tracing) based on verbosity
3031
let log_level = match args.verbose {
3132
0 => tracing::Level::INFO,
3233
1 => tracing::Level::DEBUG,
@@ -37,7 +38,12 @@ async fn main() -> Result<()> {
3738

3839
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
3940

40-
// 3. Bootstrap engine — core session orchestration (stub inference until real adapters wire in)
41+
load_config(args.config.as_deref())?;
42+
43+
info!(target: "pai_engine::bootstrap", "pai-engine composition root starting");
44+
45+
log_domain_stack_init();
46+
4147
#[derive(Debug)]
4248
struct StubInference;
4349

@@ -63,24 +69,22 @@ async fn main() -> Result<()> {
6369
}
6470
});
6571
info!(
66-
"Booting paiOS engine workspace (session state: {:?})...",
72+
target: "pai_engine::bootstrap",
73+
"session orchestration ready (state: {:?})",
6774
session.state_machine().state()
6875
);
6976

70-
if let Some(path) = args.config {
71-
info!("Using configuration from: {}", path.display());
72-
} else {
73-
info!("No configuration file provided, using defaults.");
74-
}
75-
76-
// TODO: In future steps, construct adapters and wire domain crates via `core`.
77+
info!(
78+
target: "pai_engine::bootstrap",
79+
"engine main loop running (waiting for shutdown signal)"
80+
);
7781

78-
// 4. Wait for shutdown signal to keep the process alive
79-
if let Err(e) = tokio::signal::ctrl_c().await {
80-
error!("Failed to listen for shutdown signal: {e}");
81-
}
82+
wait_for_shutdown_signal().await;
8283

83-
info!("Shutdown signal received. Exiting paiOS engine.");
84+
info!(
85+
target: "pai_engine::bootstrap",
86+
"shutdown complete; exiting pai-engine"
87+
);
8488

8589
Ok(())
8690
}

0 commit comments

Comments
 (0)