Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 203 additions & 17 deletions happ/crates/holons_guest/src/guest_shared_objects/commit_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use holons_core::{
};

use base_types::{BaseValue, MapString};
use core_types::HolonError;
use core_types::{HolonError, HolonId};
use holons_core::core_shared_objects::transactions::{
TransactionContext, TransactionContextHandle,
};
Expand Down Expand Up @@ -247,25 +247,59 @@ pub fn commit(
// and mark the overall response incomplete. This keeps failure handling bounded and avoids
// cascading secondary errors.
let mut first_error: Option<HolonError> = None;
let source_holon_ref = HolonReference::Staged(staged_reference.clone());
let source_key: Option<MapString> = match staged_reference.key() {
Ok(key) => key,
Err(err) => {
first_error = Some(err);
None
}
};

for (name, holon_collection_rc) in relationship_collections {
debug!("COMMITTING {:#?} relationship", name.0.clone());
if first_error.is_none() {
for (name, holon_collection_rc) in relationship_collections {
debug!("COMMITTING {:#?} relationship", name.0.clone());

let holon_collection = holon_collection_rc.read().map_err(|e| {
HolonError::FailedToAcquireLock(format!(
"Failed to acquire read lock on relationship collection for {}: {}",
name.0 .0, e
))
})?;
let holon_collection = holon_collection_rc.read().map_err(|e| {
HolonError::FailedToAcquireLock(format!(
"Failed to acquire read lock on relationship collection for {}: {}",
name.0 .0, e
))
})?;

if let Err(err) = commit_relationship(
context,
source_local_id.clone(),
name.clone(),
&holon_collection,
) {
first_error = Some(err);
break;
if let Err(err) = commit_relationship(
context,
source_local_id.clone(),
name.clone(),
&holon_collection,
) {
first_error = Some(err);
break;
}

match resolve_inverse_relationship_name(&source_holon_ref, &name) {
Ok(Some(inverse_name)) => {
if let Err(err) = commit_inverse_smartlinks(
&source_local_id,
source_key.clone(),
&inverse_name,
&holon_collection,
) {
first_error = Some(err);
break;
}
}
Ok(None) => {
debug!(
"No inverse resolved for '{}' — skipping inverse SmartLink",
name.0.0
);
}
Err(schema_err) => {
first_error = Some(schema_err);
break;
}
}
}
}

Expand Down Expand Up @@ -300,6 +334,158 @@ pub fn commit(
Ok(response_reference)
}

/// Clones collection members under a short-lived read lock so callers can traverse
/// references without holding the collection lock across later resolution steps.
fn clone_collection_members(
collection_arc: Arc<RwLock<HolonCollection>>,
collection_label: &str,
) -> Result<Vec<HolonReference>, HolonError> {
let collection = collection_arc.read().map_err(|e| {
HolonError::FailedToAcquireLock(format!(
"Failed to acquire read lock on {} holon collection: {}",
collection_label, e
))
})?;

collection.is_accessible(AccessType::Read)?;
Ok(collection.get_members().clone())
}

/// Resolves the inverse relationship name declared for a forward relationship on the
/// source holon's descriptor, if that schema contract exists.
fn resolve_inverse_relationship_name(
source_holon_ref: &HolonReference,
forward_name: &RelationshipName,
) -> Result<Option<RelationshipName>, HolonError> {
// Descriptor lookup: undescribed holons have no schema contract to enforce.
let Some(source_descriptor_ref) = source_holon_ref.get_descriptor()? else {
debug!(
"No descriptor found for {} while resolving inverse for '{}'",
source_holon_ref.reference_id_string(),
forward_name.0.0
);
return Ok(None);
};

// Declared relationship lookup: search the descriptor's declared instance relationships by type name.
let declared_relationship_refs = clone_collection_members(
source_descriptor_ref.related_holons(CoreRelationshipTypeName::InstanceRelationships)?,
"InstanceRelationships",
)?;

let mut matched_declared_relationship_ref: Option<HolonReference> = None;
for declared_relationship_ref in declared_relationship_refs {
let type_name_value =
declared_relationship_ref.property_value(CorePropertyTypeName::TypeName)?;
let Some(type_name_value) = type_name_value else {
continue;
};

let type_name_string: String = (&type_name_value).into();
if type_name_string == forward_name.0.0 {
matched_declared_relationship_ref = Some(declared_relationship_ref);
break;
}
}

let Some(matched_declared_relationship_ref) = matched_declared_relationship_ref else {
debug!(
"No declared relationship descriptor matched '{}' for {}",
forward_name.0.0,
source_descriptor_ref.reference_id_string()
);
return Ok(None);
};

// Inverse lookup: a matched declared relationship must have exactly one HasInverse target.
let inverse_relationship_refs = clone_collection_members(
matched_declared_relationship_ref.related_holons(CoreRelationshipTypeName::HasInverse)?,
"HasInverse",
)?;

if inverse_relationship_refs.is_empty() {
return Err(HolonError::InvalidRelationship(
forward_name.0.0.clone(),
format!(
"descriptor {} is missing HasInverse",
source_descriptor_ref.reference_id_string()
),
));
}

if inverse_relationship_refs.len() > 1 {
return Err(HolonError::DuplicateError(
"HasInverse".to_string(),
format!(
"declared relationship '{}' on {}",
forward_name.0.0,
source_descriptor_ref.reference_id_string()
),
));
}

// Type-name extraction: the inverse descriptor's TypeName becomes the inverse relationship name.
let inverse_relationship_ref = inverse_relationship_refs.into_iter().next().expect(
"inverse_relationship_refs length checked above to contain exactly one member",
);

let inverse_type_name_value = inverse_relationship_ref
.property_value(CorePropertyTypeName::TypeName)?
.ok_or_else(|| {
HolonError::EmptyField(format!(
"TypeName missing on inverse relationship descriptor {}",
inverse_relationship_ref.reference_id_string()
))
})?;

let inverse_type_name_string: String = (&inverse_type_name_value).into();
Ok(Some(RelationshipName(MapString(inverse_type_name_string))))
}

/// Persists inverse SmartLinks by reversing the endpoints of each forward member
/// and re-tagging the link with the inverse relationship name.
fn commit_inverse_smartlinks(
source_local_id: &LocalId,
source_key: Option<MapString>,
inverse_name: &RelationshipName,
collection: &HolonCollection,
) -> Result<(), HolonError> {
let key_prop = CorePropertyTypeName::Key.as_property_name();

// Iterate the forward targets and materialize the inverse link only for local targets.
for (idx, holon_reference) in collection.get_members().iter().enumerate() {
let target_id = holon_reference.holon_id()?;

if target_id.is_external() {
debug!(
"Skipping inverse SmartLink for external target at index {} on '{}': {:?}",
idx,
inverse_name.0.0,
target_id
);
continue;
}

let target_local_id = target_id.local_id().clone();

// Preserve key semantics: the inverse target is the original source, so encode the source key.
let smart_property_values = source_key.clone().map(|key| {
let mut property_map: PropertyMap = BTreeMap::new();
property_map.insert(key_prop.clone(), BaseValue::StringValue(key));
property_map
});

save_smartlink(SmartLink {
from_address: target_local_id,
to_address: HolonId::Local(source_local_id.clone()),
relationship_name: inverse_name.clone(),
smart_property_values,
})?;
}

Ok(())
}

/// Attempts to persist the holon referenced by the given [`StagedReference`].
///
/// This low-level persistence routine determines the holon's current [`StagedState`]
Expand Down
20 changes: 19 additions & 1 deletion happ/crates/holons_loader/src/loader_ref_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,29 @@
//
// Pass-2 (Resolver): Transform queued LoaderRelationshipReference holons into
// concrete writes on staged holons. Implements the multi‑pass, graph‑driven
// inverse handling policy:
// resolution policy:
// Pass-2a: write DescribedBy (declared) first
// Pass-2b: write InverseOf (declared) next (no endpoint prefilter)
// Pass-2c: resolve remaining relationships via fixed-point iteration
//
// ## All staged relationships are declared relationships
//
// The JSON import format allows relationships to be expressed from either
// direction as an authoring convenience. LRRs with IsDeclared=false represent
// relationships that were authored from the inverse direction. In Pass-2c,
// `try_inverse_single_resolve()` translates these by:
// 1. deriving the declared relationship name via type-graph walk,
// 2. flipping source and target to the declared orientation, and
// 3. writing the relationship under the declared name on the flipped source.
//
// As a result, every relationship that ends up in a staged holon's relationship
// map is a declared relationship. Inverse relationships are NEVER staged here.
//
// Inverse SmartLinks are materialized by `commit_functions::commit()` (Pass 2)
// at persistence time, based on the schema's HasInverse contract. This resolver
// is not responsible for producing inverse links — only for ensuring every
// declared relationship is correctly staged before commit runs.
//
// Design goals:
// - Self‑contained, self‑describing code with explicit invariants
// - No global/in‑memory inverse name index; resolution is graph‑proven
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,25 @@ pub(crate) async fn launch_holochain_runtime(
clean_dev_conductor_state(&dev_dir);

// DangerTestKeystore is set in the config; no lair process needed.
Conductor::builder().config(conductor_config).build().await?
//
// Warm-start cleanup tries to preserve WASM cache artifacts for faster restarts, but if
// that preserved state is inconsistent after prior crashes or migrations, conductor build
// can still fail. In dev mode we prefer a self-healing retry over forcing the user to
// manually delete /tmp state.
match Conductor::builder().config(conductor_config.clone()).build().await {
Ok(conductor) => conductor,
Err(first_err) => {
tracing::warn!(
"[LAUNCH] DEV MODE: conductor build failed after cache-preserving reset: {:?}",
first_err
);
tracing::warn!(
"[LAUNCH] DEV MODE: retrying after full dev conductor wipe (WASM cache will be rebuilt)"
);
wipe_dev_conductor_dir(&dev_dir);
Conductor::builder().config(conductor_config).build().await?
}
}
} else {
tracing::info!("[LAUNCH] Spawning lair keystore (in-proc)...");
let t0 = std::time::Instant::now();
Expand Down Expand Up @@ -421,3 +439,26 @@ fn clean_dev_conductor_state(conductor_dir: &std::path::Path) {
t.elapsed().as_secs_f64()
);
}

/// Fall back to a complete dev-dir wipe when cache-preserving cleanup still leaves the
/// conductor unable to start. This trades startup speed for reliability in dev mode.
fn wipe_dev_conductor_dir(conductor_dir: &std::path::Path) {
if conductor_dir.exists() {
if let Err(err) = std::fs::remove_dir_all(conductor_dir) {
tracing::warn!(
"[LAUNCH] DEV MODE: failed to remove conductor dir {:?}: {}",
conductor_dir,
err
);
return;
}
}

if let Err(err) = std::fs::create_dir_all(conductor_dir) {
tracing::warn!(
"[LAUNCH] DEV MODE: failed to recreate conductor dir {:?}: {}",
conductor_dir,
err
);
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
"host/map-sdk"
],
"scripts": {
"start": "npm run build:happ && RUST_LOG=${RUST_LOG:-host:warn} WASM_LOG=${WASM_LOG:-warn} HC_DEV_MODE=${HC_DEV_MODE:-1} npm run start -w host",
"start": "npm run build:happ && RUST_LOG=${RUST_LOG:-host:warn} WASM_LOG=${WASM_LOG:-warn} HC_DEV_MODE=${HC_DEV_MODE:-0} npm run start -w map-host",
"start:info": "npm run build:happ && RUST_LOG=host=info npm run start:host",
"start:debug": "npm run build:happ && RUST_LOG=host=debug npm run start:host",
"start:host": "npm run start -w map-host",
"start:nobuild": "RUST_LOG=host:debug WASM_LOG=info HC_DEV_MODE=1 npm run start -w host",
"start:nobuild": "RUST_LOG=host:debug WASM_LOG=info HC_DEV_MODE=1 npm run start -w map-host",

"build": "npm run build:happ && npm run build:host",
"build:host": "npm run build -w map-host",
Expand Down
Loading
Loading