feat(nakui-ui): Action::Morphism wired al pipeline real (compute -> log -> apply)

Cierra el ultimo gran TODO de la metainterfaz Nakui: las acciones
Action::Morphism ya no son un toast informativo; despachan al
Executor cargado del manifest nakui-core (nsmc.json + schemas KCL +
scripts Rhai), pasando por el pipeline completo: compute (con
dry-run + KCL post-checks) -> log append -> store apply.

Schema nakui-ui-schema extendido:
- Module.nakui_module_dir: Option<String> nuevo. Path al modulo
  nakui-core. Sin esto, Action::Morphism quedan no-op con toast.
  SeedEntity sigue funcionando (alta administrativa sin manifest).
- Action::Morphism gano dos campos opcionales:
  - inputs: BTreeMap<String, String> — mapeo role -> field_name.
  - params: Vec<String> — fields cuyos values van al params JSON.
    Si vacio, todos los fields no-input van a params.

Runtime nakui-ui:
- MetaUi.executors: BTreeMap<String, Arc<Executor>> nuevo. Carga
  Executor::load_module(nakui_module_dir) en MetaUi::new.
- commit_morphism: resuelve inputs (parsea UUIDs), arma params
  (Value object con tipos inferidos), llama
  execute_and_log_with_recovery. Toast con count de ops o error.
- infer_param_value: heuristica i64 -> f64 -> bool -> string.

Tests: 2 nuevos. E2E morphism_pipeline_executes_real_sales_vender
carga el modulo real crates/modules/nakui/modules/sales, ejecuta
"vender" con inputs Stock+Caja y params (cantidad=5, precio=200,
venta_id, timestamp). Asserta:
- el morphism produce ops (no vacio).
- stock.cantidad: 100 -> 95.
- caja.saldo: 1_000_000 -> 1_001_000.

12 tests verdes en nakui-ui (+1). Schema extension no rompio nada
(6 unit + 5 integration siguen verdes).

Demo nuevo: examples/nakui-modules/sales_engine/module.json apunta
al sales real via nakui_module_dir. 6 vistas (list+form para Stock/
Caja/Venta + "Vender" con Action::Morphism). El user crea Stocks +
Cajas con seed_entity, copia los UUIDs a los inputs de "Vender", y
ejecuta el morphism real con KCL post-checks.

Activacion:
  NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\
  NAKUI_MODULES_DIR=examples/nakui-modules \\
  cargo run -p nakui-ui

Trade-offs:
- Inputs UUID a mano (no dropdown). Nice-to-have: FieldKind::EntityRef
  que renderee selector.
- Inferencia de tipo en params es heuristica.
This commit is contained in:
Sergio
2026-05-09 20:41:37 +00:00
parent 170d1f890a
commit 932e7464d7
5 changed files with 601 additions and 8 deletions
+36 -1
View File
@@ -66,6 +66,22 @@ pub struct Module {
#[serde(default)]
pub entities: Vec<EntitySpec>,
/// Path a un módulo nakui-core (directorio con `nsmc.json` +
/// schemas KCL + scripts Rhai). Cuando está set, el runtime
/// carga un `Executor` para ese path y permite que las acciones
/// `Morphism { name }` despachen al pipeline real
/// (compute → log → apply).
///
/// Path resuelto relativo al directorio del `module.json`
/// o absoluto.
///
/// Si es `None`, las acciones `Morphism` quedan deshabilitadas
/// (toast informativo al usuario). Las acciones `SeedEntity`
/// siguen funcionando sin esto — son altas administrativas que
/// no necesitan validación de manifest.
#[serde(default)]
pub nakui_module_dir: Option<String>,
/// Items del menú. Cada uno apunta a una key de `views`. Orden
/// importa (es el orden en que se presentan en el sidebar).
pub menu: Vec<MenuItem>,
@@ -199,9 +215,27 @@ pub enum Action {
next_view: Option<String>,
},
/// Ejecuta un morphism declarado en el manifest del módulo
/// nakui-core. Inputs y params se mapean desde los campos del form.
/// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
/// Inputs (records existentes) y params (valores escalares) se
/// mapean desde los campos del form.
Morphism {
/// Nombre del morphism declarado en `nsmc.json` del manifest
/// nakui apuntado por el módulo.
name: String,
/// Mapeo `role → field_name`: por cada input declarado en
/// el `MorphismSpec.inputs`, indica qué field del form
/// contiene el UUID del record. El runtime parsea el value
/// como `Uuid` y lo pasa como input al `execute_and_log`.
///
/// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
/// morphism `vender` que toma roles `stock` y `caja`.
#[serde(default)]
inputs: BTreeMap<String, String>,
/// Lista de fields del form cuyos values van al `params`
/// JSON object pasado al morphism. Si está vacío, todos los
/// fields que no estén en `inputs` van a params.
#[serde(default)]
params: Vec<String>,
#[serde(default)]
next_view: Option<String>,
},
@@ -329,6 +363,7 @@ mod tests {
id: "customers".into(),
label: "Clientes".into(),
description: Some("Gestión de clientes".into()),
nakui_module_dir: None,
entities: vec![EntitySpec {
name: "customer".into(),
label: "Cliente".into(),
@@ -14,7 +14,7 @@ fn examples_dir() -> std::path::PathBuf {
}
#[test]
fn loads_all_six_demo_modules() {
fn loads_all_demo_modules() {
let dir = examples_dir();
let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
panic!("load failed for {}: {e}", dir.display());
@@ -27,10 +27,37 @@ fn loads_all_six_demo_modules() {
"inventory_movements",
"invoices",
"products",
"sales_engine",
"sales_orders",
"suppliers",
],
"expected 6 modules in alphabetical order"
"expected 7 modules in alphabetical order \
(sales_engine se sumó al wirear Action::Morphism)"
);
}
#[test]
fn sales_engine_declares_nakui_module_dir_and_morphism() {
// Sanity del módulo demo de morphism: nakui_module_dir set,
// y al menos una vista con Action::Morphism en su on_submit.
let mods = load_modules_from_dir(examples_dir()).unwrap();
let sales = mods
.iter()
.find(|m| m.id == "sales_engine")
.expect("sales_engine debe estar");
assert!(
sales.nakui_module_dir.is_some(),
"sales_engine debería declarar nakui_module_dir"
);
let has_morphism_view = sales.views.values().any(|v| match v {
nakui_ui_schema::View::Form(form) => {
matches!(form.on_submit, nakui_ui_schema::Action::Morphism { .. })
}
_ => false,
});
assert!(
has_morphism_view,
"sales_engine debería tener al menos una Form con Action::Morphism"
);
}