feat(homecore): complete migration and startup restore

This commit is contained in:
ruv
2026-07-27 14:14:04 -04:00
parent 3136f1305b
commit 0a8e72e762
24 changed files with 1552 additions and 481 deletions
+17
View File
@@ -47,6 +47,9 @@ pub struct Context {
}
impl Context {
/// Marker stored on snapshots loaded from durable state at startup.
pub const RESTORE_USER_ID: &'static str = "homecore.restore";
pub fn new() -> Self {
Self::default()
}
@@ -66,6 +69,20 @@ impl Context {
parent_id: Some(parent.id),
}
}
/// Create a fresh context that identifies a startup restoration. The
/// persisted context, when valid, is retained as the causal parent.
pub fn restoration(parent_id: Option<Uuid>) -> Self {
Self {
id: Uuid::new_v4(),
user_id: Some(Self::RESTORE_USER_ID.to_owned()),
parent_id,
}
}
pub fn is_restoration(&self) -> bool {
self.user_id.as_deref() == Some(Self::RESTORE_USER_ID)
}
}
impl Default for Context {
+7 -1
View File
@@ -6,7 +6,7 @@
use std::sync::Arc;
use crate::bus::EventBus;
use crate::registry::EntityRegistry;
use crate::registry::{DeviceRegistry, EntityRegistry};
use crate::service::ServiceRegistry;
use crate::state::StateMachine;
@@ -20,6 +20,7 @@ struct HomeCoreInner {
pub states: StateMachine,
pub services: ServiceRegistry,
pub entities: EntityRegistry,
pub devices: DeviceRegistry,
}
impl HomeCore {
@@ -31,6 +32,7 @@ impl HomeCore {
services: ServiceRegistry::with_event_bus(bus.clone()),
bus,
entities: EntityRegistry::new(),
devices: DeviceRegistry::new(),
}),
}
}
@@ -50,6 +52,10 @@ impl HomeCore {
pub fn entities(&self) -> &EntityRegistry {
&self.inner.entities
}
pub fn devices(&self) -> &DeviceRegistry {
&self.inner.devices
}
}
impl Default for HomeCore {
+8 -9
View File
@@ -11,7 +11,7 @@
//! - [`state`] — `StateMachine`: DashMap-backed concurrent state store
//! - [`bus`] — `EventBus`: tokio broadcast wiring for system + domain events
//! - [`service`] — `ServiceRegistry` (stub; full mpsc dispatch lands in P2)
//! - [`registry`] — `EntityRegistry` (in-memory P1; persistence lands in P2)
//! - [`registry`] — in-memory entity and device registries, restored by the server
//! - [`homecore`] — `HomeCore` runtime coordinator: holds bus + states + services
//!
//! ## Threading model
@@ -23,31 +23,30 @@
//!
//! ## What's NOT here yet (deferred to P2+)
//!
//! - Persistence of entity registry to `.homecore/storage/core.entity_registry`
//! - Automatic persistence of registry mutations (startup restoration exists)
//! - Schema validation (`schemas` module from §3 stub)
//! - Service handler mpsc dispatch (`service::ServiceRegistry::call`)
//! - Device registry (mirror of HA's `core.device_registry`)
//! - Witness chain integration (ADR-028)
//!
//! Each is marked `// TODO P2:` at the relevant call site.
pub mod bus;
pub mod entity;
pub mod event;
pub mod state;
pub mod bus;
pub mod service;
pub mod registry;
pub mod service;
pub mod state;
mod homecore;
pub use homecore::HomeCore;
pub use bus::EventBus;
pub use entity::{EntityId, EntityIdError, State};
pub use event::{Context, DomainEvent, EventType, StateChangedEvent, SystemEvent};
pub use state::StateMachine;
pub use bus::EventBus;
pub use registry::{DeviceEntry, DeviceRegistry, EntityCategory, EntityEntry, EntityRegistry};
pub use service::{ServiceCall, ServiceError, ServiceName, ServiceRegistry};
pub use registry::{EntityCategory, EntityEntry, EntityRegistry};
pub use state::StateMachine;
/// HOMECORE protocol/data-model version. Bumped when the public surface
/// or on-disk persistence schema changes in a backwards-incompatible way.
+77 -3
View File
@@ -1,9 +1,9 @@
//! In-memory entity registry (P1). Persistence to
//! `.homecore/storage/core.entity_registry` lands in P2.
//! In-memory entity and device registries. Durable files are loaded by
//! `homecore-server` during bounded startup restoration.
//!
//! Schema fields mirror HA `core.entity_registry` v13 per ADR-127 §2.4.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -43,6 +43,41 @@ pub struct EntityEntry {
pub config_entry_id: Option<String>,
}
/// Physical-device metadata persisted in `core.device_registry`.
///
/// The fields track the HA v13 registry surface used by HOMECORE. Identifier
/// and connection pairs are sets because their order is not semantically
/// meaningful in HA.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeviceEntry {
pub id: String,
#[serde(default)]
pub config_entries: HashSet<String>,
#[serde(default)]
pub identifiers: HashSet<(String, String)>,
#[serde(default)]
pub connections: HashSet<(String, String)>,
pub manufacturer: Option<String>,
pub model: Option<String>,
pub model_id: Option<String>,
pub name: Option<String>,
pub name_by_user: Option<String>,
pub sw_version: Option<String>,
pub hw_version: Option<String>,
pub serial_number: Option<String>,
pub via_device_id: Option<String>,
pub area_id: Option<String>,
pub entry_type: Option<String>,
pub disabled_by: Option<String>,
pub configuration_url: Option<String>,
#[serde(default)]
pub labels: HashSet<String>,
pub primary_config_entry: Option<String>,
/// Forward-compatible device fields from newer HA v13-compatible rows.
#[serde(default, flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone)]
pub struct EntityRegistry {
entries: Arc<RwLock<HashMap<EntityId, EntityEntry>>>,
@@ -89,6 +124,45 @@ impl Default for EntityRegistry {
}
}
#[derive(Clone)]
pub struct DeviceRegistry {
entries: Arc<RwLock<HashMap<String, DeviceEntry>>>,
}
impl DeviceRegistry {
pub fn new() -> Self {
Self {
entries: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn register(&self, entry: DeviceEntry) {
self.entries.write().await.insert(entry.id.clone(), entry);
}
pub async fn get(&self, id: &str) -> Option<DeviceEntry> {
self.entries.read().await.get(id).cloned()
}
pub async fn all(&self) -> Vec<DeviceEntry> {
self.entries.read().await.values().cloned().collect()
}
pub async fn len(&self) -> usize {
self.entries.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.entries.read().await.is_empty()
}
}
impl Default for DeviceRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
+19 -1
View File
@@ -14,7 +14,6 @@
//!
//! - `async_set_internal` schema validation
//! - Bulk delete of an entire domain (`async_remove_domain`)
//! - Restore-state on startup from the recorder (ADR-132)
use std::sync::Arc;
@@ -158,6 +157,19 @@ impl StateMachine {
next
}
/// Install a durable snapshot during startup without emitting a new
/// state-change event. Callers must mark the snapshot context as a
/// restoration; this prevents accidental use as a silent runtime write.
pub fn restore(&self, snapshot: State) -> Result<Arc<State>, RestoreStateError> {
if !snapshot.context.is_restoration() {
return Err(RestoreStateError::UnmarkedContext);
}
let entity_id = snapshot.entity_id.clone();
let snapshot = Arc::new(snapshot);
self.inner.states.insert(entity_id, Arc::clone(&snapshot));
Ok(snapshot)
}
/// Remove a state. Fires `state_changed` with `new_state = None`.
pub fn remove(&self, entity_id: &EntityId) -> Option<Arc<State>> {
let removed = self.inner.states.remove(entity_id).map(|(_, s)| s);
@@ -213,6 +225,12 @@ impl Default for StateMachine {
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum RestoreStateError {
#[error("restored state context is not marked as a restoration")]
UnmarkedContext,
}
#[cfg(test)]
mod tests {
use super::*;