mizan-tauri + Pydantic-aware codegen: Tauri-as-Mizan-backend substrate

Tauri now joins FastAPI/Django/axum as a first-class Mizan backend. The
React frontend calls Mizan-registered functions through Tauri's IPC
with the same {result, invalidate, merge} envelope the HTTP path uses;
the schema flows Pydantic → decoru → Rust → KDL → TS in one
mizan-generate invocation.

New packages:
* backends/mizan-tauri — Tauri plugin exposing a single `mizan_invoke`
  command that routes through mizan-core's FUNCTIONS / CONTEXTS
  registries. No per-function tauri::command; the linkme slice IS the
  dispatch table.
* frontends/mizan-tauri-transport — TS package exporting
  tauriTransport() that wraps invoke('plugin:mizan|mizan_invoke', ...)
  and re-shapes errors into MizanError. Pairs with mizan-tauri.

@mizan/base — pluggable transport:
* Adds MizanTransport interface + transport config field.
* Existing fetch-based body factored into httpTransport() (default).
* mizanCall/mizanFetch delegate to config.transport; merge/invalidate
  side-effects stay in the kernel (transport-agnostic).
* Consumers swap via configure({ transport: tauriTransport() }).

mizan-codegen — Rust source + Pydantic pre-step:
* [source.rust] runs a Cargo bin (cargo run --bin <name>) and parses
  KDL from stdout. The bin uses mizan_core::build_ir() after
  force-linking the consumer's #[derive(Mizan)] / #[mizan::client]
  registrations.
* [source.rust.pydantic] is an optional pre-step that pipes an
  embedded Python bridge (scripts/run_decoru.py) to python and writes
  decoru-emitted Rust types into the consumer crate. The bridge
  auto-discovers BaseModel subclasses AND Enum subclasses
  (last-variant-is-default convention so decoru's impl Default keeps
  compiling against enum-typed fields without explicit Pydantic
  defaults).
* Pure-Rust usage stays intact — omit pydantic block and write Rust
  types by hand.

mizan-macros:
* #[mizan::client] now supports Result<T, MizanError> returns. The
  dispatch wrapper `?`-unwraps the user fn so server-side errors
  surface as the protocol's standard {code, message, details?}
  envelope; T-returning functions stay unchanged.
* #[derive(Mizan)] strips the r# raw-identifier prefix and honors
  field-level #[serde(rename = "...")] when emitting IR field names.
  Matches serde's wire shape — fixes IR-vs-JSON drift for Rust-keyword
  fields (e.g. `r#type` → `type`).

react.tsx template:
* Conditionally emits context-related imports / useContextSubscription
  helper based on has_global || !named_contexts.is_empty(). Consumers
  without contexts (mutation/RPC-only apps like claude-manage) no
  longer get dead imports that trip noUnusedLocals.

