frame-cli 0.2.0

CLI for Frame — frame new scaffolds a complete Frame application: Rust composition host, Gleam component, and a connected browser page served by one binary
Documentation
//! Composition host for the generated Frame application: the component
//! identity, its durable-state schema, this host's stated runtime policy,
//! and the application spec handed to frame-host's embedding seam.
//!
//! This host imports no low-level process-runtime crate and carries no such
//! manifest entry: the component runtime, its scheduler composition, and its
//! feature selection are frame-core's own published dependency, declared
//! once, in one place.

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

use frame_core::component::{
    ChildArgument, ChildSpec, ComponentId, ComponentMeta, SupervisionPolicy,
};
use frame_core::registry::ComponentRegistry;
use frame_core::runtime::{ComponentRuntime, RuntimePolicy, mailbox_integer};
use frame_host::{AppError, AppSpec, ComponentInstall, FrameConfig};
use frame_state::{
    ComponentSchema, ComponentStateStore, ComponentStoreHandle, 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}}"}"#;
const CONFIG_PATH: &str = "frame.toml";
const STATE_PATH: &str = ".frame/state";

// 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);

/// Loads `frame.toml`, opens durable state, and boots the full application
/// stack (the component, the embedded messaging server, and the page
/// server) around it, serving until a shutdown signal.
///
/// # Errors
///
/// Returns the first typed failure from config loading, durable-state
/// reconcile, boot, serving, or ordered shutdown.
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
    let config = FrameConfig::load(Path::new(CONFIG_PATH))?;
    println!("{{NAME}} serving at http://{}", config.frame.bind);
    let state = ComponentStateStore::open(STATE_PATH)?;
    let _reconcile_report = state.reconcile()?; // Reconcile before starting anything.
    let store = resolve_store_handle(&state)?;
    frame_host::run_application(&config, app_spec(store))?;
    Ok(())
}

/// Bounded, network-free proof used by the test suite: composes the
/// component runtime directly (no embedded messaging server, no page
/// server), installs and starts the component, round-trips one entity
/// through durable storage, then stops in order and verifies zero process
/// residue.
///
/// # Errors
///
/// Returns the first typed substrate error without hiding a failed
/// composition step.
pub fn bounded_round_trip(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()?;
    let store = resolve_store_handle(&state)?;

    let runtime = ComponentRuntime::compose(RuntimePolicy {
        scheduler_threads: SCHEDULER_THREADS,
        operation_timeout: OPERATION_TIMEOUT,
    })?;
    let support = runtime.load_support_module(FFI_BEAM)?;
    let id = component_id();
    runtime
        .registry()
        .register(component_meta(), COMPONENT_BEAM.to_vec())?;
    runtime.registry().start(id)?;
    if runtime.registry().probe_child(id, CHILD)? != LIVENESS_MESSAGE {
        return Err("component liveness mailbox round-trip returned the wrong value".into());
    }

    let entity = round_trip_entity(runtime.registry(), &store)?;

    // Explicit ordered stop drains the tree before the runtime's own residue
    // check; `ComponentRuntime::shutdown` is never treated as cleanup.
    runtime.registry().stop(id)?;
    runtime.unload_support_module(support)?;
    if runtime.live_process_count() != 0 {
        return Err("ordered stop leaked a live process tree".into());
    }
    runtime.shutdown()?;
    Ok(entity)
}

/// Resolves this component's durable-state handle, declaring its schema and
/// installing storage on the first run.
fn resolve_store_handle(state: &ComponentStateStore) -> Result<ComponentStoreHandle, StateError> {
    match state.component_handle(component_id()) {
        Ok(handle) => Ok(handle),
        Err(StateError::NotActive { component }) if component == component_id() => {
            state.declare_schema(component_id(), ComponentSchema::lww())?;
            state.install_storage(component_id())
        }
        Err(error) => Err(error),
    }
}

/// Builds the application spec the embedding seam boots: the component
/// install, this host's stated runtime policy, a liveness readiness proof,
/// and the entity-storage round trip announced as a real fact once the
/// messaging server is connected.
fn app_spec(store: ComponentStoreHandle) -> AppSpec {
    AppSpec {
        components: vec![ComponentInstall {
            meta: component_meta(),
            bytecode: COMPONENT_BEAM.to_vec(),
            support_modules: vec![FFI_BEAM.to_vec()],
        }],
        policy: RuntimePolicy {
            scheduler_threads: SCHEDULER_THREADS,
            operation_timeout: OPERATION_TIMEOUT,
        },
        readiness: Box::new(|registry| {
            let answer = registry
                .probe_child(component_id(), CHILD)
                .map_err(|error| AppError::new(format!("liveness probe failed: {error}")))?;
            if answer == LIVENESS_MESSAGE {
                Ok(())
            } else {
                Err(AppError::new(format!(
                    "component answered the liveness probe with {answer}, expected {LIVENESS_MESSAGE}"
                )))
            }
        }),
        announce: Box::new(move |registry, announcer| {
            let entity = round_trip_entity(registry, &store)
                .map_err(|error| AppError::new(format!("entity round trip failed: {error}")))?;
            announcer
                .announce_fact(serde_json::json!({
                    "componentId": component_id().to_string(),
                    "entityId": entity.to_string(),
                }))
                .map_err(|error| AppError::new(format!("fact announcement refused: {error}")))
        }),
    }
}

/// Stores the welcome entity, sends the running component an acknowledgement
/// derived from the stored entity's id, and requires both the mailbox
/// answer and the committed read to agree — the round trip the served
/// page's real data traces back to.
fn round_trip_entity(
    registry: &ComponentRegistry,
    store: &ComponentStoreHandle,
) -> Result<EntityId, Box<dyn std::error::Error>> {
    let id = component_id();
    let entity = store.put(ENTITY_BYTES)?;
    // The component's mailbox speaks integers; fold the stored entity's id
    // into an acknowledgement value the actor echoes back, landing strictly
    // above the protocol's own reserved liveness/stop values.
    let entity_ack = mailbox_integer(entity.as_bytes(), STOP_MESSAGE + 1)?;
    registry.send_child(id, CHILD, entity_ack)?;
    if registry.probe_child(id, CHILD)? != entity_ack {
        return Err("component entity-id mailbox round-trip returned the wrong value".into());
    }
    store.commit()?;
    if store.get(entity)?.as_deref() != Some(ENTITY_BYTES) {
        return Err("component store did not return the round-tripped entity".into());
    }
    Ok(entity)
}

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

/// Full manifest for the generated component.
///
/// `needs` is empty by design: the component's message contract is a raw
/// mailbox receive loop with no host-mediated authority of its own, so the
/// honest declaration is none — the capability table simply holds zero
/// grants for it. The durable entity store above is opened directly by this
/// host, never by the component.
fn component_meta() -> ComponentMeta {
    ComponentMeta {
        id: component_id(),
        name: "{{NAME}} component".to_owned(),
        version: "0.1.0".to_owned(),
        requires: Vec::new(),
        provides: Vec::new(),
        needs: Vec::new(),
        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 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))
    }
}