Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
15b73d4
docs: sequence echo contract hosting backlog
flyingrobots May 4, 2026
4915d40
docs: define echo contract hosting doctrine
flyingrobots May 4, 2026
06e79a7
docs: align contract backlog with existing echo substrate
flyingrobots May 4, 2026
7e9fc84
docs: inventory echo intent registry observation boundary
flyingrobots May 4, 2026
70bbcb3
docs: decide registry host boundary
flyingrobots May 4, 2026
776f687
test: prove Wesley toy contract bridge missing
flyingrobots May 4, 2026
6c391ee
docs: track relocated Wesley schema reconciliation
flyingrobots May 4, 2026
8cef2fe
feat: emit Wesley EINT observation helpers
flyingrobots May 4, 2026
dbb9019
test: compile Wesley toy consumer bridge
flyingrobots May 4, 2026
80c1207
fix: canonicalize Wesley generated vars helpers
flyingrobots May 4, 2026
340af96
docs: explain application contract hosting
flyingrobots May 4, 2026
0c5f580
test: extract Wesley toy counter fixture
flyingrobots May 4, 2026
51c047d
fix: harden Wesley generated helper output
flyingrobots May 4, 2026
4a05c20
docs: render Mermaid diagrams in VitePress
flyingrobots May 4, 2026
0879522
docs: define authenticated Wesley intent admission posture
flyingrobots May 4, 2026
1baa426
fix: namespace Wesley generated helper types
flyingrobots May 4, 2026
dced3e7
docs: address contract hosting review feedback
flyingrobots May 4, 2026
721e0ef
docs: update changelog for PR feedback
flyingrobots May 4, 2026
0e3aa81
test: cover no-std Wesley helper output
flyingrobots May 4, 2026
2d6b3ea
docs: clarify generated query helper scope
flyingrobots May 4, 2026
e45463b
docs: update changelog for follow-up review
flyingrobots May 4, 2026
c02a906
Merge branch 'main' of github.com:flyingrobots/echo into backlog/echo…
flyingrobots May 4, 2026
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
309 changes: 307 additions & 2 deletions crates/echo-wesley-gen/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use anyhow::Result;
use clap::Parser;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use std::collections::BTreeMap;
use std::io::{self, Read};

/// Create an identifier safely, falling back to a raw identifier for Rust keywords.
Expand Down Expand Up @@ -58,6 +59,8 @@ fn main() -> Result<()> {
}

fn generate_rust(ir: &WesleyIR, args: &Args) -> Result<String> {
validate_generated_item_names(ir)?;

let mut tokens = quote! {
// Generated by echo-wesley-gen. Do not edit.
};
Expand Down Expand Up @@ -154,6 +157,30 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result<String> {
use echo_registry_api::{ArgDef, EnumDef, ObjectDef, OpDef, OpKind, RegistryInfo, RegistryProvider};
});

if ir.ops.iter().any(|op| op.kind == OpKind::Query) {
tokens.extend(quote! {
use echo_wasm_abi::kernel_port::{
ObservationAt, ObservationCoordinate, ObservationFrame, ObservationProjection,
ObservationRequest, WorldlineId,
};
});
}

if ir.ops.iter().any(|op| op.kind == OpKind::Mutation) {
tokens.extend(quote! {
use echo_wasm_abi::pack_intent_v1;

/// Error produced while building a generated EINT intent.
#[derive(Debug)]
pub enum GeneratedIntentError {
Comment thread
flyingrobots marked this conversation as resolved.
Outdated
/// Operation vars could not be encoded canonically.
EncodeVars(echo_wasm_abi::CanonError),
/// Encoded vars could not be packed into an EINT envelope.
PackEnvelope(echo_wasm_abi::EnvelopeError),
}
});
}

let mut enum_defs: Vec<_> = ir
.types
.iter()
Expand Down Expand Up @@ -241,6 +268,84 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result<String> {
});
}