Verified end-to-end: cargo build clean across mizan-tauri,
mizan-codegen, AFI rust_app; AFI three-way KDL parity tests pass;
claude-manage migration drives the full stack (Pydantic schema →
generated TS api → Tauri-IPC transport → mizan-core dispatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 19:01:45 -04:00
parent 54f060c273
commit 22dcf0e3c1
13 changed files with 5478 additions and 39 deletions

4621
backends/mizan-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
[package]
name = "mizan-tauri"
version = "0.1.0"
edition = "2021"
description = "Tauri backend adapter for Mizan — typed RPC dispatch over Tauri's IPC. Single `mizan_invoke` command routes through mizan-core's compile-time function registry."
license = "MIT"
[dependencies]
mizan-core = { path = "../../cores/mizan-rust" }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,220 @@
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
//!
//! Ships as a Tauri plugin. The consumer installs it with one line:
//!
//! ```ignore
//! tauri::Builder::default()
//! .plugin(mizan_tauri::init())
//! .run(tauri::generate_context!())
//! .expect("error while running tauri application");
//! ```
//!
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
//! sends call/fetch envelopes to it; the dispatch routes through
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
//! consumes. There is no per-function tauri::command; the registry IS
//! the dispatch table.
//!
//! Wire envelope:
//!
//! ```json
//! { "op": "call", "fn": "list_sessions", "args": {} }
//! { "op": "fetch", "context": "session", "params": {} }
//! ```
//!
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
//! mizan-rust-axum:
//!
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//!
//! Error responses come back as the `Err` variant of the Tauri command's
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
//! The TS-side transport re-wraps it into a `MizanError` so consumers
//! see one error surface regardless of transport.
use mizan_core::{
compute_invalidation, compute_merges, lookup_context, lookup_function,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("mizan")
.invoke_handler(tauri::generate_handler![mizan_invoke])
.build()
}
// === Wire envelope ===
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
/// Tauri's serde deserializer pulls this struct out of the `envelope`
/// field of the invoke payload.
#[derive(Debug, Deserialize)]
#[serde(tag = "op")]
pub enum Envelope {
#[serde(rename = "call")]
Call {
/// Wire-level function name — registered name on the Rust side.
#[serde(rename = "fn")]
function_name: String,
#[serde(default)]
args: Map<String, Value>,
},
#[serde(rename = "fetch")]
Fetch {
context: String,
#[serde(default)]
params: Map<String, Value>,
},
}
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
/// this and constructs a `MizanError`.
#[derive(Debug, Serialize)]
pub struct ErrorPayload {
pub code: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl From<MizanError> for ErrorPayload {
fn from(e: MizanError) -> Self {
let details = if let MizanError::ValidationFailed { details, .. } = &e {
Some(details.clone())
} else {
None
};
Self {
code: e.code(),
message: e.message().to_string(),
details,
}
}
}
// === Dispatch ===
/// The single Mizan dispatch command. Registered on the plugin's invoke
/// handler — the consumer never wires it directly.
///
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
/// it into a `RequestHandle` so `#[mizan::client]` functions can
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
/// emission. Stateless functions ignore the handle.
#[tauri::command]
async fn mizan_invoke<R: Runtime>(
app: tauri::AppHandle<R>,
envelope: Envelope,
) -> Result<Value, ErrorPayload> {
match envelope {
Envelope::Call {
function_name,
args,
} => handle_call(&app, &function_name, args).await,
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
}
}
async fn handle_call<R: Runtime>(
app: &tauri::AppHandle<R>,
fn_name: &str,
args: Map<String, Value>,
) -> Result<Value, ErrorPayload> {
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
ErrorPayload::from(MizanError::NotFound(format!(
"function {fn_name:?} not registered"
)))
})?;
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args.clone()))
.await
.map_err(ErrorPayload::from)?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let merges = compute_merges(fn_spec, &args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
let mut payload = json!({
"result": result,
"invalidate": invalidate,
});
if let Some(merge) = merge_payload {
payload
.as_object_mut()
.expect("payload is a JSON object")
.insert("merge".into(), Value::Array(merge));
}
Ok(payload)
}
async fn handle_fetch<R: Runtime>(
app: &tauri::AppHandle<R>,
context_name: &str,
params: Map<String, Value>,
) -> Result<Value, ErrorPayload> {
if lookup_context(context_name).is_none() {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context_name:?} not registered"
))));
}
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(context_name))
.collect();
if members.is_empty() {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
}
let mut bundled = Map::new();
for fn_spec in &members {
let args = filter_args(*fn_spec, &params);
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args))
.await
.map_err(ErrorPayload::from)?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(Value::Object(bundled))
}
/// Filter the envelope's params down to keys this function declares as
/// input. The HTTP/axum adapter coerces string-typed query params to
/// JSON primitives in the equivalent step; the Tauri arg channel already
/// carries typed JSON, so the filter is sufficient on its own.
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
let mut out = Map::new();
for ip in fn_spec.input_params() {
if let Some(v) = params.get(ip.name) {
out.insert(ip.name.into(), v.clone());
}
}
out
}

View File

