frame-cli 0.1.0

CLI for Frame — scaffold applications, manage components, run dev server
Documentation
//! Composition host for the generated Frame application.

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};

use beamr::module::ModuleRegistry;
use beamr::namespace::NamespaceId;
use beamr::scheduler::{Scheduler, SchedulerConfig, SchedulerServices};
use beamr::term::Term;
use frame_core::capability::{Capability, CapabilityRequest, GrantProvenance, PathRoot};
use frame_core::component::{
    ChildArgument, ChildSpec, ComponentId, ComponentMeta, SupervisionPolicy,
};
use frame_core::registry::ComponentRegistry;
use frame_core::supervision::LifecycleConfig;
use frame_state::{ComponentSchema, ComponentStateStore, EntityId, StateError};

const COMPONENT_BEAM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/{{GLEAM_NAME}}.beam"));
const FFI_BEAM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/{{GLEAM_NAME}}_ffi.beam"));
const CHILD: &str = "entity_actor";
const LIVENESS_MESSAGE: i64 = 0;
const STOP_MESSAGE: i64 = 2;
const ENTITY_BYTES: &[u8] = br#"{"kind":"welcome","message":"hello from {{NAME}}"}"#;

// Explicit local-development policy: Frame intentionally supplies no lifecycle defaults.
// Process observation is event-driven; only semantic operation deadlines remain.
const SCHEDULER_THREADS: usize = 2;
const OPERATION_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_RESTARTS: usize = 3;
const RESTART_WINDOW: Duration = Duration::from_secs(30);

/// Runs install, start, entity round-trip, and explicit ordered stop.
///
/// # Errors
///
/// Returns the first typed substrate error without hiding a failed composition step.
pub fn run_application(state_path: &Path) -> Result<EntityId, Box<dyn std::error::Error>> {
    let state_path = absolute(state_path)?;
    let state = ComponentStateStore::open(&state_path)?;
    let _reconcile_report = state.reconcile()?; // Boot law: reconcile before starting anything.

    // compose_scheduler populates the BIF registry (gate 1) before construction;
    // bare Scheduler::with_services installs an EMPTY one and every erlang:*
    // import in hot-loaded bytecode defers into a process-fatal dispatch refusal.
    let scheduler = Arc::new(frame_core::composition::compose_scheduler(
        SchedulerConfig {
            thread_count: Some(SCHEDULER_THREADS),
            ..SchedulerConfig::default()
        },
        SchedulerServices::minimal(),
        Arc::new(ModuleRegistry::new()),
    )?);
    let registry = ComponentRegistry::new(
        Arc::clone(&scheduler),
        LifecycleConfig {
            operation_timeout: OPERATION_TIMEOUT,
        },
    );
    let ffi = scheduler.hot_load_module(FFI_BEAM)?;
    let id = component_id();
    let need = storage_need(&state_path);
    registry.register(component_meta(id, need.clone()), COMPONENT_BEAM.to_vec())?;
    let store = match state.component_handle(id) {
        Ok(active) => active,
        Err(StateError::NotActive { component }) if component == id => {
            state.declare_schema(id, ComponentSchema::lww())?;
            state.install_storage(id)?
        }
        Err(error) => return Err(error.into()),
    };

    // F-1b boot convention: declared grants MUST be applied before component start.
    registry.host_capabilities().grant(
        id,
        need,
        GrantProvenance {
            granted_by: "generated composition host".to_owned(),
            granted_at: SystemTime::now(),
        },
    )?;
    registry.start(id)?;
    if registry.probe_child(id, CHILD)? != LIVENESS_MESSAGE {
        return Err("component liveness mailbox round-trip returned the wrong value".into());
    }

    // ComponentStoreHandle is deliberately host-only, so this adapter performs the
    // storage act and sends the BEAM actor an i64 witness derived from the entity id.
    let entity_id = store.put(ENTITY_BYTES)?;
    let entity_witness = entity_witness(entity_id);
    registry.send_child(id, CHILD, entity_witness)?;
    if registry.probe_child(id, CHILD)? != entity_witness {
        return Err("component entity-id mailbox round-trip returned the wrong value".into());
    }
    store.commit()?;
    if store.get(entity_id)?.as_deref() != Some(ENTITY_BYTES) {
        return Err("component store did not return the round-tripped entity".into());
    }

    // Explicit ordered stop drains the tree; Scheduler::shutdown is not cleanup.
    registry.stop(id)?;
    unload_support_module(&scheduler, ffi.module_name)?;
    if scheduler.process_count() != 0 {
        return Err("ordered stop leaked a live process tree".into());
    }
    // Stop is not uninstall: archive_storage is intentionally NOT called here.
    scheduler.shutdown();
    Ok(entity_id)
}

fn component_id() -> ComponentId {
    ComponentId::derive("frame.generated", "{{NAME}}")
}

fn entity_witness(entity: EntityId) -> i64 {
    let bytes = entity.as_bytes();
    let raw = u64::from_le_bytes([
        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
    ]);
    let witness = (raw & Term::SMALL_INT_MAX.cast_unsigned()).cast_signed();
    if witness <= STOP_MESSAGE {
        witness + STOP_MESSAGE + 1
    } else {
        witness
    }
}

fn storage_need(path: &Path) -> CapabilityRequest {
    CapabilityRequest::new(Capability::FsWrite {
        root: PathRoot(path.to_path_buf()),
    })
}

fn component_meta(id: ComponentId, need: CapabilityRequest) -> ComponentMeta {
    ComponentMeta {
        id,
        name: "{{NAME}} component".to_owned(),
        version: "0.1.0".to_owned(),
        requires: Vec::new(),
        provides: Vec::new(),
        needs: vec![need],
        children: vec![ChildSpec {
            name: CHILD.to_owned(),
            module: "{{GLEAM_NAME}}".to_owned(),
            function: "run".to_owned(),
            arguments: vec![ChildArgument::SupervisorPid],
            liveness_message: LIVENESS_MESSAGE,
            stop_message: STOP_MESSAGE,
        }],
        supervision: Some(SupervisionPolicy {
            max_restarts: MAX_RESTARTS,
            window: RESTART_WINDOW,
        }),
    }
}

fn unload_support_module(
    scheduler: &Scheduler,
    module: beamr::atom::Atom,
) -> Result<(), Box<dyn std::error::Error>> {
    if scheduler.check_old_code(module) {
        scheduler.purge_module(module)?;
    }
    if !scheduler.delete_module(module)
        || scheduler
            .lookup_module_in(NamespaceId::DEFAULT, module)
            .is_some()
    {
        return Err("support module remained loaded after ordered stop".into());
    }
    Ok(())
}

fn absolute(path: &Path) -> Result<PathBuf, std::io::Error> {
    if path.is_absolute() {
        Ok(path.to_path_buf())
    } else {
        Ok(std::env::current_dir()?.join(path))
    }
}