for op in &ops_sorted {
let const_name = op_const_ident(&op.name, op.op_id);
let helper_name = format_ident!("{}", to_snake_case(&op.name));
let vars_name = format_ident!("{}Vars", to_pascal_case(&op.name));
Comment thread
flyingrobots marked this conversation as resolved.
let vars_fields = op.args.iter().map(|a| {
let field_name = safe_ident(&a.name);
let base_ty = map_type(&a.type_name, args);
let list_ty: TokenStream = if a.list {
quote! { Vec<#base_ty> }
} else {
quote! { #base_ty }
};

if a.required {
quote! { pub #field_name: #list_ty }
} else {
quote! { pub #field_name: Option<#list_ty> }
}
});
let encode_fn_name = format_ident!("encode_{}_vars", helper_name);
tokens.extend(quote! {
/// Canonical vars payload for this generated operation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct #vars_name {
#(#vars_fields),*
}

/// Encode this operation's vars using Echo canonical CBOR.
pub fn #encode_fn_name(vars: &#vars_name) -> Result<Vec<u8>, echo_wasm_abi::CanonError> {
echo_wasm_abi::encode_cbor(vars)
}
});
match op.kind {
OpKind::Mutation => {
let fn_name = format_ident!("pack_{}_intent", helper_name);
let raw_fn_name = format_ident!("pack_{}_intent_raw_vars", helper_name);
tokens.extend(quote! {
/// Encode this mutation's vars and pack them into an EINT v1 intent.
pub fn #fn_name(vars: &#vars_name) -> Result<Vec<u8>, GeneratedIntentError> {
let vars_bytes = #encode_fn_name(vars).map_err(GeneratedIntentError::EncodeVars)?;
pack_intent_v1(#const_name, &vars_bytes).map_err(GeneratedIntentError::PackEnvelope)
}

/// Pack already-canonical vars bytes for this generated mutation into EINT v1.
pub fn #raw_fn_name(vars: &[u8]) -> Result<Vec<u8>, echo_wasm_abi::EnvelopeError> {
pack_intent_v1(#const_name, vars)
}
});
}
OpKind::Query => {
let fn_name = format_ident!("{}_observation_request", helper_name);
let raw_fn_name = format_ident!("{}_observation_request_raw_vars", helper_name);
tokens.extend(quote! {
/// Encode this query's vars and build a frontier query-view observation request.
pub fn #fn_name(worldline_id: WorldlineId, vars: &#vars_name) -> Result<ObservationRequest, echo_wasm_abi::CanonError> {
let vars_bytes = #encode_fn_name(vars)?;
Ok(#raw_fn_name(worldline_id, &vars_bytes))
}

/// Build a frontier query-view request from already-canonical vars bytes.
pub fn #raw_fn_name(worldline_id: WorldlineId, vars: &[u8]) -> ObservationRequest {
ObservationRequest {
coordinate: ObservationCoordinate {
worldline_id,
at: ObservationAt::Frontier,
},
frame: ObservationFrame::QueryView,
projection: ObservationProjection::Query {
query_id: #const_name,
vars_bytes: Vec::from(vars),
},
}
}
});
}
}
}

// OPS table (sorted by op_id).
let ops_entries = ops_sorted.iter().map(|op| {
let kind = match op.kind {
Expand Down Expand Up @@ -307,6 +412,10 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result<String> {
}

fn op_const_ident(name: &str, op_id: u32) -> proc_macro2::Ident {
format_ident!("{}", op_const_name(name, op_id))
}

fn op_const_name(name: &str, op_id: u32) -> String {
let mut out = String::new();
for (i, c) in name.chars().enumerate() {
if c.is_alphanumeric() {
Expand All @@ -319,9 +428,56 @@ fn op_const_ident(name: &str, op_id: u32) -> proc_macro2::Ident {
}
}
if out.is_empty() {
return format_ident!("OP_ID_{}", op_id);
return format!("OP_ID_{op_id}");
}
format!("OP_{out}")
}

fn to_pascal_case(name: &str) -> String {
let mut out = String::new();
let mut capitalize_next = true;
for c in name.chars() {
if c.is_alphanumeric() {
if capitalize_next {
out.push(c.to_ascii_uppercase());
capitalize_next = false;
} else {
out.push(c);
}
} else {
capitalize_next = true;
}
}
if out.is_empty() {
"Op".to_string()
} else {
out
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn to_snake_case(name: &str) -> String {
let mut out = String::new();
let mut previous_was_separator = true;
for (index, c) in name.chars().enumerate() {
if c.is_alphanumeric() {
if c.is_uppercase() && index > 0 && !previous_was_separator {
out.push('_');
}
out.push(c.to_ascii_lowercase());
previous_was_separator = false;
} else if !previous_was_separator {
out.push('_');
previous_was_separator = true;
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() {
"op".to_string()
} else {
out
}
format_ident!("OP_{}", out)
}

fn validate_version(ir: &WesleyIR) -> Result<()> {
Expand All @@ -337,6 +493,155 @@ fn validate_version(ir: &WesleyIR) -> Result<()> {
}
}

fn validate_generated_item_names(ir: &WesleyIR) -> Result<()> {
let mut items = BTreeMap::new();

record_generated_item(
&mut items,
"SCHEMA_SHA256",
"generated schema hash constant",
)?;
record_generated_item(&mut items, "CODEC_ID", "generated codec id constant")?;
record_generated_item(
&mut items,
"REGISTRY_VERSION",
"generated registry version constant",
)?;

for type_def in &ir.types {
match type_def.kind {
TypeKind::Enum => {
record_generated_item(
&mut items,
type_def.name.as_str(),
format!("enum type `{}`", type_def.name),
)?;
record_generated_item(
&mut items,
format!("ENUM_{}_VALUES", type_def.name.to_ascii_uppercase()),
format!("enum `{}` values constant", type_def.name),
)?;
}
TypeKind::Object | TypeKind::InputObject => {
record_generated_item(
&mut items,
type_def.name.as_str(),
format!("object type `{}`", type_def.name),
)?;
if type_def.kind == TypeKind::Object {
record_generated_item(
&mut items,
format!("OBJ_{}_FIELDS", type_def.name.to_ascii_uppercase()),
format!("object `{}` fields constant", type_def.name),
)?;
}
}
TypeKind::Scalar | TypeKind::Interface | TypeKind::Union => {}
}
}

if !ir.ops.is_empty() {
for (name, source) in [
("ENUMS", "generated enum registry"),
("OBJECTS", "generated object registry"),
("OPS", "generated operation registry"),
("op_by_id", "generated operation lookup function"),
("op_by_name", "generated operation lookup function"),
("GeneratedRegistry", "generated registry provider type"),
("REGISTRY", "generated registry provider value"),
] {
record_generated_item(&mut items, name, source)?;
}
}

if ir.ops.iter().any(|op| op.kind == OpKind::Mutation) {
record_generated_item(
&mut items,
"GeneratedIntentError",
"generated intent helper error",
)?;
}

for op in &ir.ops {
let kind = op_kind_name(&op.kind);
let const_name = op_const_name(&op.name, op.op_id);
let helper_name = to_snake_case(&op.name);

record_generated_item(
&mut items,
const_name.as_str(),
format!("{kind} operation `{}` id constant", op.name),
)?;
record_generated_item(
&mut items,
format!("{const_name}_ARGS"),
format!("{kind} operation `{}` args constant", op.name),
)?;
record_generated_item(
&mut items,
format!("{}Vars", to_pascal_case(&op.name)),
format!("{kind} operation `{}` vars type", op.name),
)?;
record_generated_item(
&mut items,
format!("encode_{helper_name}_vars"),
format!("{kind} operation `{}` vars encoder", op.name),
)?;

match op.kind {
OpKind::Mutation => {
record_generated_item(
&mut items,
format!("pack_{helper_name}_intent"),
format!("mutation operation `{}` EINT helper", op.name),
)?;
record_generated_item(
&mut items,
format!("pack_{helper_name}_intent_raw_vars"),
format!("mutation operation `{}` raw EINT helper", op.name),
)?;
}
OpKind::Query => {
record_generated_item(
&mut items,
format!("{helper_name}_observation_request"),
format!("query operation `{}` observation helper", op.name),
)?;
record_generated_item(
&mut items,
format!("{helper_name}_observation_request_raw_vars"),
format!("query operation `{}` raw observation helper", op.name),
)?;
}
}
}

Ok(())
}

fn record_generated_item(
items: &mut BTreeMap<String, String>,
name: impl Into<String>,
source: impl Into<String>,
) -> Result<()> {
let name = name.into();
let source = source.into();
if let Some(existing_source) = items.get(&name) {
anyhow::bail!(
"generated Rust item name collision for `{name}`: {existing_source} conflicts with {source}"
);
}
items.insert(name, source);
Ok(())
}

fn op_kind_name(kind: &OpKind) -> &'static str {
match kind {
OpKind::Query => "query",
OpKind::Mutation => "mutation",
}
}

/// Map a GraphQL base type name to a Rust type used in generated DTOs.
///
/// GraphQL `Float` intentionally maps to `f32` (not `f64`) so generated types
Expand Down
29 changes: 29 additions & 0 deletions crates/echo-wesley-gen/tests/fixtures/toy-counter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!-- SPDX-License-Identifier: Apache-2.0 OR LicenseRef-MIND-UCAL-1.0 -->
<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->

# Toy Counter Fixture

This is the smallest shared Echo/Wesley contract-hosting fixture.

It exists to prove:

- generated operation ids and registry metadata;
- typed operation variable generation;
- canonical operation variable encoding;
- EINT v1 packing;
- observation request generation;
- generated output compilation in a standalone consumer crate;
- future installed-host contract smoke tests.

It is not:

- a `jedit` fixture;
- a dynamic loading fixture;
- a GraphQL execution fixture;
- a host-side registry validation fixture;
- a text-editing or product-domain fixture.

Tests should consume `echo-ir-v1.json` through `include_str!(...)`. Do not copy
the toy counter IR into new tests. If the fixture needs to change, update this
single source and make the contract boundary change explicit in the test that
requires it.
Loading
Loading