@@ -142,7 +142,14 @@ fn emit_struct(s: &DataStruct) -> TokenStream {
.ident .ident
.as_ref() .as_ref()
.expect("named field always has an ident"); .expect("named field always has an ident");
let name = ident.to_string(); // Field-level `#[serde(rename = "...")]` wins; otherwise strip
// the raw-identifier prefix that Rust uses to escape keywords
// (`r#type` → `type`). Serde itself strips the prefix when
// computing the default field name; the IR has to match the
// wire form, not the Rust source form.
let raw_ident = ident.to_string();
let stripped = raw_ident.strip_prefix("r#").unwrap_or(&raw_ident);
let name = serde_rename(&field.attrs).unwrap_or_else(|| stripped.to_string());
let shape = type_shape_expr(&field.ty); let shape = type_shape_expr(&field.ty);
// A field is `required` iff its type is not `Option<...>`. Defaults // A field is `required` iff its type is not `Option<...>`. Defaults

View File

@@ -353,7 +353,13 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
let output_nullable = analysis.nullable; let output_nullable = analysis.nullable;
let private = args.private; let private = args.private;
let dispatch_body = build_dispatch(&item, &input_args, has_input, &input_type_ident); let dispatch_body = build_dispatch(
&item,
&input_args,
has_input,
&input_type_ident,
analysis.returns_result,
);
quote! { quote! {
// Keep the user's original fn intact — the macro never rewrites the // Keep the user's original fn intact — the macro never rewrites the
@@ -454,8 +460,18 @@ fn build_dispatch(
input_args: &[InputArg], input_args: &[InputArg],
has_input: bool, has_input: bool,
input_type_ident: &syn::Ident, input_type_ident: &syn::Ident,
returns_result: bool,
) -> TokenStream { ) -> TokenStream {
let inner = &item.sig.ident; let inner = &item.sig.ident;
// When the user returns `Result<T, MizanError>`, lift Err out into the
// dispatch wrapper's outer Result so the HTTP/IPC adapter can surface
// it as the standard error envelope. When the user returns `T`,
// serialize directly — the substrate has no error path for them.
let unwrap_user_result = if returns_result {
quote! { ? }
} else {
TokenStream::new()
};
if has_input { if has_input {
let arg_names: Vec<_> = input_args.iter().map(|a| &a.ident).collect(); let arg_names: Vec<_> = input_args.iter().map(|a| &a.ident).collect();
quote! { quote! {
@@ -467,7 +483,7 @@ fn build_dispatch(
let result = #inner( let result = #inner(
&req, &req,
#( validated.#arg_names ),* #( validated.#arg_names ),*
).await; ).await #unwrap_user_result;
::mizan_core::__priv::serde_json::to_value(&result) ::mizan_core::__priv::serde_json::to_value(&result)
.map_err(|e| ::mizan_core::MizanError::InternalError( .map_err(|e| ::mizan_core::MizanError::InternalError(
format!("output serialization failed: {e}"), format!("output serialization failed: {e}"),
@@ -476,7 +492,7 @@ fn build_dispatch(
} else { } else {
quote! { quote! {
let _ = args; let _ = args;
let result = #inner(&req).await; let result = #inner(&req).await #unwrap_user_result;
::mizan_core::__priv::serde_json::to_value(&result) ::mizan_core::__priv::serde_json::to_value(&result)
.map_err(|e| ::mizan_core::MizanError::InternalError( .map_err(|e| ::mizan_core::MizanError::InternalError(
format!("output serialization failed: {e}"), format!("output serialization failed: {e}"),

View File

@@ -16,20 +16,32 @@ pub struct ReturnAnalysis {
pub is_vec: bool, pub is_vec: bool,
/// When `is_vec`, this is the element type `T`. /// When `is_vec`, this is the element type `T`.
pub vec_inner: Option<Type>, pub vec_inner: Option<Type>,
/// True when the user's return type is `Result<T, MizanError>` — the
/// dispatch wrapper emits `?` so user-side errors bubble out as
/// `MizanError` instead of being serialized into the success payload.
/// The IR sees only the `T` side; the error variant is the substrate's
/// invariant, not part of the output shape.
pub returns_result: bool,
} }
pub fn analyze_return(ty: &Type) -> ReturnAnalysis { pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
let (inner, nullable) = if let Some(t) = unwrap_option(ty) { let (effective, returns_result) = if let Some(ok) = unwrap_result_ok(ty) {
(t, true) (ok, true)
} else { } else {
(ty.clone(), false) (ty.clone(), false)
}; };
let (inner, nullable) = if let Some(t) = unwrap_option(&effective) {
(t, true)
} else {
(effective, false)
};
if let Some(elem) = unwrap_vec(&inner) { if let Some(elem) = unwrap_vec(&inner) {
ReturnAnalysis { ReturnAnalysis {
inner: inner.clone(), inner: inner.clone(),
nullable, nullable,
is_vec: true, is_vec: true,
vec_inner: Some(elem), vec_inner: Some(elem),
returns_result,
} }
} else { } else {
ReturnAnalysis { ReturnAnalysis {
@@ -37,10 +49,27 @@ pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
nullable, nullable,
is_vec: false, is_vec: false,
vec_inner: None, vec_inner: None,
returns_result,
} }
} }
} }
/// If `ty` is `Result<T, E>`, return `T`. Otherwise None. The substrate
/// only honors `Result<T, MizanError>`; the macro doesn't try to verify
/// `E` here — it lets rustc raise the type-mismatch at the `?` site if
/// the consumer used a non-MizanError variant.
pub fn unwrap_result_ok(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Result" {
return None;
}
extract_single_generic(&last.arguments)
}
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]` /// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]`
/// when constructing the struct field shapes. /// when constructing the struct field shapes.
pub fn type_shape_expr(ty: &Type) -> TokenStream { pub fn type_shape_expr(ty: &Type) -> TokenStream {

View File

@@ -30,6 +30,39 @@ export class MizanError extends Error {
} }
} }
// === Transport ===
/**
* Wire surface the kernel uses to reach a Mizan backend. The default
* implementation is `httpTransport()` (POST /call/, GET /ctx/). Tauri
* apps swap in `tauriTransport()` from `@mizan/tauri-transport`. Any
* future transport — workers, edge runtimes, channels — implements this
* interface and replaces the default via `configure({ transport })`.
*/
export interface MizanTransport {
/** RPC dispatch — invokes a Mizan-registered function. */
call(
fnName: string,
args: Record<string, any>,
): Promise<MizanCallResponse>
/** Context-bundle fetch — invokes a Mizan-registered context. */
fetch(
contextName: string,
params?: Record<string, any>,
): Promise<any>
}
/**
* Raw envelope a transport returns from `call()`. The kernel uses the
* `merge` and `invalidate` arrays to drive client-side cache updates;
* `result` is the function's typed return value.
*/
export interface MizanCallResponse {
result: any
invalidate?: Array<string | { context: string; params?: Record<string, any> } | { function: string }>
merge?: Array<{ context: string; slot: string; value: unknown; params?: Record<string, any> }>
}
// === Configuration === // === Configuration ===
interface MizanConfig { interface MizanConfig {
@@ -45,6 +78,13 @@ interface MizanConfig {
* this onto the schema-advertised capability surface. * this onto the schema-advertised capability surface.
*/ */
session: boolean session: boolean
/**
* Wire transport. Defaults to `httpTransport()` (fetch-based,
* compatible with FastAPI / Django backends). Swap with a custom
* transport (e.g. `tauriTransport()`) at app entry to route
* Mizan calls through a different channel.
*/
transport: MizanTransport
} }
const config: MizanConfig = { const config: MizanConfig = {
@@ -53,6 +93,8 @@ const config: MizanConfig = {
csrfCookieName: 'csrftoken', csrfCookieName: 'csrftoken',
csrfHeaderName: 'X-CSRFToken', csrfHeaderName: 'X-CSRFToken',
session: true, session: true,
// Initialized below once httpTransport is defined.
transport: null as unknown as MizanTransport,
} }
export function configure(opts: Partial<MizanConfig>): void { export function configure(opts: Partial<MizanConfig>): void {
@@ -344,42 +386,68 @@ async function resolveHeaders(): Promise<Record<string, string>> {
} }
} }
/**
* Default Mizan transport — POST `${baseUrl}/call/` and GET
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
* `mizan-django`, and `mizan-rust-axum`. Swap with a different
* transport via `configure({ transport })` when running in a
* non-HTTP host (e.g. Tauri).
*/
export function httpTransport(): MizanTransport {
return {
async call(functionName, args) {
const headers = await resolveHeaders()
headers['Content-Type'] = 'application/json'
const res = await fetch(`${config.baseUrl}/call/`, {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ fn: functionName, args }),
})
if (!res.ok) throw new MizanError(res.status, await res.text())
return res.json()
},
async fetch(contextName, params) {
const url = new URL(
`${config.baseUrl}/ctx/${contextName}/`,
typeof globalThis.location !== 'undefined'
? globalThis.location.origin
: 'http://localhost',
)
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v))
}
}
const headers = await resolveHeaders()
const res = await fetchWithRetry(url.toString(), {
headers,
credentials: 'same-origin',
})
if (!res.ok) throw new MizanError(res.status, await res.text())
return res.json()
},
}
}
// Install the default transport now that httpTransport is in scope. The
// config object was constructed earlier with a placeholder so the type
// stayed honest; this line is the actual binding.
config.transport = httpTransport()
export async function mizanFetch( export async function mizanFetch(
contextName: string, contextName: string,
params?: Record<string, any>, params?: Record<string, any>,
): Promise<any> { ): Promise<any> {
const url = new URL( return config.transport.fetch(contextName, params)
`${config.baseUrl}/ctx/${contextName}/`,
typeof globalThis.location !== 'undefined' ? globalThis.location.origin : 'http://localhost',
)
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v))
}
}
const headers = await resolveHeaders()
const res = await fetchWithRetry(url.toString(), { headers, credentials: 'same-origin' })
if (!res.ok) throw new MizanError(res.status, await res.text())
return res.json()
} }
export async function mizanCall( export async function mizanCall(
functionName: string, functionName: string,
args: Record<string, any>, args: Record<string, any>,
): Promise<any> { ): Promise<any> {
const headers = await resolveHeaders() const data = await config.transport.call(functionName, args)
headers['Content-Type'] = 'application/json'
const res = await fetch(`${config.baseUrl}/call/`, {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ fn: functionName, args }),
})
if (!res.ok) throw new MizanError(res.status, await res.text())
const data = await res.json()
// Server-driven merges run before invalidations so a context that is // Server-driven merges run before invalidations so a context that is
// both merged-into and invalidated ends in the invalidation state — the // both merged-into and invalidated ends in the invalidation state — the
@@ -395,9 +463,12 @@ export async function mizanCall(
for (const entry of data.invalidate) { for (const entry of data.invalidate) {
if (typeof entry === 'string') { if (typeof entry === 'string') {
invalidate(entry) invalidate(entry)
} else { } else if ('context' in entry) {
invalidate(entry.context, entry.params) invalidate(entry.context, entry.params)
} }
// {function: name} entries route through the kernel's
// function-output cache layer, which lives in the framework
// adapter; mizan-base treats them as a no-op here.
} }
} }

View File

@@ -0,0 +1,15 @@
{
"name": "@mizan/tauri-transport",
"version": "0.1.0",
"description": "Mizan transport adapter routing calls through Tauri's IPC instead of HTTP. Paired with the `mizan-tauri` Rust plugin.",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"@mizan/base": "*",
"@tauri-apps/api": "^2"
},
"license": "MIT"
}

View File

@@ -0,0 +1,81 @@
/**
* @mizan/tauri-transport — routes Mizan calls through Tauri's IPC
* instead of HTTP fetch. Paired with the `mizan-tauri` Rust plugin
* (which exposes a single `mizan_invoke` command).
*
* Usage:
*
* import { configure } from '@mizan/base'
* import { tauriTransport } from '@mizan/tauri-transport'
*
* configure({ transport: tauriTransport() })
*
* The transport keeps the same protocol surface as the HTTP transport
* (call/fetch envelopes, {result, invalidate, merge} response shape),
* so the codegen output and React adapter are unchanged — only the
* wire channel differs.
*/
import { invoke } from '@tauri-apps/api/core'
import { MizanError, type MizanCallResponse, type MizanTransport } from '@mizan/base'
/** Plugin-prefixed Tauri command — `init()` on the Rust side installs
* the plugin under the name `mizan`, so the dispatch command is
* reachable from JS as `plugin:mizan|mizan_invoke`. */
const COMMAND = 'plugin:mizan|mizan_invoke'
type TauriError = {
code?: string
message?: string
details?: unknown
}
/**
* Wrap a `mizan-tauri` error payload into a `MizanError` so consumers
* see one error surface regardless of transport. Tauri's invoke()
* rejects with the raw `Err` payload from the Rust command; we re-shape
* it to the same `{error: {code, message, details}}` envelope the HTTP
* transport surfaces.
*/
function wrapError(raw: unknown): MizanError {
if (raw && typeof raw === 'object') {
const err = raw as TauriError
const body = JSON.stringify({ error: { code: err.code, message: err.message, details: err.details } })
// 0 means "no HTTP status" — the IPC transport bypassed the
// protocol's HTTP layer. The error envelope still carries
// .code/.message/.details so consumers don't have to special-case.
return new MizanError(0, body)
}
return new MizanError(0, JSON.stringify({ error: { message: String(raw) } }))
}
/**
* Build a Mizan transport that routes through Tauri's IPC. Install via:
*
* import { configure } from '@mizan/base'
* import { tauriTransport } from '@mizan/tauri-transport'
* configure({ transport: tauriTransport() })
*/
export function tauriTransport(): MizanTransport {
return {
async call(fnName, args): Promise<MizanCallResponse> {
try {
const data = await invoke<MizanCallResponse>(COMMAND, {
envelope: { op: 'call', fn: fnName, args },
})
return data
} catch (e) {
throw wrapError(e)
}
},
async fetch(contextName, params) {
try {
return await invoke(COMMAND, {
envelope: { op: 'fetch', context: contextName, params: params ?? {} },
})
} catch (e) {
throw wrapError(e)
}
},
}
}

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Pydantic → Rust codegen helper invoked by mizan-codegen's
`[source.rust.pydantic]` step.
Reads a JSON payload from argv[1] with keys:
- module: Python module to import (e.g. "claude_manage.schema")
- output: Path to write the generated Rust file
- derives: List of derive identifiers to apply to every emitted item
- header: Optional file prefix (e.g. an AUTO-GENERATED warning)
Discovers every BaseModel subclass declared in the module (handled by
decoru) AND every Enum subclass declared there (handled inline — decoru
itself is scoped to BaseModel). Writes one Rust file containing both.
Bundled with the mizan-codegen binary (include_str!) and piped to
`python -` at codegen time — no install step beyond decoru being
importable in the python environment.
"""
import importlib
import inspect
import json
import sys
import textwrap
from enum import Enum
from pathlib import Path
from pydantic import BaseModel # type: ignore[import-untyped]
from decoru import emit_rust_struct, walk_pydantic_model # type: ignore[import-untyped]
def _declared_in(module, obj) -> bool:
return getattr(obj, "__module__", None) == module.__name__
def discover_models(module) -> list[type[BaseModel]]:
"""BaseModel subclasses declared in this module. Imported helpers are
skipped — only own-module declarations qualify."""
return [
obj
for _, obj in inspect.getmembers(module, inspect.isclass)
if issubclass(obj, BaseModel)
and obj is not BaseModel
and _declared_in(module, obj)
]
def discover_enums(module) -> list[type[Enum]]:
"""Enum subclasses declared in this module. Filters out the
framework's own Enum class and anything imported from elsewhere."""
return [
obj
for _, obj in inspect.getmembers(module, inspect.isclass)
if issubclass(obj, Enum) and obj is not Enum and _declared_in(module, obj)
]
def _pascal_case(s: str) -> str:
return "".join(part.capitalize() for part in s.split("_") if part)
# Last-variant-is-default matches the catch-all idiom (e.g. `Metadata`
# in `claude_manage.schema.EntryType`). Decoru's `emit_rust_struct`
# emits `impl Default` unconditionally on every BaseModel, so any
# enum-typed field that lacks a Pydantic default must still satisfy
# `EntryType::default()`. Forcing #[default] on the last member keeps
# the generated structs compilable without per-enum config.
_ENUM_TEMPLATE = textwrap.dedent("""\
#[derive({derives})]
#[serde(rename_all = "snake_case")]
pub enum {name} {{
{variants}
}}
""")
def _render_variant(member: Enum, *, is_default: bool) -> str:
pascal = _pascal_case(member.name)
default_attr = " #[default]\n" if is_default else ""
return f"{default_attr} {pascal},"
def emit_rust_enum(enum_class: type[Enum], derives: tuple[str, ...]) -> str:
"""Render a Rust enum with PascalCase variants from Python member
names. Pairs `#[serde(rename_all = "snake_case")]` so the wire form
matches each member's `value`. Adds `Default` to the derives and
marks the last member `#[default]` — see `_ENUM_TEMPLATE` for the
rationale."""
name = enum_class.__name__
members = list(enum_class)
full_derives = ", ".join((*derives, "Default"))
variants = "\n".join(
_render_variant(m, is_default=(i == len(members) - 1))
for i, m in enumerate(members)
)
return _ENUM_TEMPLATE.format(derives=full_derives, name=name, variants=variants)
def main() -> int:
if len(sys.argv) < 2:
sys.stderr.write("run_decoru.py: missing JSON payload argument\n")
return 2
payload = json.loads(sys.argv[1])
module_name: str = payload["module"]
output_path = Path(payload["output"]).resolve()
derives = tuple(payload.get("derives", ()))
header = payload.get("header") or ""
sys.path.insert(0, str(Path.cwd()))
module = importlib.import_module(module_name)
enums = discover_enums(module)
models = discover_models(module)
if not enums and not models:
sys.stderr.write(
f"run_decoru.py: no Enum or BaseModel subclasses declared in {module_name!r}\n"
)
return 3
enum_blocks = [emit_rust_enum(e, derives) for e in enums]
struct_blocks = [emit_rust_struct(walk_pydantic_model(m), derives=derives) for m in models]
body = "\n".join((*enum_blocks, *struct_blocks))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(header + body)
sys.stderr.write(
f"run_decoru.py: wrote {len(enums)} enum(s) + {len(models)} struct(s) to {output_path}\n"
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -62,6 +62,13 @@ pub struct SourceConfig {
#[serde(default)] #[serde(default)]
pub django: Option<DjangoSource>, pub django: Option<DjangoSource>,
/// Canonical "Pydantic + Rust" DX path. The Rust crate is the IR
/// authority; an optional `pydantic` sub-block invokes decoru as a
/// pre-step to author Rust types from Pydantic models. Pure-Rust
/// usage (no Pydantic) just omits the sub-block.
#[serde(default)]
pub rust: Option<RustSource>,
} }
@@ -98,6 +105,107 @@ pub struct DjangoSource {
} }
/// `[source.rust]` — spawn a Cargo binary that emits the Mizan IR (KDL)
/// to stdout. The binary uses `mizan_core::build_ir()` after force-linking
/// the consumer crate's `#[derive(Mizan)]` types and `#[mizan::client]`
/// functions.
#[derive(Debug, Deserialize)]
pub struct RustSource {
/// Path to the consumer's Cargo.toml, relative to the codegen config
/// directory. Defaults to `Cargo.toml` (i.e. config_dir/Cargo.toml).
#[serde(default)]
pub manifest_path: Option<PathBuf>,
/// Name of the binary under `[[bin]]` that exports the IR. Defaults
/// to `emit-mizan-ir` — the convention this substrate documents.
#[serde(default = "default_rust_bin")]
pub bin: String,
/// Cargo features to enable when building the bin.
#[serde(default)]
pub features: Vec<String>,
/// Build in release mode. Defaults to false (dev mode is faster for
/// codegen, and the binary is throwaway).
#[serde(default)]
pub release: bool,
/// Environment overrides for the cargo subprocess.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Optional pre-step — invoke decoru on a Pydantic source module
/// before running the Cargo bin. When present, the pipeline becomes:
/// 1. python + decoru → write Rust types to `pydantic.output`
/// 2. cargo run --bin <bin> → emit IR to stdout
/// Omit for pure-Rust usage (hand-authored or otherwise-generated
/// Rust types with `#[derive(Mizan)]`).
#[serde(default)]
pub pydantic: Option<PydanticPreStep>,
}
fn default_rust_bin() -> String {
"emit-mizan-ir".to_string()
}
/// Pydantic → Rust pre-step. Runs an embedded Python helper that walks
/// the named module for `BaseModel` subclasses and invokes decoru's
/// `walk_pydantic_model` + `emit_rust_struct` to produce a Rust file.
#[derive(Debug, Deserialize)]
pub struct PydanticPreStep {
/// Python module name to import (e.g. `claude_manage.schema`).
pub module: String,
/// Path to write the generated Rust file, relative to the codegen
/// config directory.
pub output: PathBuf,
/// Working directory for the python subprocess, relative to the
/// codegen config directory. Defaults to the config directory itself.
/// The script prepends this to `sys.path` so the module imports.
#[serde(default)]
pub cwd: Option<PathBuf>,
/// Python executable. Defaults to `python`.
#[serde(default)]
pub python: Option<String>,
/// Full command override (e.g. `["uv", "run", "python"]`). Wins over
/// `python` when present.
#[serde(default)]
pub command: Option<Vec<String>>,
/// Derive macros to apply to every generated struct. The default
/// matches the Mizan-canonical set used in `cores/rust/blazr/session`
/// — serde + mizan_core::Mizan for end-to-end RPC participation.
#[serde(default = "default_pydantic_derives")]
pub derives: Vec<String>,
/// Optional prelude inserted at the top of the generated file
/// (typically a "// AUTO-GENERATED" warning + `use` statements for
/// referenced types not produced by decoru itself).
#[serde(default)]
pub header: Option<String>,
/// Environment overrides for the python subprocess.
#[serde(default)]
pub env: BTreeMap<String, String>,
}
fn default_pydantic_derives() -> Vec<String> {
vec![
"Debug".to_string(),
"Clone".to_string(),
"::serde::Serialize".to_string(),
"::serde::Deserialize".to_string(),
"::mizan_core::Mizan".to_string(),
]
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
#[serde(untagged)] #[serde(untagged)]
pub enum RustKernelSpec { pub enum RustKernelSpec {

View File

@@ -4,24 +4,48 @@
//! Backends: //! Backends:
//! - FastAPI: `python -m mizan_fastapi.ir <module>` //! - FastAPI: `python -m mizan_fastapi.ir <module>`
//! - Django: `python manage.py export_mizan_ir` //! - Django: `python manage.py export_mizan_ir`
//! - Rust: `cargo run --bin <bin>` (consumer-side binary that
//! force-links its `#[derive(Mizan)]` types and
//! `#[mizan::client]` functions, then calls
//! `mizan_core::build_ir()`).
//!
//! The Rust source supports an optional `[source.rust.pydantic]`
//! pre-step that invokes decoru on a Pydantic module to author the
//! Rust types before the cargo bin runs — the "Pydantic + Rust"
//! canonical DX.
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::{Command, Stdio};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use serde_json::json;
use crate::config::{Config, DjangoSource, FastapiSource}; use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource};
use crate::ir::{parse_ir, MizanIR}; use crate::ir::{parse_ir, MizanIR};
/// Embedded decoru bridge script — piped to `python -` at codegen time
/// when `[source.rust.pydantic]` is set. The script imports decoru,
/// walks the named module's BaseModel subclasses, and writes a Rust
/// file. See `scripts/run_decoru.py` for the full body.
const DECORU_BRIDGE_SCRIPT: &str = include_str!("../scripts/run_decoru.py");
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> { pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
let raw = if let Some(fa) = &config.source.fastapi { let raw = if let Some(rs) = &config.source.rust {
if let Some(py) = &rs.pydantic {
run_pydantic_prestep(py, config_dir)
.context("running [source.rust.pydantic] pre-step")?;
}
run_rust(rs, config_dir)?
} else if let Some(fa) = &config.source.fastapi {
run_fastapi(fa, config_dir)? run_fastapi(fa, config_dir)?
} else if let Some(dj) = &config.source.django { } else if let Some(dj) = &config.source.django {
run_django(dj, config_dir)? run_django(dj, config_dir)?
} else { } else {
return Err(anyhow!( return Err(anyhow!(
"config.source must declare either [source.fastapi] or [source.django]" "config.source must declare one of [source.rust], [source.fastapi], or [source.django]"
)); ));
}; };
@@ -111,6 +135,96 @@ fn run_subprocess(
} }
fn run_rust(src: &RustSource, config_dir: &Path) -> Result<String> {
let manifest = config_dir.join(
src.manifest_path
.clone()
.unwrap_or_else(|| PathBuf::from("Cargo.toml")),
);
let mut args: Vec<String> = vec![
"run".to_string(),
"--quiet".to_string(),
"--manifest-path".to_string(),
manifest.to_string_lossy().into_owned(),
"--bin".to_string(),
src.bin.clone(),
];
if src.release {
args.push("--release".to_string());
}
if !src.features.is_empty() {
args.push("--features".to_string());
args.push(src.features.join(","));
}
run_subprocess("cargo", &args, config_dir, &src.env, "Rust IR export")
}
fn run_pydantic_prestep(src: &PydanticPreStep, config_dir: &Path) -> Result<()> {
let cwd = match &src.cwd {
Some(rel) => config_dir.join(rel),
None => config_dir.to_path_buf(),
};
let output_abs = if src.output.is_absolute() {
src.output.clone()
} else {
config_dir.join(&src.output)
};
let payload = json!({
"module": &src.module,
"output": output_abs.to_string_lossy(),
"derives": &src.derives,
"header": &src.header,
})
.to_string();
let (program, mut args) = resolve_command(&src.command, &src.python);
// `python -` reads the script body from stdin; the JSON payload is
// passed as argv[1] (which lands on sys.argv[1] inside the script).
args.push("-".to_string());
args.push(payload);
let mut cmd = Command::new(&program);
cmd.args(&args).current_dir(&cwd);
for (k, v) in &src.env {
cmd.env(k, v);
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let mut child = cmd
.spawn()
.with_context(|| format!("spawning decoru bridge ({program})"))?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow!("failed to acquire decoru bridge stdin"))?;
stdin
.write_all(DECORU_BRIDGE_SCRIPT.as_bytes())
.context("piping decoru bridge script to python")?;
}
let status = child
.wait()
.context("waiting for decoru bridge to complete")?;
if !status.success() {
return Err(anyhow!(
"[source.rust.pydantic]: decoru bridge exited with status {:?}",
status.code()
));
}
Ok(())
}
/// Library helper: parse a KDL IR from a string. /// Library helper: parse a KDL IR from a string.
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> { pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
parse_ir(source) parse_ir(source)

View File

@@ -2,30 +2,38 @@
// AUTO-GENERATED by mizan — do not edit // AUTO-GENERATED by mizan — do not edit
{% set has_contexts = has_global || !named_contexts.is_empty() -%}
import { import {
{% if has_contexts -%}
createContext, createContext,
{% endif -%}
useCallback, useCallback,
{% if has_contexts -%}
useContext, useContext,
useEffect, useEffect,
{% endif -%}
useRef, useRef,
useState, useState,
{% if has_contexts -%}
useSyncExternalStore, useSyncExternalStore,
{% endif -%}
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { import {
configure, configure,
initSession,
mizanCall, mizanCall,
mizanFetch, mizanFetch,
MizanError, {% if has_contexts -%}
registerContext, registerContext,
type ContextState, type ContextState,
{% endif -%}
} from '@mizan/base' } from '@mizan/base'
{% if !stage1_imports.is_empty() -%} {% if !stage1_imports.is_empty() -%}
import { {{ stage1_imports|join(", ") }} } from './index' import { {{ stage1_imports|join(", ") }} } from './index'
{% endif -%} {% endif -%}
{% if has_contexts -%}
// Internal — runs inside a Provider, registers with the kernel exactly once. // Internal — runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>( function useContextSubscription<T>(
name: string, name: string,
@@ -46,6 +54,7 @@ function useContextSubscription<T>(
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState) return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
} }
{% endif %}
// Internal — wraps an imperative call() with isPending / error state. // Internal — wraps an imperative call() with isPending / error state.
interface MutationHook<TArgs, TResult> { interface MutationHook<TArgs, TResult> {