Skip to content

Instantly share code, notes, and snippets.

@darwin
Created February 23, 2026 23:28
Show Gist options
  • Select an option

  • Save darwin/2d36572dd1a6f2dec95aa6b4c5699ed4 to your computer and use it in GitHub Desktop.

Select an option

Save darwin/2d36572dd1a6f2dec95aa6b4c5699ed4 to your computer and use it in GitHub Desktop.
vcad-supex integration plan

vcad-supex — Integrating VCAD BRep kernel into SketchUp

Format: Plan for plan-runner skill execution. Each ## Phase <code-name> section is one plan-runner invocation. Phases with ### Verify sections ending in cargo build/test or uv run pytest are automatically verifiable. Phases requiring running SketchUp or Tauri dev are manual verification only.

Project Overview

Integration of VCAD BRep kernel into SketchUp via the existing supex extension. The agent writes parametric CAD code in Loon (a Lisp with algebraic data types, type inference and square-bracket syntax), the VCAD Rust sidecar evaluates the code to produce a VCAD IR Document, evaluates it into BRep geometry, exports the resulting mesh as COLLADA DAE, and SketchUp natively imports it as a component. A standalone Tauri viewer provides high-fidelity BRep preview — the agent can read viewer state (camera, selection, annotations) and use it as context for agentic coding over the user's Loon project.

Architecture

            AI Agent (Claude Code, MCP client)
                         |
                  [MCP Protocol (stdio)]
                         |
                supex Python MCP Driver
                /                    \
  [TCP JSON-RPC :9876]        [TCP JSON-RPC :9877]
         |                            |
  SketchUp Ruby Runtime       VCAD Rust Sidecar
  (bridge_server.rb)          (loon-lang + vcad-loon + vcad-eval + vcad-kernel)
         |                            |
  SketchUp Application         .cmp.oo files (source of truth)

Evaluation Pipeline

Loon code produces pure ADT data trees — no BRep objects are created during evaluation. The BRep kernel evaluates the resulting IR in a separate step:

.cmp.oo source
    | (loon-lang: parse + interpret)
Value::Adt tree (pure data — Cube, Union, Fillet, etc.)
    | (vcad-loon: value_to_document)
vcad_ir::Document (DAG of CsgOp nodes)
    | (vcad-eval: evaluate_document)
vcad_kernel::Solid (BRep geometry)
    | (Solid::to_mesh)
TriangleMesh {vertices, indices, normals}
    | (COLLADA DAE export)
.dae file → SketchUp definitions.import

Repositories

  • supex: /Users/darwin/x/p/supex-ws/supex (all changes go here — branch dev)
  • vcad: /Users/darwin/x/p/supex-ws/vcad (reference only — Rust crates as path deps; patches on supex-patches branch)
  • loon: /Users/darwin/x/p/supex-ws/loon (reference only — loon-lang crate; patches on supex-patches branch)
/Users/darwin/x/p/supex-ws/
├── supex/         # Main repo — all implementation goes here (git branch: dev)
│   └── vcad/sidecar/   # Rust sidecar (Phase sidecar) — path deps to sibling vcad/loon
├── vcad/          # vcad BRep kernel (44 crates) — reference + patches
├── loon/          # Loon language interpreter — reference + patches
├── example-simple-table/  # Example project
└── research/      # Research notes

Existing supex Infrastructure

  • Python MCP driver: driver/src/supex_driver/mcp/mcp_server.py — MCP server and tool registration
  • TCP connections: driver/src/supex_driver/connection/sketchup_connection.py and driver/src/supex_driver/connection/vcad_connection.py — JSON-RPC 2.0 clients
  • Ruby bridge: runtime/src/supex_runtime/bridge_server.rb — TCP JSON-RPC server on :9876
  • Ruby tools: runtime/src/supex_runtime/tools.rb — tool dispatch
  • Stdlib: stdlib/src/supex_stdlib/ — SketchUp API helpers

Key Design Decisions

Decision Choice Rationale
Sidecar runtime Rust native binary Direct access to loon-lang, vcad-eval, vcad-kernel; native ADT cache for cross-node composition; full Loon module resolution with filesystem access; no Node.js dependency
Sidecar location supex repo (vcad/sidecar/) Supex controls the entire stack
Modeling language Loon (ADT-based Lisp) Native vcad language; Hindley-Milner type inference; algebraic data types map directly to vcad IR; pipe-friendly subject-last convention; module system with [use ...]
Mesh exchange COLLADA DAE files (obj_path field kept for wire compatibility) Native SketchUp definitions.import, no custom format
Communication TCP JSON-RPC 2.0 on :9877 Same pattern as supex bridge
Metadata in SketchUp Attribute dictionary "vcad" node_id + source_file, not IR
Viewer Standalone Tauri app, slim R3F frontend No HtmlDialog limits, no @vcad/app editor baggage
Eval concurrency Single eval worker + queue Serialized evaluation avoids shared mutable state in Evaluator/ADT cache; health checks bypass queue via fast-path; backpressure via bounded queue
Cross-node composition ADT-level injection (Loon Value::Adt) Cached ADT tree injected into Loon env before eval; kernel optimizes full combined CSG tree in one pass; no mesh roundtrip or intermediate BRep serialization
Import resolution Three-way AST-based protocol Sidecar parses and rewrites AST, driver resolves references via Ruby bridge, sidecar evaluates with injected bindings; keeps sidecar SketchUp-agnostic and driver Loon-agnostic
Mesh authority Driver owns all mesh state Driver mediates sidecar -> SketchUp and sidecar -> viewer; single source of truth for revision/freshness; viewer and SketchUp never talk to sidecar directly
Reactive updates Poll + coalesce + revision guard SketchUp observer polled (no push transport needed); multi-source events coalesced into batched cascades; monotonic revision with drop-on-stale prevents rollback flicker
SketchUp target 2026-only, no legacy paths Simplified API surface (definitions.import returns ComponentDefinition directly); no fallback code for older versions

Loon CAD API (reference)

Loon uses algebraic data types (ADT) to represent geometry. The vcad CAD library (cad-lib/src/lib.loon) is automatically prepended before user code. Convenience constructors use subject-last convention for pipe compatibility.

Primitives:

  • [cube x y z]Cube f64 f64 f64
  • [cylinder r h]Cylinder f64 f64
  • [sphere r]Sphere f64
  • [cone rb rt h]Cone f64 f64 f64

Booleans (subject-last):

  • [union other s]Union Solid Solid
  • [difference tool s]Difference Solid Solid
  • [intersection other s]Intersection Solid Solid

Transforms (subject-last):

  • [translate x y z s]Translate Solid f64 f64 f64
  • [rotate x y z s]Rotate Solid f64 f64 f64
  • [scale x y z s]Scale Solid f64 f64 f64

Features (subject-last):

  • [fillet r s]Fillet Solid f64
  • [chamfer d s]Chamfer Solid f64
  • [shell t s]Shell Solid f64

Patterns (subject-last):

  • [linear-pattern dx dy dz count spacing s]LinearPattern Solid f64 f64 f64 Int f64
  • [circular-pattern ox oy oz ax ay az count angle s]CircularPattern ...

Sketch + extrude:

  • [sketch ox oy oz xx xy xz yx yy yz segments]Sketch ...
  • [extrude dx dy dz sk]Extrude Sketch f64 f64 f64
  • [revolve aox aoy aoz adx ady adz angle sk]Revolve ...

Sweep/Loft:

  • [sweep-line sx sy sz ex ey ez sk]SweepLine ...
  • [sweep-helix radius pitch height turns sk]SweepHelix ...
  • [loft sketches]Loft (Vec Sketch)

Scene/Material:

  • [root solid "material-name"]SceneEntry Solid Str
  • [material "name" r g b metallic roughness]Material ...

Pipe operator (thread-last):

[pipe [cube 50.0 30.0 5.0]
  [difference [cylinder 3.0 10.0]]
  [fillet 1.0]
  [translate 0.0 0.0 10.0]]

Rust evaluation API (reference)

Key crates used by the sidecar:

// loon-lang: parse + evaluate
loon_lang::parser::parse(source: &str) -> Result<Vec<Expr>, ParseError>
loon_lang::interp::eval_program(exprs: &[Expr]) -> IResult  // IResult = Result<Value, InterpError>
loon_lang::interp::eval_program_with_base_dir(exprs: &[Expr], base_dir: Option<&Path>) -> IResult

// vcad-loon: Loon source -> vcad IR Document
vcad_loon::eval_vcad(source: &str, base_dir: Option<&Path>) -> Result<Document, String>
vcad_loon::eval_vcad_file(path: &Path) -> Result<Document, String>
vcad_loon::eval_vcad_to_value(source: &str, base_dir: Option<&Path>) -> Result<Value, String>
vcad_loon::value_to_document(value: &Value) -> Result<Document, String>
// Internally: prepends lib.loon, parses, evaluates, converts Value::Adt -> Document

// vcad-eval: Document -> EvaluatedScene (BRep + meshes)
vcad_eval::evaluate_document(doc: &Document, options: &EvalOptions) -> Result<EvaluatedScene, EvalError>
// EvalOptions { skip_clash_detection: bool }
// EvaluatedScene { parts: Vec<EvaluatedPart>, part_defs, instances, clashes }
// EvaluatedPart { mesh: EvaluatedMesh, material: String, solid: Option<Solid> }
// EvaluatedMesh { positions: Vec<f32>, indices: Vec<u32>, normals: Option<Vec<f32>> }
//   NOTE: normals is Option — handle None case in mesh export

// vcad-kernel: BRep operations + mesh
vcad_kernel::Solid::volume(&self) -> f64
vcad_kernel::Solid::surface_area(&self) -> f64
vcad_kernel::Solid::bounding_box(&self) -> ([f64;3], [f64;3])
vcad_kernel::Solid::is_empty(&self) -> bool
vcad_kernel::Solid::to_mesh(&self, segments: u32) -> TriangleMesh
// TriangleMesh { vertices: Vec<f32>, indices: Vec<u32>, normals: Vec<f32> }
//   NOTE: field is `vertices` not `positions` (different from EvaluatedMesh)

Data model: one .cmp.oo = one ComponentDefinition (MVP enforced)

The last expression in a .cmp.oo file is evaluated by the Loon interpreter to produce an ADT value. vcad-loon converts this to a Document. For Phase sidecar-mcp-tools, each file must produce exactly one renderable part, which maps to one SketchUp ComponentDefinition.

This is enforced by sidecar validation (not only convention):

  • scene.parts.len() == 0 -> NO_GEOMETRY
  • scene.parts.len() == 1 -> valid
  • scene.parts.len() > 1 -> MULTI_PART_UNSUPPORTED

For multi-part assemblies, the user structures the project as:

project/
├── src/
│   └── build.loon              # Shared: parametric geometry functions
├── base-plate.cmp.oo           # Exports one Solid (base plate)
├── bracket-left.cmp.oo         # Exports one Solid (left bracket)
├── bracket-right.cmp.oo        # Exports one Solid (right bracket)
└── bolt-pattern.cmp.oo         # Exports one Solid (bolt array)
; src/build.loon — shared parametric logic
[pub let plate-w 200.0]
[pub let plate-d 150.0]

[pub fn make-plate [] [cube plate-w plate-d 10.0]]
[pub fn make-bracket [side]
  [let offset [if [= side :left] -80.0 80.0]]
  [pipe [cube 10.0 40.0 60.0]
    [translate offset 0.0 5.0]]]
[pub fn make-bolts []
  [pipe [cylinder 4.0 15.0]
    [linear-pattern 50.0 0.0 0.0 4 50.0]]]

; base-plate.cmp.oo — one Solid out
[use build]
[build.make-plate]

; bracket-left.cmp.oo — one Solid out
[use build]
[build.make-bracket :left]

Each .cmp.oo produces one ComponentDefinition in SketchUp. The user places instances and arranges hierarchy in SketchUp manually (or via agent MCP tools).

Loon module system ([use ...]) resolves imports from the project root via the filesystem. The sidecar passes base_dir to eval_vcad_file(), enabling seamless cross-file imports. Shared functions in build.loon return Solid ADT values — since these are pure data (not BRep objects), they serialize/copy trivially.

ADT composition (Phase adt-imports): After evaluating a .cmp.oo, the sidecar retains the Loon Value::Adt tree (the result before conversion to Document). When another node imports :solid from a vcad-backed entity, the cached ADT is injected as a Loon binding — the importing code operates on it with normal cad-lib functions ([difference imported], [fillet 2.0 imported]). The combined ADT tree flows through the full pipeline (value_to_document -> evaluate_document) so the kernel optimizes the entire CSG tree in one pass — no mesh roundtrip, no intermediate BRep serialization.

supex Conventions

  • Branch: dev (never directly on main)
  • Version: DO NOT BUMP without explicit request
  • Commits: commit after each phase (plan-runner default)
  • SketchUp support target: 2026 only (no legacy compatibility paths)
  • Emoji: DO NOT USE in documentation

Phase sidecar: vcad sidecar — Rust TCP server + Loon evaluator ✅

Objective

Create a Rust sidecar binary in vcad/sidecar/ that listens on TCP :9877, evaluates Loon code via the vcad Rust crate pipeline (loon-lang + vcad-loon + vcad-eval + vcad-kernel), and returns OBJ meshes with geometry metadata. The phase outcome is a working vcad.eval_code and vcad.eval_file via JSON-RPC.

Specification

Cargo project setup

Create vcad/sidecar/Cargo.toml:

[package]
name = "supex-vcad-sidecar"
version = "0.1.0"
edition = "2021"

[dependencies]
# vcad evaluation pipeline (path deps to supex-ws siblings)
loon-lang = { path = "../../../loon/crates/loon-lang" }
vcad-loon = { path = "../../../vcad/crates/vcad-loon" }
vcad-eval = { path = "../../../vcad/crates/vcad-eval" }
vcad-kernel = { path = "../../../vcad/crates/vcad-kernel" }
vcad-ir = { path = "../../../vcad/crates/vcad-ir" }

# JSON-RPC + serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["clock"] }
blake3 = "1"

# Temp files
tempfile = "3"

# Signal handling
ctrlc = "3"

# Logging
tracing = "0.1"
tracing-subscriber = "0.3"

Path deps assume workspace layout:

/Users/darwin/x/p/supex-ws/
  supex/vcad/sidecar/     # this Cargo project
  vcad/crates/             # vcad Rust crates
  loon/crates/             # loon-lang crate

Project structure (vcad/sidecar/)

vcad/sidecar/
├── Cargo.toml
├── src/
│   ├── main.rs            # Entry point: start TCP server
│   ├── server.rs          # TCP listener, JSON-RPC dispatch
│   ├── evaluator.rs       # Loon evaluation pipeline
│   ├── obj_export.rs      # EvaluatedMesh -> Wavefront OBJ
│   ├── adt_cache.rs       # Loon ADT cache for cross-node composition (Phase adt-imports)
│   └── config.rs          # Port, host, temp_dir from env

TCP JSON-RPC server (vcad/sidecar/src/server.rs)

Newline-delimited JSON-RPC 2.0 server on 127.0.0.1:9877 (configurable via VCAD_PORT).

Concurrency model:

  • Network I/O accepts multiple TCP clients concurrently.
  • Geometry evaluation is serialized through a single eval worker (queue) that owns Evaluator.
  • Lightweight methods (hello, ping, resources/list) are handled immediately and never wait behind long eval jobs.

This preserves safe single-evaluator semantics while keeping health checks responsive under load.

Pattern: follow runtime/src/supex_runtime/bridge_server.rb — same JSON-RPC format, same error codes.

Backpressure/timeouts:

  • VCAD_MAX_QUEUE (default 64) limits queued eval jobs.
  • VCAD_EVAL_TIMEOUT_MS (default 120000) limits wait time for one eval request.
  • Queue overflow returns structured JSON-RPC error VCAD_QUEUE_FULL.
  • Eval timeout returns structured JSON-RPC error VCAD_EVAL_TIMEOUT.

Graceful shutdown:

  • On SIGTERM/SIGINT, sidecar stops accepting new TCP connections and drains in-flight work.
  • Listener socket is closed immediately (no new connections accepted).
  • Running eval job is allowed to finish (bounded by VCAD_EVAL_TIMEOUT_MS).
  • Pending queued jobs are dropped (their reply_tx channels are dropped, clients get connection-closed).
  • Open TCP connections finish their current response, then close.
  • Artifact pair write in progress completes or rolls back via pending marker (crash-recovery handles the rest on next startup).
  • Exit code 0 on clean shutdown, non-zero on timeout or error.

Security model:

  • Bind to loopback by default (127.0.0.1).
  • Non-loopback bind is allowed only with VCAD_ALLOW_REMOTE=1 and configured VCAD_AUTH_TOKEN.
  • Clients authenticate in hello params via token; missing/invalid token returns AUTH_REQUIRED / AUTH_INVALID.
  • Path-bearing operations (vcad.eval_file, export paths, future file writes) must validate against connection workspace root; violations return PATH_NOT_ALLOWED.
  • Reject path traversal outside workspace (including ../ escapes and disallowed absolute paths).
  • Manifest paths are internal-only: derive from accepted obj_path and reject any read/write outside workspace-scoped allowed roots.

JSON-RPC contract (aligned with supex bridge):

  • hello — handshake (returns {version, engine: "rust", protocol_version, capabilities, limits})
  • ping — health check
  • resources/list — optional sidecar resources/capabilities listing
  • tools/call — domain method dispatch via params.name + params.arguments

hello expected params:

  • name, version, agent, pid (same shape as supex bridge)
  • workspace (used for path policy)
  • token (required when sidecar auth is enabled)
  • protocol_version (driver-supported protocol major, required)
  • requested_capabilities (optional list for explicit negotiation)

Handshake compatibility rules:

  • sidecar responds with protocol_version (major) and optional protocol_minor
  • if requested/advertised major versions differ, sidecar returns PROTOCOL_MISMATCH
  • no migration/fallback mode for protocol mismatch in this plan; incompatibility is fail-fast
  • sidecar advertises capabilities + limits; driver must gate feature usage accordingly

hello response additions:

  • capabilities example:
    • imports.data_extracts
    • imports.solid_adt
    • viewer.relay
    • auth.required
  • limits example:
    • max_queue
    • eval_timeout_ms
    • payload_inline_max

Supported tools/call names:

  • vcad.eval_code(code: string) — evaluates Loon string, returns mesh + metadata
  • vcad.eval_file(path: string) — evaluates .cmp.oo file (with module resolution)
  • vcad.inspect(code_or_path: string) — returns volume, bbox, surface_area (no OBJ export)
  • vcad.eval_repl(code: string) — evaluates Loon and returns display string (no mesh)

Unified application error taxonomy (use these error_code values across sidecar/driver/ruby/viewer-relay):

error_code Layer Meaning
NO_GEOMETRY sidecar scene produced zero parts
MULTI_PART_UNSUPPORTED sidecar scene produced more than one part in single-component phase
VCAD_QUEUE_FULL sidecar eval queue capacity exceeded
VCAD_EVAL_TIMEOUT sidecar eval request timed out while waiting for worker
AUTH_REQUIRED sidecar auth token required but missing
AUTH_INVALID sidecar auth token provided but invalid
PROTOCOL_MISMATCH sidecar/driver/viewer relay incompatible protocol major versions
CAPABILITY_UNAVAILABLE driver requested feature not present in negotiated capabilities
STATE_RECONCILE_REQUIRED driver persisted runtime state diverged from SketchUp/runtime reality
SOURCE_FILE_MISSING driver node exists in SketchUp but source file is missing/unreadable during recovery
SCHEMA_VALIDATION_FAILED driver/sidecar/viewer relay payload violates negotiated JSON schema contract
ARTIFACT_READ_FAILED driver diagnostics artifact manifest cannot be read (missing/corrupt/io)
PATH_NOT_ALLOWED sidecar/ruby/driver workspace path policy violation
IMPORT_DEFINITION_NOT_FOUND ruby runtime import did not return expected definition
PAYLOAD_TOO_LARGE viewer relay inline payload exceeds allowed size
SOLID_IMPORT_UNAVAILABLE import pipeline required solid-import capability not available

Error response contract:

  • Always return machine-readable error_code separate from human message.
  • Optional details object may include operation, path, counts, etc.
  • For selected error codes, details has required fields defined in Phase error-details.
  • Tests must assert error_code, not message substrings.
// server.rs — request/response types
#[derive(Deserialize)]
struct JsonRpcRequest {
    jsonrpc: String,  // "2.0"
    id: serde_json::Value,
    method: String,
    params: Option<serde_json::Value>,
}

#[derive(Serialize)]
struct JsonRpcResponse {
    jsonrpc: String,
    id: serde_json::Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    result: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<JsonRpcError>,
}

#[derive(Serialize)]
struct JsonRpcError {
    code: i32,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    data: Option<serde_json::Value>, // {"error_code": "...", "details": {...}}
}

Server loop:

fn run(config: &Config) {
    let listener = TcpListener::bind((config.host.as_str(), config.port))?;
    eprintln!("vcad sidecar listening on :{}",  config.port);

    // Shutdown signal: set by SIGTERM/SIGINT handler.
    let shutdown = Arc::new(AtomicBool::new(false));
    {
        let shutdown = shutdown.clone();
        ctrlc::set_handler(move || {
            eprintln!("vcad sidecar shutting down...");
            shutdown.store(true, Ordering::SeqCst);
        }).expect("Failed to set signal handler");
    }

    let (job_tx, job_rx) = std::sync::mpsc::sync_channel::<EvalJob>(config.max_queue);

    // Single eval worker owns Evaluator; all heavy eval requests are serialized here.
    // Exits when job_tx is dropped (all senders gone after shutdown).
    std::thread::spawn({
        let temp_dir = config.temp_dir.clone();
        let temp_ttl_sec = config.temp_ttl_sec;
        let temp_max_files = config.temp_max_files;
        let adt_cache_max = config.adt_cache_max;
        move || {
            let mut evaluator = Evaluator::new(temp_dir, temp_ttl_sec, temp_max_files, adt_cache_max);
            while let Ok(job) = job_rx.recv() {
                let result = dispatch_eval(job.request, &mut evaluator);
                let _ = job.reply_tx.send(result);
            }
        }
    });

    // Non-blocking accept loop: check shutdown flag between accepts.
    listener.set_nonblocking(true)?;
    while !shutdown.load(Ordering::SeqCst) {
        match listener.accept() {
            Ok((stream, _)) => {
                stream.set_nonblocking(false)?;
                let job_tx = job_tx.clone();
                let eval_timeout_ms = config.eval_timeout_ms;
                std::thread::spawn(move || {
                    handle_connection(stream, job_tx, eval_timeout_ms);
                });
            }
            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                std::thread::sleep(std::time::Duration::from_millis(50));
            }
            Err(e) => return Err(e.into()),
        }
    }

    // Shutdown: drop job_tx so eval worker drains current job and exits.
    drop(job_tx);
    eprintln!("vcad sidecar stopped");
}

fn handle_connection(
    stream: TcpStream,
    job_tx: std::sync::mpsc::SyncSender<EvalJob>,
    eval_timeout_ms: u64,
) {
    let reader = BufReader::new(&stream);
    let mut writer = BufWriter::new(&stream);

    for line in reader.lines() {
        let request: JsonRpcRequest = serde_json::from_str(&line?)?;

        // Fast-path methods bypass eval queue so health checks stay responsive.
        let response = match request.method.as_str() {
            "hello" | "ping" | "resources/list" => dispatch_fast(&request),
            _ => {
                // dispatch_eval validates auth + workspace path policy before execution.
                let (reply_tx, reply_rx) = std::sync::mpsc::channel();
                let job = EvalJob { request, reply_tx };

                if job_tx.send(job).is_err() {
                    error_response("VCAD_QUEUE_FULL", "Evaluation queue is full")
                } else {
                    match reply_rx.recv_timeout(std::time::Duration::from_millis(eval_timeout_ms)) {
                        Ok(resp) => resp,
                        Err(_) => error_response("VCAD_EVAL_TIMEOUT", "Evaluation timed out"),
                    }
                }
            }
        };

        serde_json::to_writer(&mut writer, &response)?;
        writer.write_all(b"\n")?;
        writer.flush()?;
    }
}

Connection context requirements:

  • Persist workspace and auth state from hello per TCP connection.
  • Persist negotiated protocol_version, capabilities, and limits from hello.
  • Reject non-hello methods until identification succeeds.
  • For vcad.eval_file(path) and any file-writing tool, validate path against connection workspace before enqueueing eval job.

Evaluator (vcad/sidecar/src/evaluator.rs)

Encapsulates the full Loon -> Document -> Solid -> mesh -> OBJ pipeline.

use vcad_loon::{eval_vcad, eval_vcad_file, eval_vcad_to_value};
use vcad_eval::{evaluate_document, EvalOptions};
use vcad_ir::Document;

struct TempRetention {
    ttl_sec: u64,
    max_files: usize,
    seq: u64,
}

pub struct Evaluator {
    temp_dir: PathBuf,
    adt_cache: AdtCache,
    retention: TempRetention,
}

#[derive(Serialize)]
pub struct EvalResult {
    pub obj_path: String,
    pub manifest_path: String,
    pub volume: f64,
    pub surface_area: f64,
    pub bbox: BBox,
    pub is_empty: bool,
}

#[derive(Serialize)]
pub struct ArtifactManifest {
    pub status: String,           // applied | stale_dropped | superseded
    pub node_id: Option<String>,
    pub revision: Option<u64>,
    pub request_id: Option<String>,
    pub source_file: Option<String>,
    pub source_hash: String,
    pub obj_path: String,
    pub volume: f64,
    pub surface_area: f64,
    pub bbox: BBox,
    pub queued_at: Option<String>,
    pub started_at: Option<String>,
    pub finished_at: String,
}

#[derive(Serialize)]
pub struct BBox {
    pub min: [f64; 3],
    pub max: [f64; 3],
}

impl Evaluator {
    pub fn new(temp_dir: PathBuf, ttl_sec: u64, max_files: usize, adt_cache_max: usize) -> Self {
        std::fs::create_dir_all(&temp_dir).ok();
        let max_files = max_files.max(1);
        let seq = Self::load_retention_seq(&temp_dir);
        let mut this = Self {
            temp_dir,
            adt_cache: AdtCache::new(adt_cache_max),
            retention: TempRetention {
                ttl_sec,
                max_files,
                seq,
            },
        };
        this.recover_incomplete_artifact_pairs().ok();
        this
    }

    fn cleanup_temp_dir(&self) {
        // Remove expired files by ttl_sec and trim to max_files newest files.
    }

    fn load_retention_seq(temp_dir: &Path) -> u64 {
        let mut max_seq = 0_u64;
        let Ok(entries) = std::fs::read_dir(temp_dir) else {
            return 0;
        };

        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("obj") {
                continue;
            }
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            let Some((_, seq_str)) = stem.rsplit_once('-') else {
                continue;
            };
            if seq_str.len() != 20 || !seq_str.bytes().all(|b| b.is_ascii_digit()) {
                continue;
            }
            if let Ok(seq) = seq_str.parse::<u64>() {
                max_seq = max_seq.max(seq);
            }
        }

        max_seq
    }

    fn next_obj_path(&mut self, name: &str) -> Result<PathBuf, String> {
        let safe_name = Self::sanitize_obj_name(name);

        loop {
            let next_seq = self.retention.seq.checked_add(1)
                .ok_or_else(|| "TEMP_SEQ_EXHAUSTED: cannot allocate unique OBJ filename".to_string())?;
            self.retention.seq = next_seq;
            let candidate = self.temp_dir.join(format!(
                "{}-{:020}.obj",
                safe_name,
                self.retention.seq,
            ));
            if !candidate.exists() {
                return Ok(candidate);
            }
        }
    }

    fn sanitize_obj_name(name: &str) -> String {
        // Keep [A-Za-z0-9-_], replace others with '_'.
    }

    /// Evaluate inline Loon code (no module resolution).
    pub fn eval_code(&mut self, code: &str) -> Result<EvalResult, String> {
        let doc = eval_vcad(code, None)?;
        self.evaluate_and_export(&doc, "eval")
    }

    /// Evaluate .cmp.oo file (with module resolution via base_dir).
    pub fn eval_file(&mut self, path: &str) -> Result<EvalResult, String> {
        let file_path = Path::new(path);
        let doc = eval_vcad_file(file_path)?;
        let stem = file_path.file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("file");
        self.evaluate_and_export(&doc, stem)
    }

    /// Evaluate Loon code and return display string (REPL mode, no mesh).
    pub fn eval_repl(&self, code: &str) -> Result<String, String> {
        let value = eval_vcad_to_value(code, None)?;
        Ok(format!("{}", value))
    }

    /// Enforce single-part contract for this phase.
    fn select_single_part<'a>(
        &self,
        scene: &'a vcad_eval::EvaluatedScene,
    ) -> Result<&'a vcad_eval::EvaluatedPart, String> {
        match scene.parts.len() {
            0 => Err("NO_GEOMETRY: scene produced zero parts".to_string()),
            1 => Ok(&scene.parts[0]),
            n => Err(format!(
                "MULTI_PART_UNSUPPORTED: scene produced {} parts",
                n
            )),
        }
    }

    /// Inspect: evaluate and return only geometry metadata (no OBJ export).
    pub fn inspect(&self, code_or_path: &str) -> Result<EvalResult, String> {
        let doc = if code_or_path.ends_with(".cmp.oo") || code_or_path.ends_with(".loon") {
            eval_vcad_file(Path::new(code_or_path))?
        } else {
            eval_vcad(code_or_path, None)?
        };

        let options = EvalOptions { skip_clash_detection: true };
        let scene = evaluate_document(&doc, &options)
            .map_err(|e| format!("Evaluation error: {:?}", e))?;

        let part = self.select_single_part(&scene)?;
        let solid = part.solid.as_ref()
            .ok_or_else(|| "No BRep solid produced".to_string())?;

        let (bb_min, bb_max) = solid.bounding_box();

        Ok(EvalResult {
            obj_path: String::new(),
            manifest_path: String::new(),
            volume: solid.volume(),
            surface_area: solid.surface_area(),
            bbox: BBox { min: bb_min, max: bb_max },
            is_empty: solid.is_empty(),
        })
    }

    fn evaluate_and_export(&mut self, doc: &Document, name: &str) -> Result<EvalResult, String> {
        // Pre-clean before export to reclaim expired/overflow files even if write fails.
        self.cleanup_temp_dir();

        let options = EvalOptions { skip_clash_detection: true };
        let scene = evaluate_document(doc, &options)
            .map_err(|e| format!("Evaluation error: {:?}", e))?;

        let part = self.select_single_part(&scene)?;

        // Extract geometry metadata from Solid
        let (volume, surface_area, bbox, is_empty) = if let Some(ref solid) = part.solid {
            let (bb_min, bb_max) = solid.bounding_box();
            (solid.volume(), solid.surface_area(), BBox { min: bb_min, max: bb_max }, solid.is_empty())
        } else {
            // Fallback: compute bbox from mesh positions
            let bb = compute_mesh_bbox(&part.mesh);
            (0.0, 0.0, bb, false)
        };

        // Build deterministic artifact files.
        let obj_content = mesh_to_obj(&part.mesh);
        let obj_path = self.next_obj_path(name)?;
        let manifest_path = Self::manifest_path_for_obj(&obj_path, &self.temp_dir)?;
        let manifest = ArtifactManifest {
            status: "applied".to_string(),
            node_id: None,
            revision: None,
            request_id: None,
            source_file: None,
            source_hash: Self::hash_document(doc),
            obj_path: obj_path.to_string_lossy().into_owned(),
            volume,
            surface_area,
            bbox: BBox { min: bbox.min, max: bbox.max },
            queued_at: None,
            started_at: None,
            finished_at: Self::now_rfc3339(),
        };

        // Publish OBJ + manifest as an atomic pair (with crash-recovery marker).
        Self::write_artifact_pair_atomic(
            &self.temp_dir,
            &obj_path,
            &obj_content,
            &manifest_path,
            &manifest,
        )
        .map_err(|e| format!("Artifact pair write error: {}", e))?;

        self.cleanup_temp_dir();

        Ok(EvalResult {
            obj_path: obj_path.to_string_lossy().into_owned(),
            manifest_path: manifest_path.to_string_lossy().into_owned(),
            volume,
            surface_area,
            bbox,
            is_empty,
        })
    }

    fn write_artifact_pair_atomic(
        allowed_root: &Path,
        obj_path: &Path,
        obj_content: &str,
        manifest_path: &Path,
        manifest: &ArtifactManifest,
    ) -> Result<(), String> {
        let canonical_root = allowed_root
            .canonicalize()
            .map_err(|e| format!("PATH_NOT_ALLOWED: root canonicalize failed: {}", e))?;
        for p in [obj_path, manifest_path] {
            let parent = p.parent().ok_or_else(|| "PATH_NOT_ALLOWED: missing parent".to_string())?;
            let canonical_parent = parent
                .canonicalize()
                .map_err(|e| format!("PATH_NOT_ALLOWED: parent canonicalize failed: {}", e))?;
            if !canonical_parent.starts_with(&canonical_root) {
                return Err("PATH_NOT_ALLOWED: artifact path outside allowed root".to_string());
            }
        }

        let obj_tmp = obj_path.with_extension("obj.tmp");
        let manifest_tmp = manifest_path.with_extension("json.tmp");
        let pair_marker = obj_path.with_extension("pair.pending");

        let marker = serde_json::json!({
            "obj_path": obj_path,
            "manifest_path": manifest_path,
            "created_at": Self::now_rfc3339(),
        });
        let marker_body = serde_json::to_vec_pretty(&marker)
            .map_err(|e| format!("Pair marker serialize error: {}", e))?;
        std::fs::write(&pair_marker, marker_body)
            .map_err(|e| format!("Pair marker write error: {}", e))?;

        std::fs::write(&obj_tmp, obj_content)
            .map_err(|e| format!("OBJ temp write error: {}", e))?;

        let body = serde_json::to_vec_pretty(manifest)
            .map_err(|e| format!("Manifest serialize error: {}", e))?;
        std::fs::write(&manifest_tmp, body)
            .map_err(|e| format!("Manifest temp write error: {}", e))?;

        std::fs::rename(&obj_tmp, obj_path)
            .map_err(|e| format!("OBJ publish rename error: {}", e))?;
        std::fs::rename(&manifest_tmp, manifest_path)
            .map_err(|e| format!("Manifest publish rename error: {}", e))?;
        std::fs::remove_file(&pair_marker).ok();

        Ok(())
    }

    fn recover_incomplete_artifact_pairs(&mut self) -> Result<(), String> {
        // On startup, scan *.pair.pending markers and rollback incomplete pairs.
        // Recovery rule: if marker exists, remove tmp files and any half-published pair,
        // then remove marker so retention sees only committed pairs.
        Ok(())
    }

    fn manifest_path_for_obj(obj_path: &Path, allowed_root: &Path) -> Result<PathBuf, String> {
        let canonical_root = allowed_root
            .canonicalize()
            .map_err(|e| format!("PATH_NOT_ALLOWED: root canonicalize failed: {}", e))?;
        let parent = obj_path
            .parent()
            .ok_or_else(|| "PATH_NOT_ALLOWED: obj parent missing".to_string())?;
        let canonical_parent = parent
            .canonicalize()
            .map_err(|e| format!("PATH_NOT_ALLOWED: parent canonicalize failed: {}", e))?;
        if !canonical_parent.starts_with(&canonical_root) {
            return Err("PATH_NOT_ALLOWED: manifest path outside allowed root".to_string());
        }
        let file_name = obj_path
            .file_name()
            .ok_or_else(|| "PATH_NOT_ALLOWED: obj filename missing".to_string())?;
        Ok(canonical_parent.join(file_name).with_extension("manifest.json"))
    }

    fn hash_document(doc: &Document) -> String {
        // Deterministic hash of evaluated source/document for reproducibility diagnostics.
        format!("blake3:{}", blake3::hash(format!("{:?}", doc).as_bytes()).to_hex())
    }

    fn now_rfc3339() -> String {
        chrono::Utc::now().to_rfc3339()
    }
}

OBJ export (vcad/sidecar/src/obj_export.rs)

use vcad_eval::EvaluatedMesh;

/// Convert EvaluatedMesh to Wavefront OBJ text format.
/// EvaluatedMesh has: positions: Vec<f32>, indices: Vec<u32>, normals: Option<Vec<f32>>
/// Units: mm (vcad default).
/// Note: OBJ carries geometry only (no materials). Material metadata (color, metallic,
/// roughness) is delivered separately via the viewer relay `mesh.update` message and
/// via SketchUp attribute dictionaries — not embedded in the OBJ file.
pub fn mesh_to_obj(mesh: &EvaluatedMesh) -> String {
    use std::fmt::Write;
    let mut obj = String::new();

    // Vertices: v x y z
    for i in (0..mesh.positions.len()).step_by(3) {
        writeln!(obj, "v {} {} {}",
            mesh.positions[i], mesh.positions[i+1], mesh.positions[i+2]).unwrap();
    }

    // Normals: vn nx ny nz (if available)
    let has_normals = mesh.normals.as_ref().map_or(false, |n| !n.is_empty());
    if let Some(ref normals) = mesh.normals {
        for i in (0..normals.len()).step_by(3) {
            writeln!(obj, "vn {} {} {}",
                normals[i], normals[i+1], normals[i+2]).unwrap();
        }
    }

    // Faces: f v1//n1 v2//n2 v3//n3 (with normals) or f v1 v2 v3 (without)
    for i in (0..mesh.indices.len()).step_by(3) {
        let v1 = mesh.indices[i] + 1;
        let v2 = mesh.indices[i+1] + 1;
        let v3 = mesh.indices[i+2] + 1;
        if has_normals {
            writeln!(obj, "f {}//{} {}//{} {}//{}",
                v1, v1, v2, v2, v3, v3).unwrap();
        } else {
            writeln!(obj, "f {} {} {}", v1, v2, v3).unwrap();
        }
    }

    obj
}

/// Compute bounding box from mesh positions (fallback when Solid not available).
/// Note: EvaluatedMesh.positions is Vec<f32>, convert to f64 for BBox.
pub fn compute_mesh_bbox(mesh: &EvaluatedMesh) -> BBox {
    let mut min = [f64::MAX; 3];
    let mut max = [f64::MIN; 3];
    let positions = &mesh.positions;
    for i in (0..positions.len()).step_by(3) {
        for j in 0..3 {
            let v = positions[i + j] as f64;
            min[j] = min[j].min(v);
            max[j] = max[j].max(v);
        }
    }
    BBox { min, max }
}

ADT cache (vcad/sidecar/src/adt_cache.rs)

Retained for Phase adt-imports cross-node composition. Stores evaluated Loon ADT trees (Value::Adt) keyed by node_id. When another node imports :solid, the cached ADT is injected as a Loon binding — the importing code uses it with normal cad-lib functions.

Eviction policy:

  • Maximum entries controlled by VCAD_ADT_CACHE_MAX (default 256).
  • LRU eviction: on set, if cache is at capacity, the least-recently-used entry is evicted.
  • get counts as access (updates LRU order).
  • invalidate and clear are immediate (no LRU interaction).
  • DAG-tracked nodes are never auto-evicted while their node is active in the driver DAG — the driver calls invalidate explicitly on node removal.
use loon_lang::interp::Value;
use std::collections::HashMap;

pub struct AdtCache {
    cache: HashMap<String, CacheEntry>,
    max_entries: usize,
    access_seq: u64,
}

struct CacheEntry {
    value: Value,
    last_access: u64,
}

impl AdtCache {
    pub fn new(max_entries: usize) -> Self {
        Self {
            cache: HashMap::new(),
            max_entries: max_entries.max(1),
            access_seq: 0,
        }
    }

    pub fn set(&mut self, node_id: &str, value: Value) {
        self.access_seq += 1;
        self.cache.insert(node_id.to_string(), CacheEntry {
            value,
            last_access: self.access_seq,
        });
        self.evict_if_needed();
    }

    pub fn get(&mut self, node_id: &str) -> Option<&Value> {
        self.access_seq += 1;
        let seq = self.access_seq;
        self.cache.get_mut(node_id).map(|entry| {
            entry.last_access = seq;
            &entry.value
        })
    }

    pub fn has(&self, node_id: &str) -> bool {
        self.cache.contains_key(node_id)
    }

    pub fn invalidate(&mut self, node_id: &str) {
        self.cache.remove(node_id);
    }

    pub fn clear(&mut self) {
        self.cache.clear();
    }

    pub fn len(&self) -> usize {
        self.cache.len()
    }

    fn evict_if_needed(&mut self) {
        while self.cache.len() > self.max_entries {
            let lru_key = self.cache.iter()
                .min_by_key(|(_, e)| e.last_access)
                .map(|(k, _)| k.clone());
            if let Some(key) = lru_key {
                self.cache.remove(&key);
            } else {
                break;
            }
        }
    }
}

Entry point (vcad/sidecar/src/main.rs)

mod config;
mod evaluator;
mod obj_export;
mod server;
mod adt_cache;

fn main() {
    tracing_subscriber::fmt::init();
    let config = config::Config::from_env();
    eprintln!("vcad sidecar v{} (Rust + loon-lang)", env!("CARGO_PKG_VERSION"));
    server::run(&config);
}

Config (vcad/sidecar/src/config.rs)

pub struct Config {
    pub host: String,
    pub port: u16,
    pub temp_dir: PathBuf,
    pub temp_ttl_sec: u64,
    pub temp_max_files: usize,
    pub max_queue: usize,
    pub eval_timeout_ms: u64,
    pub adt_cache_max: usize,
    pub allow_remote: bool,
    pub auth_token: Option<String>,
}

impl Config {
    pub fn from_env() -> Self {
        Self {
            host: std::env::var("VCAD_HOST").unwrap_or_else(|_| "127.0.0.1".into()),
            port: std::env::var("VCAD_PORT")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(9877),
            temp_dir: std::env::var("VCAD_TEMP_DIR")
                .map(PathBuf::from)
                .unwrap_or_else(|_| std::env::temp_dir().join("vcad-sidecar")),
            temp_ttl_sec: std::env::var("VCAD_TEMP_TTL_SEC")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(3600),
            temp_max_files: std::env::var("VCAD_TEMP_MAX_FILES")
                .ok()
                .and_then(|s| s.parse().ok())
                .filter(|v: &usize| *v > 0)
                .unwrap_or(500),
            max_queue: std::env::var("VCAD_MAX_QUEUE")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(64),
            eval_timeout_ms: std::env::var("VCAD_EVAL_TIMEOUT_MS")
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or(120_000),
            adt_cache_max: std::env::var("VCAD_ADT_CACHE_MAX")
                .ok()
                .and_then(|s| s.parse().ok())
                .filter(|v: &usize| *v > 0)
                .unwrap_or(256),
            allow_remote: std::env::var("VCAD_ALLOW_REMOTE")
                .ok()
                .map(|s| s == "1")
                .unwrap_or(false),
            auth_token: std::env::var("VCAD_AUTH_TOKEN").ok(),
        }
    }
}

Verify

cd /Users/darwin/x/p/supex-ws/supex/vcad/sidecar && cargo build && cargo test

# runtime behavior check:
# - enqueue a long eval request
# - verify `ping` responds while eval is in progress
# - verify queue overflow returns VCAD_QUEUE_FULL and timeout returns VCAD_EVAL_TIMEOUT
# - evaluate source that produces >1 part and assert MULTI_PART_UNSUPPORTED
# - verify remote bind without VCAD_AUTH_TOKEN is rejected
# - verify eval_file path traversal (`../`) returns PATH_NOT_ALLOWED
# - run many evals and assert retained files in VCAD_TEMP_DIR stay <= VCAD_TEMP_MAX_FILES
# - verify OBJ/manifest retention remains paired (no orphan manifest without OBJ)
# - verify eval response includes manifest_path and manifest contains deterministic source_hash + timestamps
# - verify manifest path escapes (`../`) are rejected with PATH_NOT_ALLOWED
# - verify symlinked temp path outside workspace is rejected with PATH_NOT_ALLOWED
# - inject fault between OBJ/manifest publish steps and verify no committed half-pair is visible
# - restart sidecar and verify startup recovery scan removes incomplete pair markers/tmp files
# - age out temp files and assert cleanup respects VCAD_TEMP_TTL_SEC
# - restart sidecar and verify subsequent exports do not overwrite existing retained OBJ files
# - simulate OBJ write failure and verify retention cleanup still runs (pre-write + failure path)
# - seed `*-18446744073709551615.obj` and verify export returns TEMP_SEQ_EXHAUSTED instead of hanging
# - all negative-path checks assert `error_code` field (not message substring)
# graceful shutdown check:
# - send SIGTERM during idle -> sidecar exits with code 0
# - send SIGTERM during active eval -> running eval finishes, sidecar exits with code 0
# - send SIGTERM with queued pending jobs -> pending jobs are dropped, running job completes
# - verify no new TCP connections are accepted after SIGTERM

Phase vcad-conn: supex driver — vcad connection + sidecar lifecycle ✅

Objective

Add TCP client for the vcad sidecar and sidecar process lifecycle management to the supex Python driver. No MCP tools yet — just the connection infrastructure.

Specification

vcad connection (driver/src/supex_driver/connection/vcad_connection.py)

TCP client for the vcad sidecar. Copies the pattern from connection.py (SketchupConnection):

class VcadConnection:
    """TCP JSON-RPC 2.0 client for vcad Rust sidecar."""

    def __init__(self, host, port, timeout, agent):
        # Same structure as SketchupConnection

    def send_command(self, method: str, params: dict) -> dict:
        # JSON-RPC request/response

    def eval_code(self, code: str) -> dict:
        return self.send_command("vcad.eval_code", {"code": code})

    def eval_file(self, path: str) -> dict:
        return self.send_command("vcad.eval_file", {"path": path})

    def inspect(self, code_or_path: str) -> dict:
        return self.send_command("vcad.inspect", {"code_or_path": code_or_path})

def get_vcad_connection(agent: str = "unknown") -> VcadConnection:
    """Singleton factory — mirrors get_sketchup_connection."""

Transport behavior must mirror SketchupConnection:

  • send_command("vcad.eval_code", ...) is encoded as JSON-RPC tools/call with {"name": "vcad.eval_code", "arguments": {...}}
  • hello, ping, and resources/list remain direct JSON-RPC methods
  • hello must include workspace and (when configured) token for sidecar auth/path policy

Protocol negotiation requirements:

  • driver sends protocol_version in hello and validates sidecar response before first tool call
  • on major mismatch, fail startup path with PROTOCOL_MISMATCH (hard fail)
  • do not auto-downgrade/upgrade protocol version; no compatibility fallback path
  • driver stores negotiated capabilities and limits in connection state
  • if any operation requires a missing capability, fail-fast with CAPABILITY_UNAVAILABLE
  • CAPABILITY_UNAVAILABLE details are required: required_capability, negotiated_capabilities, operation
  • no graceful degrade/silent fallback for vcad operations when capability negotiation fails

Connection env vars: VCAD_HOST (default localhost), VCAD_PORT (default 9877), VCAD_TIMEOUT (default 30.0 — larger than SU due to evaluation time), VCAD_AUTH_TOKEN (optional; required if sidecar auth enabled), VCAD_STATE_PATH (optional persisted state path; default <workspace>/.supex/vcad-state.json). Related sidecar/runtime env vars: VCAD_MAX_QUEUE (default 64), VCAD_EVAL_TIMEOUT_MS (default 120000), VCAD_ADT_CACHE_MAX (default 256), VCAD_ALLOW_REMOTE (default 0), VCAD_TEMP_TTL_SEC (default 3600), VCAD_TEMP_MAX_FILES (default 500), VCAD_TRIGGER_COALESCE_MS (default 150).

Retry, reconnect, idle timeout — same as SketchupConnection.

Driver error mapping policy:

  • preserve sidecar/ruby error_code unchanged in MCP tool responses
  • only transport-level failures (connection/protocol/timeout before JSON-RPC response) may use driver-local error codes

Result freshness guard (stale-result protection):

  • Driver owns per-node monotonic revision counters (incremented on each trigger before eval starts).
  • Every eval/update task carries {node_id, revision} metadata through sidecar request, SketchUp update call, and viewer relay publish.
  • Driver applies geometry only when revision == current_revision(node_id).
  • If an older result arrives late, driver drops it (stale_dropped += 1) and does not mutate SketchUp or viewer state.
  • Prevents rollback/flicker when rapid edits enqueue overlapping evaluations.

Supersede-aware queue pruning (same-node pending optimization):

  • When a new eval job for node_id is enqueued, older pending (not-yet-started) jobs for the same node_id are marked superseded.
  • Eval worker checks supersede status immediately before starting compute; superseded jobs are skipped without evaluation.
  • Running evaluation is never cancelled; stale-result guard still protects apply phase.
  • No new client-facing error code is introduced; this is internal scheduling optimization only.

Trigger coalescing window (multi-source burst merging):

  • Driver merges rapid change events from fs-watch, mod-track, su-observer, and manual updates into one pending node set.
  • Coalescing window is controlled by VCAD_TRIGGER_COALESCE_MS (default 150).
  • Exactly one topological cascade is scheduled per coalesced batch.
  • Events arriving during an active cascade are queued for the next batch; no parallel cascades.

Persistent runtime state (restart recovery baseline):

  • Driver persists workspace-local vcad runtime state in <workspace>/.supex/vcad-state.json (override via VCAD_STATE_PATH).
  • Persist per node: {node_id, source_file, revision, applied_revision, last_entity_id, status}.
  • status values: active (normal), degraded (recoverable inconsistency), orphan (present in SU but not tracked by source).
  • Writes are atomic (temp file + rename) to avoid partial state on crash.

Startup recovery + reconciliation flow:

  1. Load persisted state file (if present).
  2. Call SketchUp bridge list_vcad_nodes to obtain authoritative in-model nodes.
  3. Reconstruct in-memory DAG + revision counters from persisted state.
  4. Compare persisted/runtime snapshots and classify drift:
    • missing_node: persisted node absent in SketchUp
    • orphan_definition: SketchUp vcad definition absent in persisted state
    • revision_gap: applied_revision behind actual update intent
    • source_missing: source file missing/unreadable
  5. Reconcile policy:
    • no drift: keep graph, continue normal operation
    • drift on known source files: run targeted vcad_update_cascade for affected roots
    • source missing: mark node degraded, return SOURCE_FILE_MISSING, skip destructive overwrite
    • unrecoverable drift set: return STATE_RECONCILE_REQUIRED with details.drift

Sidecar restart behavior:

  • Driver treats sidecar restart as cache-loss event (ADT/import caches invalid).
  • On reconnect, rerun reconciliation for nodes with dependencies and rebuild sidecar caches by cascading in topological order.
  • During reconciliation, apply stale-result guard so late pre-restart responses cannot overwrite recovered state.

Sidecar lifecycle (driver/src/supex_driver/connection/vcad_sidecar.py)

class VcadSidecar:
    """Manages vcad sidecar Rust binary process lifecycle."""

    def __init__(self, sidecar_path: str | None = None):
        self.sidecar_path = sidecar_path or os.environ.get("VCAD_SIDECAR_PATH")
        self.process: subprocess.Popen | None = None

    def ensure_running(self) -> None:
        """Start sidecar if not running. Check health via ping."""

    def stop(self) -> None:
        """Graceful shutdown (SIGTERM)."""

Auto-start: get_vcad_connection() calls VcadSidecar.ensure_running() before the first connection. Sidecar binary path defaults to <supex_root>/vcad/sidecar/target/release/supex-vcad-sidecar.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/ -v -k vcad_connection

# stale-result guard check:
# - enqueue two eval/update requests for same node (slow old + fast new)
# - verify only new revision is applied; old result is dropped
# - verify stale_dropped metric > 0

# supersede pruning check:
# - enqueue multiple rapid pending updates for same node before worker starts
# - verify only newest pending revision is evaluated; older pending jobs are skipped
# - verify counters: superseded_dropped_total and superseded_skipped_before_eval_total

# trigger coalescing check:
# - emit rapid fs-watch/mod-track/su-observer events within coalesce window
# - verify one merged cascade run for union of affected nodes
# - verify events during active cascade are deferred to next batch (no parallel cascade)

# protocol handshake check:
# - simulate sidecar protocol major mismatch -> expect PROTOCOL_MISMATCH
# - simulate missing required capability -> expect CAPABILITY_UNAVAILABLE with details.required_capability, details.negotiated_capabilities, details.operation
# - positive path (SketchUp 2026-only config): negotiated capabilities + limits populated in connection state
# - verify mismatch path is fail-fast (no downgrade/fallback attempt)

# restart recovery check:
# - restart driver while SketchUp model stays open; reload persisted state and reconcile via list_vcad_nodes
# - verify DAG/revision state reconstructed without duplicate imports
# - remove a source file and verify node is marked degraded with SOURCE_FILE_MISSING

# sidecar restart under load:
# - restart sidecar during queued evals
# - verify driver reconnects, rebuilds cache via topological cascade, and avoids stale rollback

Phase viewer: Standalone vcad viewer — Tauri + R3F rendering ✅

Objective

Standalone native viewer application using Tauri with a slim React frontend. Renders triangle meshes with PBR materials. In this phase the viewer loads meshes from local files (OBJ or JSON) — no sidecar connection yet.

Rationale

@vcad/app is a full CAD editor (~80% editor-specific: FeatureTree, SketchToolbar, PropertyPanel, DrawingView, PrintPanel, CamPanel, AIPanel, auth, onboarding). Only the rendering pipeline (~15-20%) is relevant. Building a slim viewer from the same npm primitives is cleaner than stripping the editor.

Specification

App location

vcad/viewer/ — Tauri project with slim React + R3F frontend.

Project structure (vcad/viewer/)

vcad/viewer/
├── src-tauri/              # Rust backend
│   ├── Cargo.toml
│   ├── tauri.conf.json
│   └── src/
│       └── main.rs         # Tauri entry, IPC commands (load mesh from file)
├── src/                    # Frontend (~300-500 lines in this phase)
│   ├── main.tsx            # React entry
│   ├── App.tsx             # Top-level: Canvas + UI shell
│   ├── Viewport.tsx        # R3F Canvas, lights, post-processing
│   ├── SceneMesh.tsx       # Mesh rendering with PBR materials
│   ├── ViewerControls.tsx  # OrbitControls, grid, gizmo
│   └── store.ts            # Zustand: meshes, camera, selection
├── package.json
├── vite.config.ts
├── tailwind.config.ts
└── index.html

Dependencies

{
  "dependencies": {
    "@react-three/fiber": "^9",
    "@react-three/drei": "^10",
    "@react-three/postprocessing": "^3",
    "three": "^0.172",
    "react": "^19",
    "react-dom": "^19",
    "zustand": "^5"
  },
  "devDependencies": {
    "vitest": "^3",
    "@react-three/test-renderer": "^9",
    "happy-dom": "^17"
  }
}

No @vcad/app, no @vcad/auth, no @vcad/core. The viewer receives pre-computed meshes (positions, indices, normals) — it doesn't need the BRep engine.

Viewer responsibilities

The viewer is a dumb mesh renderer:

  • Loads triangle meshes from local files (OBJ or JSON with positions/indices/normals)
  • Renders with PBR materials (color, metallic, roughness)
  • OrbitControls for camera, grid plane, ambient occlusion
  • Selection: click mesh -> highlight
  • Tauri IPC: load_mesh(path) command from Rust backend loads OBJ and pushes to frontend

The evaluation stays in the Rust sidecar. The viewer never evaluates Loon or creates Solids.

Tauri Rust backend

#[tauri::command]
fn load_mesh(path: String) -> Result<MeshData, String> {
    // Read OBJ file, parse into positions/indices/normals
    // Return to frontend via IPC
}

This enables manual testing: build a mesh with the sidecar, then open the OBJ in the viewer.

Viewer launch

  • Via CLI: ./scripts/launch-vcad-viewer.sh
  • Dev mode: cd vcad/viewer && npm run tauri dev

Headless tests (vcad/viewer/src/__tests__/)

Two testing layers, both run in Node via vitest (no browser, no display server needed):

Store + logic tests (vitest + happy-dom):

  • store.test.ts — Zustand store: add/remove/update meshes, camera state, selection state
  • mesh-parsing.test.ts — OBJ parse logic, positions/indices/normals extraction

Scene graph tests (vitest + @react-three/test-renderer):

  • SceneMesh.test.tsx — mesh component receives positions/indices/normals, creates correct BufferGeometry attributes
  • Viewport.test.tsx — scene contains expected number of meshes after store updates, material properties applied

The test renderer creates a reconciled R3F scene graph without WebGL — tests verify structure and props, not pixel output. Visual verification remains manual (npm run tauri dev).

Verify

cd /Users/darwin/x/p/supex-ws/supex/vcad/viewer && npx vitest run

Manual visual check (not run by plan-runner): cd vcad/viewer && npm run tauri dev


Phase viewer-live: Viewer — driver relay WebSocket + MCP tools ✅

Objective

Connect the viewer to the Python driver via WebSocket relay for live mesh push. The driver bridges sidecar evaluation outputs to the viewer and exposes viewer state/screenshot via MCP tools.

Specification

Prerequisites

  • Phase viewer (Tauri viewer with R3F rendering)
  • Phase vcad-conn (driver ↔ sidecar connection)

Driver relay WebSocket endpoint

New WebSocket server in the driver (alongside MCP server), on configurable port (default :9878).

Security model: loopback-only (127.0.0.1). No authentication token required — the relay carries pre-computed mesh data (not source code or credentials) and is not exposed beyond the local machine. If VCAD_VIEWER_RELAY_ALLOW_REMOTE=1 is set in the future, authentication must be added before that is allowed.

Driver -> Viewer:

  • mesh.update {node_id, revision, positions, indices, normals, material, bbox} — push mesh after evaluation
  • mesh.remove {node_id} — remove mesh
  • scene.snapshot {nodes: [...]} — full latest-state snapshot after viewer reconnect
  • scene.reset — clear all meshes

Viewer -> Driver:

  • viewer.state {camera, selection} — periodic or on-change
  • viewer.ready {protocol_version, features} — connection established + capability advertisement

Driver -> Sidecar transport (unchanged):

  • TCP JSON-RPC (tools/call) for vcad.eval_*, vcad.inspect, etc.

No WebSocket endpoint is required in the sidecar in this phase.

Viewer relay compatibility rules:

  • driver validates viewer.ready.protocol_version before sending mesh traffic
  • major mismatch => reject viewer data-plane session and return PROTOCOL_MISMATCH for viewer-dependent tools
  • driver enables only negotiated viewer features; unsupported requests fail-fast with CAPABILITY_UNAVAILABLE
  • viewer capability failures must include details.required_capability, details.negotiated_capabilities, and details.operation
  • after successful viewer.ready, driver sends exactly one scene.snapshot containing only latest revision per node
  • driver does not replay historical mesh events on reconnect (snapshot replaces event backlog replay)

Frontend addition (vcad/viewer/src/DriverRelayClient.ts)

WebSocket client that connects to the driver relay, receives mesh push messages, and updates Zustand store. Replaces file-based loading from Phase viewer as the primary mesh source.

vcad/viewer/src/
  ...existing files...
  DriverRelayClient.ts    # WebSocket client to driver relay (NEW)

Driver relay APIs (for MCP tools)

Driver maintains latest viewer state and can request screenshots over the relay channel. MCP tools read these values from driver-owned state (no sidecar viewer RPC needed).

  • Viewer snapshot source is driver-applied mesh state (applied_revision map), including data restored by startup reconciliation.

Freshness rule on relay publishes:

  • Driver publishes mesh.update only for the current per-node revision.
  • Viewer keeps latest_revision_by_node and ignores any message with lower revision.
  • This ensures no stale render rollback during overlapping evaluations.

Screenshot output model (file-first, no large inline payloads):

  • Viewer sends PNG bytes to driver over relay.
  • Driver stores screenshot under workspace temp (default: <workspace>/.tmp/vcad-viewer/shot-<timestamp>.png).
  • vcad_viewer_screenshot returns JSON metadata with path, width, height, bytes, and created_at.
  • Optional inline preview is allowed only for very small payloads (inline_base64 max 128 KB).

Path policy:

  • screenshot files must be written inside workspace-scoped allowed paths.
  • invalid output path requests return PATH_NOT_ALLOWED.
  • oversize inline requests return PAYLOAD_TOO_LARGE.

MCP tools (agent-facing)

@mcp.tool()
def vcad_viewer_state(ctx: McpContext) -> str:
    """Get current vcad viewer state: camera position, selection, visible nodes."""

@mcp.tool()
def vcad_viewer_screenshot(ctx: McpContext) -> str:
    """Capture screenshot from vcad viewer.
    Returns metadata including workspace-relative file path (no large base64 payload)."""

@mcp.tool()
def vcad_viewer_focus(ctx: McpContext, node_id: str) -> str:
    """Focus viewer camera on a specific vcad node."""

Live integration

After this phase, the full loop works:

  1. Agent calls vcad_place → driver requests sidecar evaluation → driver pushes mesh to viewer via relay WebSocket
  2. Agent calls vcad_viewer_screenshot → sees the rendered result
  3. Agent calls vcad_viewer_state → reads camera/selection for context

Headless tests (vcad/viewer/src/__tests__/)

Extends Phase viewer test suite with relay client tests (vitest + happy-dom, no browser needed):

  • DriverRelayClient.test.ts — WebSocket client: connect, receive mesh.update/mesh.remove/scene.snapshot/scene.reset, send viewer.ready/viewer.state; uses a lightweight in-process WS server stub
  • relay-freshness.test.ts — revision guard: out-of-order mesh.update with lower revision is ignored, only latest revision reflected in store
  • relay-reconnect.test.ts — disconnect + reconnect: client sends viewer.ready, receives scene.snapshot, store rebuilt from snapshot

Verify

cd /Users/darwin/x/p/supex-ws/supex/vcad/viewer && npx vitest run

# responsiveness check:
# 1) start a long vcad eval (complex boolean model)
# 2) in parallel, call check_sketchup_status / vcad ping and confirm health response is immediate
# 3) confirm viewer relay still receives state/screenshot messages during/after eval

# screenshot payload check:
# - vcad_viewer_screenshot returns `path` and file metadata
# - returned file exists on disk under workspace `.tmp/vcad-viewer/`
# - MCP response remains small (no large inline base64 blob)

# relay protocol/capability check:
# - viewer.ready major mismatch -> vcad_viewer_* returns PROTOCOL_MISMATCH
# - missing viewer feature (e.g. screenshot) -> CAPABILITY_UNAVAILABLE with required_capability, negotiated_capabilities, and operation details
# - matching protocol/features in SketchUp 2026-only stack -> mesh push + screenshot/state tools operate normally
# - mismatch must not enter control/data fallback mode; fail-fast error path only

# reconnect snapshot check:
# - run several updates, disconnect viewer, reconnect viewer
# - first message after viewer.ready is scene.snapshot with latest revision per node
# - verify no historical replay duplicates and rendered state matches driver applied_revision map

Manual visual + live relay check (not run by plan-runner): cd vcad/viewer && npm run tauri dev


Phase ruby-tools: Ruby runtime — vcad node tools ✅

Objective

Add tools to the supex Ruby runtime for importing OBJ meshes into SketchUp as vcad components with attributes.

Specification

Prerequisites

  • Existing runtime code: bridge_server.rb, tools.rb
  • This phase should be completed before Phase mcp-tools and before final su-mock integration verification

SketchUp API policy for this phase:

  • target SketchUp 2026 only
  • use definitions.import(obj_path) return value directly (Sketchup::ComponentDefinition)
  • do not implement legacy fallback via definitions.to_a.last

vcad_tools.rb (runtime/src/supex_runtime/vcad_tools.rb)

module SupexRuntime
  module VcadTools
    extend self

    VCAD_DICT = 'vcad'.freeze

    # Import OBJ as ComponentDefinition, set vcad attributes, place instance
    def place_vcad_node(params, workspace: nil)
      obj_path = params['obj_path']
      node_id = params['node_id']
      source_file = params['source_file']
      component_name = params['component_name'] || "vcad_#{node_id}"
      position = params['position'] || [0, 0, 0]

      # Path policy (workspace-scoped)
      PathPolicy.validate!(obj_path, operation: 'vcad import', workspace: workspace)
      PathPolicy.validate!(source_file, operation: 'vcad source', workspace: workspace) if source_file

      model = Sketchup.active_model
      model.start_operation('Place vcad node', true)

      # SketchUp 2026: definitions.import returns ComponentDefinition.
      defn = model.definitions.import(obj_path)
      unless defn.is_a?(Sketchup::ComponentDefinition)
        raise "IMPORT_DEFINITION_NOT_FOUND: #{obj_path}"
      end
      defn.name = component_name

      # Set vcad metadata
      defn.set_attribute(VCAD_DICT, 'node_id', node_id)
      defn.set_attribute(VCAD_DICT, 'source_file', source_file)
      defn.set_attribute(VCAD_DICT, 'version', 1)

      # Place instance
      pt = Geom::Point3d.new(
        position[0].to_f.mm,
        position[1].to_f.mm,
        position[2].to_f.mm
      )
      tr = Geom::Transformation.new(pt)
      instance = model.active_entities.add_instance(defn, tr)

      model.commit_operation

      {
        success: true,
        node_id: node_id,
        entity_id: instance.entityID,
        definition_name: defn.name
      }
    end

    # Re-import OBJ using atomic definition swap + rollback on failure.
    # Never mutate the existing definition in place.
    def update_vcad_node(params, workspace: nil)
      obj_path = params['obj_path']
      node_id = params['node_id']
      source_file = params['source_file']

      # Path policy (workspace-scoped)
      PathPolicy.validate!(obj_path, operation: 'vcad import', workspace: workspace)
      PathPolicy.validate!(source_file, operation: 'vcad source', workspace: workspace) if source_file

      model = Sketchup.active_model
      old_defn = find_vcad_definition(model, node_id)
      raise "vcad node not found: #{node_id}" unless old_defn

      model.start_operation('Update vcad node', true)

      begin
        # Capture current placements so we can rebind all instances to a new definition.
        placements = old_defn.instances.map do |inst|
          {
            parent_entities: inst.parent.entities,
            transformation: inst.transformation,
            layer: inst.respond_to?(:layer) ? inst.layer : nil,
            material: inst.respond_to?(:material) ? inst.material : nil,
            name: inst.respond_to?(:name) ? inst.name : nil
          }
        end

        old_name = old_defn.name
        version = old_defn.get_attribute(VCAD_DICT, 'version', 0).to_i + 1

        # SketchUp 2026: import returns the new ComponentDefinition directly.
        new_defn = model.definitions.import(obj_path)
        unless new_defn.is_a?(Sketchup::ComponentDefinition)
          raise "IMPORT_DEFINITION_NOT_FOUND: #{obj_path}"
        end

        # Keep the new definition hidden from UI naming collisions until swap is complete.
        new_defn.name = "#{old_name}__updating"
        new_defn.set_attribute(VCAD_DICT, 'node_id', node_id)
        new_defn.set_attribute(VCAD_DICT, 'source_file', source_file || old_defn.get_attribute(VCAD_DICT, 'source_file'))
        new_defn.set_attribute(VCAD_DICT, 'version', version)

        # Rebind every instance to the new definition at the same transform.
        old_instances = old_defn.instances.to_a
        placements.each_with_index do |placement, idx|
          replacement = placement[:parent_entities].add_instance(new_defn, placement[:transformation])
          replacement.layer = placement[:layer] if placement[:layer]
          replacement.material = placement[:material] if placement[:material]
          replacement.name = placement[:name] if placement[:name] && replacement.respond_to?(:name=)
          old_instances[idx]&.erase!
        end

        # Finalize naming and cleanup old definition when no instances remain.
        old_defn.name = "#{old_name}__old"
        new_defn.name = old_name
        model.definitions.remove(old_defn) if old_defn.instances.empty?

        model.commit_operation
        {
          success: true,
          node_id: node_id,
          version: version,
          definition_name: new_defn.name,
          replaced_instances: placements.length
        }
      rescue StandardError => e
        model.abort_operation
        raise "Update vcad node failed: #{e.message}"
      end
    end

    # List all vcad nodes
    def list_vcad_nodes(_params = {}, workspace: nil)
      model = Sketchup.active_model
      nodes = model.definitions.select { |d| d.get_attribute(VCAD_DICT, 'node_id') }
      nodes.map do |d|
        {
          node_id: d.get_attribute(VCAD_DICT, 'node_id'),
          source_file: d.get_attribute(VCAD_DICT, 'source_file'),
          version: d.get_attribute(VCAD_DICT, 'version'),
          name: d.name,
          instances: d.instances.length
        }
      end
    end

    # Get single vcad node metadata
    def get_vcad_node(params, workspace: nil)
      node_id = params['node_id']
      model = Sketchup.active_model
      defn = find_vcad_definition(model, node_id)
      raise "vcad node not found: #{node_id}" unless defn

      {
        node_id: node_id,
        source_file: defn.get_attribute(VCAD_DICT, 'source_file'),
        version: defn.get_attribute(VCAD_DICT, 'version'),
        name: defn.name,
        instances: defn.instances.length,
        bounds: bounds_to_hash(defn.bounds)
      }
    end

    private

    def find_vcad_definition(model, node_id)
      model.definitions.find { |d| d.get_attribute(VCAD_DICT, 'node_id') == node_id }
    end

    def bounds_to_hash(bb)
      {
        min: [bb.min.x.to_mm, bb.min.y.to_mm, bb.min.z.to_mm],
        max: [bb.max.x.to_mm, bb.max.y.to_mm, bb.max.z.to_mm]
      }
    end
  end
end

Error mapping requirement:

  • catch PathPolicy::PathAccessDenied and return structured error code PATH_NOT_ALLOWED
  • include operation context (vcad import, vcad source) in error details
  • map import resolution failures to IMPORT_DEFINITION_NOT_FOUND
  • keep error_code stable so driver can pass it through unchanged

Bridge server wiring

In runtime/src/supex_runtime/bridge_server.rb add to tool dispatch:

def execute_vcad_tool(name, params, workspace: nil)
  case name
  when 'place_vcad_node'
    VcadTools.place_vcad_node(params, workspace: workspace)
  when 'update_vcad_node'
    VcadTools.update_vcad_node(params, workspace: workspace)
  when 'list_vcad_nodes'
    VcadTools.list_vcad_nodes(params, workspace: workspace)
  when 'get_vcad_node'
    VcadTools.get_vcad_node(params, workspace: workspace)
  end
end

In execute_tool add fallback to execute_vcad_tool for vcad_* tools.

main.rb

Add require_relative 'vcad_tools'.

Verify

cd /Users/darwin/x/p/supex-ws/supex/runtime && bundle exec rake test
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/ -v -k vcad_ruby_mock

# import semantics check (SketchUp 2026 target):
# - `definitions.import` return value is used as the new definition
# - no fallback to `definitions.to_a.last`
# path policy check:
# - obj/source path outside workspace returns PATH_NOT_ALLOWED

Phase mcp-tools: supex driver — MCP vcad tools ✅

Objective

Add MCP tools for orchestrating vcad evaluation and placement into SketchUp. Uses the vcad connection from Phase vcad-conn.

Specification

Prerequisites

  • Phase vcad-conn (VcadConnection + sidecar lifecycle)
  • Phase ruby-tools (Ruby runtime vcad tools + bridge dispatch)

MCP vcad tools (driver/src/supex_driver/mcp/vcad_tools.py)

New module with MCP tool implementations. Follow existing server.py style:

  • import shared mcp + McpContext from supex_driver.mcp.server
  • declare tools as module-level synchronous def functions with @mcp.tool()
  • first argument is always ctx: McpContext
@mcp.tool()
def vcad_place(
    ctx: McpContext,
    node_id: str,
    source_file: str,
    position: list[float] | None = None,
    component_name: str | None = None,
) -> str:
    """Evaluate a .cmp.oo file and place the resulting mesh in SketchUp.

    1. Send source file to vcad sidecar -> eval -> OBJ file
    2. Send OBJ path to SketchUp -> definitions.import -> ComponentDefinition
    3. Store vcad metadata in attribute dictionary
    """

@mcp.tool()
def vcad_update(ctx: McpContext, node_id: str, source_file: str | None = None) -> str:
    """Re-evaluate vcad node and update SketchUp geometry."""

@mcp.tool()
def vcad_inspect(ctx: McpContext, source: str) -> str:
    """Inspect vcad geometry: volume, surface area, bounding box.
    Source can be a .cmp.oo file path or inline Loon code."""

@mcp.tool()
def vcad_export(ctx: McpContext, source: str, format: str = "obj", output_path: str = "") -> str:
    """Export vcad geometry to OBJ or STEP."""

@mcp.tool()
def vcad_eval(ctx: McpContext, code: str) -> str:
    """Evaluate Loon code in vcad sidecar (REPL mode). Returns display string."""

@mcp.tool()
def vcad_list_nodes(ctx: McpContext) -> str:
    """List all vcad nodes in the current SketchUp model."""

MCP response contract:

  • for successful operations, return normal result payload
  • for failures from sidecar/ruby, preserve error_code and details fields unchanged
  • avoid replacing upstream error_code with generic error_type
  • when negotiation fails, use deterministic codes: PROTOCOL_MISMATCH or CAPABILITY_UNAVAILABLE
  • for CAPABILITY_UNAVAILABLE, always include details.required_capability, details.negotiated_capabilities, details.operation
  • do not auto-retry alternative code paths after capability mismatch; return error immediately
  • driver must not rewrite upstream error_code; it may only enrich missing details.operation

server.py modification

Import vcad tools module for side-effect registration (module-level @mcp.tool()):

from . import vcad_tools  # registers vcad tools on shared mcp instance

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/ -v -k vcad_tools

# negotiation error propagation check:
# - sidecar major mismatch surfaces as PROTOCOL_MISMATCH in MCP tool response
# - missing capability surfaces as CAPABILITY_UNAVAILABLE with required_capability/negotiated_capabilities/operation details
# - verify tool returns error directly (no fallback branch)

Phase su-mock: SketchUp API mock — headless Ruby test server ✅

Objective

Standalone Ruby process with comprehensive SketchUp API mocks that loads the real supex runtime code (bridge_server.rb, tools.rb, vcad_tools.rb) and runs the TCP bridge server. Python integration tests connect to it over TCP exactly like they connect to real SketchUp. Enables CI-friendly testing of vcad Ruby tools without launching SketchUp.

Specification

Prerequisites

  • Phase ruby-tools (su-mock loads real runtime code including vcad_tools.rb)
  • Existing runtime code: bridge_server.rb, tools.rb

Motivation

vcad_tools.rb exercises SketchUp API surface far beyond what the existing thin test mocks (runtime/test/helpers/sketchup_mocks.rb, 635 lines) support — definitions.import(), attribute dictionaries, Geom::Transformation, Numeric#mm, entities.clear!, manifold?, Face#mesh(). Testing these tools requires either real SketchUp (expensive, slow, not CI-friendly) or comprehensive mocks. The su-mock provides the latter as a standalone TCP server.

Directory structure

mock/
  su-mock.rb                  # Launcher: load mocks, patch event loop, start bridge
  sketchup_api/
    core.rb                   # Sketchup module singleton + loads all sub-modules
    model.rb                  # MockModel (definitions, active_entities, find_entity_by_id)
    entities.rb               # MockEntities (add_instance, clear!, grep, manifold?)
    definition_list.rb        # MockDefinitionList (import(obj_path), Enumerable)
    component_definition.rb   # Enhanced (entities, attributes, name=, bounds)
    component_instance.rb     # Enhanced (transformation, definition, entityID)
    attribute_dictionary.rb   # set_attribute/get_attribute mixin for all entities
    transformation.rb         # Geom::Transformation (from point, from array, to_a, origin)
    face.rb                   # MockFace with mesh() -> MockPolygonMesh
    polygon_mesh.rb           # MockPolygonMesh (1-based: point_at, normal_at, polygon_at)
    units.rb                  # Numeric#mm, Numeric#to_mm (SketchUp unit conversion)
    geometry.rb               # Geom::Point3d, Vector3d, BoundingBox
    ui.rb                     # UI module with blocking loop mode for headless operation
    entity_registry.rb        # Auto-incrementing entity IDs + global entity lookup
    camera.rb                 # MockCamera, MockView
    selection.rb              # MockSelection
    materials.rb              # MockMaterials, MockMaterial, MockColor
    layers.rb                 # MockLayers, MockLayer
  test_control.rb             # _test.* JSON-RPC handlers for Python test setup/teardown
  test/
    test_mock_api.rb          # Self-tests verifying mock API completeness

Connection model

Same as production: each TCP connection handles hello + one tool call, then closes. Tests reconnect per command. This ensures tests verify the full protocol path.

Event loop replacement

The bridge_server.rb uses UI.start_timer(interval, true) { handle_requests }. In the mock, UI.enable_blocking_mode! switches to a direct blocking loop (no SketchUp UI thread to cooperate with):

# In su-mock.rb:
UI.enable_blocking_mode!
server = SupexRuntime::BridgeServer.new(port: port)
server.start          # calls UI.start_timer -> stores block
UI.run_blocking_loop! # loop { block.call; sleep(interval) }

Test control API

Special _test.* tool names dispatched before normal tools in the mock's patched execute_tool:

Method Purpose
_test.reset Reset all model state to clean
_test.add_definition Create ComponentDefinition with attributes
_test.add_instance Place instance of a definition
_test.add_entity Add entity (Face, Edge, Group) with properties
_test.set_attribute Set attribute dict entry on entity by ID
_test.get_entity Return entity state by ID (for assertions)
_test.set_manifold Configure manifold? return value
_test.get_state Full model state snapshot

Called via standard send_command("_test.reset") from Python — goes through the normal hello + tools/call TCP protocol.

OBJ import mock

definitions.import(obj_path) parses OBJ minimally (vertex positions + face indices), creates and returns a MockComponentDefinition with computed BoundingBox and stored mesh data. Does NOT model full SketchUp topology — just enough for SketchUp 2026 import semantics used by vcad tools.

Separate from existing test mocks

The su-mock builds its own mock hierarchy in mock/sketchup_api/ rather than extending runtime/test/helpers/sketchup_mocks.rb. The existing mocks use rand() for entity IDs, lack module organization, and are coupled to Ruby minitest patterns. The su-mock needs deterministic IDs, proper Sketchup:: namespace, and full vcad API surface. Existing test mocks remain untouched.

Mock capabilities by priority

P0 (core — vcad_tools.rb testing):

  • Entity ID auto-increment registry with global find_entity_by_id
  • MockDefinitionList with import(), to_a, select, find
  • Attribute dictionaries on all entities
  • MockEntities.add_instance(defn, tr) and clear!
  • Geom::Transformation.new(point), #to_a, #origin
  • Numeric#mm and #to_mm (SketchUp uses inches internally; vcad uses mm)
  • Enhanced MockComponentDefinition/Instance, MockModel

P1 (Phase native-mesh testing):

  • MockFace#mesh(flags) -> MockPolygonMesh (1-based indexing)
  • MockEntities#manifold? (configurable via test control)

Python test fixture

# driver/tests/conftest.py (or fixtures/su_mock.py)
@pytest.fixture(scope="session")
def su_mock_process():
    """Start su-mock Ruby process once per test session."""
    # subprocess.Popen(['ruby', 'mock/su-mock.rb'])
    # Wait for TCP ready, yield, terminate

@pytest.fixture
def su_mock(su_mock_process):
    """Per-test connection with clean state."""
    conn = SketchupConnection(port=su_mock_port)
    conn.send_command("_test.reset")
    yield conn

Launcher script

scripts/launch-su-mock.sh — starts the mock Ruby process with configurable port. Used by pytest fixtures and manual testing.

Verify

cd /Users/darwin/x/p/supex-ws/supex/mock && ruby test/test_mock_api.rb

Phase e2e: E2E integration + sidecar management ✅

Objective

Wire all layers together — sidecar lifecycle management, full E2E pipeline from .cmp.oo file to component in SketchUp. Add launch scripts and configuration.

Specification

Sidecar build + launch script (scripts/launch-vcad-sidecar.sh)

#!/usr/bin/env bash
# Launch vcad Rust sidecar for development
set -e

# Resolve symlinks (macOS-compatible, no readlink -f)
SCRIPT_PATH="${BASH_SOURCE[0]}"
while [ -L "$SCRIPT_PATH" ]; do
  SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
  SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
  [[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH"
done
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
SIDECAR_DIR="$(cd "$SCRIPT_DIR/../vcad/sidecar" && pwd)"

# Build if needed
if [ ! -f "$SIDECAR_DIR/target/release/supex-vcad-sidecar" ]; then
    echo "Building vcad sidecar..." >&2
    cargo build --release --manifest-path "$SIDECAR_DIR/Cargo.toml"
fi

exec "$SIDECAR_DIR/target/release/supex-vcad-sidecar" "$@"

Script policy: any new launch wrapper in this project should reuse the same symlink-safe boilerplate as mcp/supex (avoid readlink -f).

vcad wrapper script (vcad-sidecar executable at repo root)

Similar to existing mcp and supex wrappers. Starts sidecar with logging.

Configuration

Document env vars in README section:

  • VCAD_HOST — sidecar host (default: 127.0.0.1)
  • VCAD_PORT — sidecar port (default: 9877)
  • VCAD_TIMEOUT — request timeout (default: 30s)
  • VCAD_AUTH_TOKEN — sidecar auth token (required when auth enabled)
  • VCAD_ALLOW_REMOTE — allow non-loopback bind (default: 0; requires VCAD_AUTH_TOKEN)
  • VCAD_MAX_QUEUE — maximum queued eval jobs in sidecar (default: 64)
  • VCAD_EVAL_TIMEOUT_MS — max wait per queued eval request (default: 120000)
  • VCAD_TEMP_TTL_SEC — max age of sidecar temp OBJ + manifest files in seconds (default: 3600)
  • VCAD_TEMP_MAX_FILES — max number of retained artifact sets (OBJ + manifest) (default: 500)
  • VCAD_ADT_CACHE_MAX — max entries in sidecar ADT cache, LRU eviction (default: 256)
  • VCAD_SIDECAR_PATH — path to sidecar binary (default: auto-detect from repo)
  • VCAD_TEMP_DIR — temp directory for OBJ files (default: system temp)
  • VCAD_STATE_PATH — persisted driver state file (default: <workspace>/.supex/vcad-state.json)

E2E test flow

  1. Build sidecar: cargo build --release --manifest-path vcad/sidecar/Cargo.toml
  2. Start sidecar: ./vcad-sidecar
  3. Start SketchUp with supex runtime
  4. Via MCP or CLI:
    • Create a .cmp.oo file with a simple solid
    • Call vcad_place("test-bracket", "bracket.cmp.oo")
    • Verify the component appears in SketchUp
    • Call vcad_list_nodes() — must return "test-bracket"
    • Modify the .cmp.oo file
    • Call vcad_update("test-bracket")
    • Verify the geometry was updated

Integration test (tests/e2e/test_vcad_pipeline.py)

def test_vcad_eval_code():
    """Test that sidecar evaluates Loon and returns OBJ path."""
    conn = get_vcad_connection()
    result = conn.eval_code("[cube 10.0 20.0 30.0]")
    assert "obj_path" in result
    assert os.path.exists(result["obj_path"])
    assert result["volume"] > 0

def test_vcad_eval_file(tmp_path):
    """Test eval_file with a .cmp.oo file."""
    loon_file = tmp_path / "test.cmp.oo"
    loon_file.write_text("[pipe [cube 50.0 10.0 30.0] [fillet 2.0]]")
    result = conn.eval_file(str(loon_file))
    assert result["volume"] > 0

def test_vcad_place_in_sketchup():
    """Test full pipeline: eval -> OBJ -> SketchUp import."""
    # Requires running SketchUp + sidecar
    result = vcad_place("e2e-test", "test.cmp.oo", position=[0, 0, 0])
    assert result["success"]
    nodes = vcad_list_nodes()
    assert any(n["node_id"] == "e2e-test" for n in nodes)

def test_vcad_eval_file_rejects_path_escape():
    """Path policy rejects paths outside workspace."""
    conn = get_vcad_connection()
    result = conn.eval_file("../outside/test.cmp.oo")
    assert result.get("error_code") == "PATH_NOT_ALLOWED"

Documentation

Add to docs/vcad.md:

  • Architecture overview
  • How to write .cmp.oo files (Loon CAD API reference)
  • Loon language quick reference (ADT, pipe, use)
  • MCP tools reference
  • Troubleshooting

Verify

# Thorough integration tests via su-mock (CI-friendly)
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/ -v -k vcad_e2e_mock

# Security checks:
# - start sidecar with non-loopback host and no VCAD_AUTH_TOKEN -> startup must fail
# - verify token mismatch on hello returns AUTH_INVALID
# - verify path traversal attempts return PATH_NOT_ALLOWED
# - assert standardized `error_code` values in responses

# restart/recovery checks:
# - restart driver only (SketchUp still running) and verify state reconcile restores DAG/revisions
# - restart sidecar during active queue and verify deterministic recovered state (no duplicate imports)
# - reconnect viewer after burst of updates and verify scene snapshot matches latest revisions

# launch script portability:
# - run scripts/launch-vcad-sidecar.sh directly and via symlink
# - verify both resolve SIDECAR_DIR correctly on macOS (no readlink -f dependency)

Phase data-imports: Import system — data extracts

Objective

vcad nodes can import data references from SketchUp entities. This phase covers data extracts (:dimensions, :bbox, :transform) from any entity. Establishes the import resolution protocol between sidecar, driver, and Ruby bridge. No ADT cache involved — data extracts are simple Loon map bindings.

Execution notes for plan-runner success:

  • Keep this phase focused on data extract imports only (:solid stays in Phase adt-imports).
  • Reuse existing driver scaffolding in driver/src/supex_driver/connection/vcad_state.py only as needed for revision safety; full DAG orchestration remains in Phase dag.
  • Update sidecar hello.capabilities so advertised import capabilities match implemented JSON-RPC methods.

Specification

Import model

Extract type Any SketchUp entity
:dimensions SketchUp bounds → Loon map {:width :height :depth}
:bbox SketchUp bounds → Loon map {:min [...] :max [...]}
:transform Instance transformation → Loon map {:matrix [...]}

Import declaration in .cmp.oo

; Import dimensions from any SketchUp component (vcad-backed or not)
[let host-dims [import :dimensions "entity:12345"]]
; host-dims = {:width 100.0 :height 200.0 :depth 50.0}

[pipe [cube [get host-dims :width] 10.0 [get host-dims :height]]
  [fillet 2.0]]

import is not a native Loon keyword. In supex, it is a reserved sidecar-recognized call pattern resolved before evaluation.

Supported import syntax in this phase:

  • [let <binding> [import <extract-keyword> "entity:<id>"]]

Validation rules:

  • <extract-keyword> must be one of :dimensions, :bbox, :transform
  • <entity-ref> must match entity:<integer-id>
  • any other import usage is rejected with IMPORT_FORM_INVALID

Resolution flow

  1. Driver reads the .cmp.oo source and calls vcad.extract_imports(source)
  2. Sidecar parses source via loon_lang::parser::parse(), walks the AST, and matches let bindings with [import ...] (no regex)
  3. Sidecar rewrites every matched import expression to an internal symbol (e.g. __vcad_import_0) and returns:
    • imports[]: {import_id, binding_name, extract, entity_ref, injected_symbol}
    • transformed_source: source with no raw [import ...] calls
  4. For each import, driver asks SketchUp bridge: resolve_vcad_import({entity_id, extract})
  5. Bridge resolves data extracts from SketchUp directly, returns {data: {...}, vcad_node_id: "base-plate" | null}
  6. Driver calls vcad.eval_with_imports(transformed_source, ...) with resolved import payload
  7. Sidecar prepends [let <injected_symbol> ...] bindings for resolved values and evaluates the transformed source
  8. vcad_node_id from any resolved import is recorded for DAG tracking (Phase dag)

Sidecar: import extraction + AST rewrite + data binding injection

impl Evaluator {
    /// Parse source, extract supported import forms, and rewrite them.
    /// Example rewrite:
    ///   [let host-dims [import :dimensions "entity:12345"]]
    /// becomes
    ///   [let host-dims __vcad_import_0]
    fn extract_and_rewrite_imports(&self, source: &str) -> Result<ExtractedImports, String> {
        let exprs = loon_lang::parser::parse(source)
            .map_err(|e| format!("Parse error: {:?}", e))?;

        let mut imports = Vec::new();
        let rewritten_exprs = rewrite_import_bindings(exprs, &mut imports)?;
        let transformed_source = render_exprs(&rewritten_exprs)?;

        Ok(ExtractedImports {
            imports,
            transformed_source,
        })
    }

    /// Evaluate transformed source with resolved imports injected as let-bindings.
    /// Raw [import ...] calls never reach the Loon interpreter.
    fn eval_with_data_imports(
        &mut self,
        transformed_source: &str,
        base_dir: Option<&Path>,
        imports: &HashMap<String, ResolvedDataImport>, // key: import_id
    ) -> Result<EvalResult, String> {
        // 1. Build preamble for injected symbols produced by extract_and_rewrite_imports
        let mut preamble = String::new();
        for (_import_id, import) in imports {
            preamble.push_str(&format_data_binding(&import.injected_symbol, &import.data));
        }
        let augmented_source = format!("{}\n{}", preamble, transformed_source);

        // 2. Evaluate augmented source via standard pipeline
        let doc = eval_vcad(&augmented_source, base_dir)?;
        self.evaluate_and_export(&doc, "import-eval")
    }
}

Sidecar JSON-RPC methods

vcad.extract_imports(source: string)               — parse, validate, and return {imports, transformed_source}
vcad.eval_with_imports(transformed_source: string, — evaluate transformed source with resolved imports injected
    base_dir: string | null,
    imports: {import_id: {extract, injected_symbol, data}})

Ruby resolve tool (runtime/src/supex_runtime/vcad_tools.rb)

def resolve_vcad_import(params, workspace: nil)
  entity_id = params['entity_id'].to_i
  extract_type = params['extract']

  entity = Sketchup.active_model.find_entity_by_id(entity_id)
  raise "Entity not found: #{entity_id}" unless entity

  defn = entity.respond_to?(:definition) ? entity.definition : nil
  vcad_node_id = defn&.get_attribute('vcad', 'node_id')

  case extract_type
  when 'dimensions'
    bb = entity.bounds
    { extract: 'dimensions', vcad_node_id: vcad_node_id,
      data: { width: bb.width.to_mm, height: bb.height.to_mm, depth: bb.depth.to_mm } }
  when 'bbox'
    bb = entity.bounds
    { extract: 'bbox', vcad_node_id: vcad_node_id,
      data: { min: [bb.min.x.to_mm, bb.min.y.to_mm, bb.min.z.to_mm],
              max: [bb.max.x.to_mm, bb.max.y.to_mm, bb.max.z.to_mm] } }
  when 'transform'
    raise "Entity #{entity_id} has no transformation" unless entity.respond_to?(:transformation)
    { extract: 'transform', vcad_node_id: vcad_node_id,
      data: { matrix: entity.transformation.to_a } }
  when 'solid'
    raise "Solid import not supported in this phase — use Phase adt-imports"
  end
end

Every response includes vcad_node_id (or null) — the driver uses this for DAG dependency tracking.

MCP tool

@mcp.tool()
def vcad_place_with_imports(
    ctx: McpContext,
    node_id: str,
    source_file: str,
    position: list[float] | None = None,
) -> str:
    """Place vcad node with data references from SketchUp entities.
    Imports are declared inline in source using:
      [let <binding> [import :dimensions|:bbox|:transform "entity:<id>"]]
    and are resolved by sidecar+driver before evaluation."""

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_data_imports.py -v
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_data_imports_mock.py -v

Phase loon-patch: loon-lang patch — env-seeded eval + module-path tracking

Objective

Add two missing loon-lang capabilities required by supex integration:

  1. eval_program_with_env_and_base_dir(...) for evaluating with a pre-populated Env (required by Phase adt-imports solid ADT injection)
  2. loaded_module_paths() plus a module-tracking evaluation wrapper (required by Phase mod-track)

Maintained as a patch on the loon repo's supex-patches branch.

Specification

Patch scope (loon repo changes)

At least two files in /Users/darwin/x/p/supex-ws/loon:

1. crates/loon-lang/src/interp/mod.rs — env-seeded evaluation entrypoint:

Add a public API that evaluates with caller-provided environment:

pub fn eval_program_with_env_and_base_dir(
    exprs: &[Expr],
    env: &mut Env,
    base_dir: Option<&Path>,
) -> IResult {
    // same pipeline as eval_program_with_base_dir, but:
    // - reuses provided env (does not allocate a fresh one)
    // - uses provided base_dir for [use ...] module resolution
    // - preserves existing builtins and globals in env
}

2. crates/loon-lang/src/module.rs — expose loaded module paths:

Loon's ModuleCache internally tracks loaded modules in modules: HashMap<PathBuf, ModuleState> (private field). Current public API: new(), with_manifest(), with_manifest_and_lockfile(), manifest(), set_manifest(), lockfile(), resolve_path(), load_module(). There is NO method to retrieve loaded module paths. Additions are needed:

// Addition to loon-lang::module::ModuleCache
impl ModuleCache {
    /// Returns paths of all modules loaded during evaluation.
    pub fn loaded_module_paths(&self) -> Vec<PathBuf> {
        self.modules.keys().cloned().collect()
    }
}

// Addition to loon-lang::interp
/// Evaluate program and return both result and list of loaded module paths.
pub fn eval_program_with_module_tracking(
    exprs: &[Expr],
    base_dir: Option<&Path>,
) -> Result<(Value, Vec<PathBuf>), InterpError> {
    // ... existing eval logic, but extract loaded_module_paths() before dropping cache
}

Patch management

# Create patch branch in loon repo
cd /Users/darwin/x/p/supex-ws/loon
git checkout -b supex-patches
# ... apply changes ...
git commit -m "Add env-seeded eval API and expose loaded module paths"

# When loon updates, rebase our patch
git fetch origin
git rebase origin/main

After applying the patch, rebuild the supex sidecar (cargo build picks up the changed crate via path deps).

Verify

cd /Users/darwin/x/p/supex-ws/loon && cargo test -p loon-lang
# sidecar still compiles against patched loon APIs
cd /Users/darwin/x/p/supex-ws/supex/vcad/sidecar && cargo build

Phase vcad-patch: vcad cad-lib patch — ImportedMesh ADT variant

Objective

Add ImportedMesh support path for native SketchUp solids with mandatory CSG participation. Native imported meshes must work inside boolean/CSG composition (difference/union/intersection), not only as root output.

Maintained as a patch on top of vcad repo.

Specification

Compatibility requirement

Target behavior is fixed for this project:

  • ImportedMesh is CSG-compatible and can be used inside difference, union, and intersection branches.

Patch scope (vcad repo changes)

At least three files in /Users/darwin/x/p/supex-ws/vcad:

1. cad-lib/src/lib.loon — add ADT variant:

[type Solid
  ; ... existing variants ...
  [ImportedMesh (Vec f64) (Vec Int) (Vec f64)]]  ; positions, indices, normals

2. crates/vcad-loon/src/convert.rs — add conversion:

// In convert_solid(), add match arm:
"ImportedMesh" => {
    let positions = extract_f64_vec(&fields[0])?;
    let indices = extract_u32_vec(&fields[1])?;
    let normals = extract_f64_vec(&fields[2])?;
    CsgOp::ImportedMesh {
        positions,
        indices,
        normals: Some(normals),
        source: None,  // CsgOp::ImportedMesh also has `source: Option<String>`
    }
}

This maps directly to the existing CsgOp::ImportedMesh in vcad-ir:

// Already in vcad-ir (no changes needed):
CsgOp::ImportedMesh {
    positions: Vec<f64>,
    indices: Vec<u32>,
    normals: Option<Vec<f64>>,
    source: Option<String>,
}

3. crates/vcad-eval/src/evaluate.rs — evaluator requirement:

  • patch evaluator so ImportedMesh participates correctly when encountered in non-root CSG branches

vcad-kernel changes are not expected.

Patch management

# Create patch branch in vcad repo
cd /Users/darwin/x/p/supex-ws/vcad
git checkout -b supex-patches
# ... apply changes ...
git commit -m "Add CSG-compatible ImportedMesh support for external mesh import"

# When vcad updates, rebase our patch
git fetch origin
git rebase origin/main

After applying the patch, rebuild the supex sidecar (cargo build picks up the changed crates via path deps).

Verify

# Verify patch applies cleanly and tests pass
cd /Users/darwin/x/p/supex-ws/vcad && cargo test -p vcad-loon
cd /Users/darwin/x/p/supex-ws/vcad && cargo test -p vcad-eval

# Acceptance:
# - add/execute test proving ImportedMesh in boolean branch evaluates successfully

Phase dag: DAG data structure + manual cascade

Objective

Dependency graph tracking all edges between vcad nodes (entity imports, vcad-to-vcad solid imports). Manual cascading re-evaluation via MCP tool — no automatic watching yet.

Use existing scaffolding in driver/src/supex_driver/connection/vcad_state.py (RevisionTracker, EvalQueue, VCADPersistentState, VCADReconciler) and wire DAG logic around it. Avoid duplicating already-implemented state primitives.

Specification

Dependency graph (driver/src/supex_driver/connection/vcad_dag.py)

@dataclass
class ImportRef:
    binding_name: str       # Loon var name
    entity_ref: str         # "entity:12345"
    extract: str            # "dimensions", "bbox", "transform", "solid"
    resolved_type: str      # "native", "vcad", or "native_mesh"
    source_node_id: str | None  # set if resolved_type == "vcad"

@dataclass
class VcadNode:
    node_id: str
    source_file: str
    imports: list[ImportRef]
    revision: int = 0            # monotonic trigger counter
    applied_revision: int = 0    # last successfully applied revision
    last_entity_id: int | None = None
    status: str = "active"       # active | degraded | orphan

@dataclass
class PersistedNodeState:
    node_id: str
    source_file: str
    revision: int
    applied_revision: int
    last_entity_id: int | None
    status: str

class VcadStateStore:
    def load(self) -> dict[str, PersistedNodeState]: ...
    def save(self, nodes: dict[str, VcadNode]) -> None: ...
    def atomic_write(self, payload: dict[str, Any]) -> None: ...

class VcadDag:
    def __init__(self):
        self.nodes: dict[str, VcadNode] = {}
        self.store = VcadStateStore()

    def add_node(self, node: VcadNode) -> None: ...
    def remove_node(self, node_id: str) -> None: ...

    def get_evaluation_order(self) -> list[str]:
        """Topological sort of all nodes."""

    def get_downstream(self, node_id: str) -> list[str]:
        """All nodes that depend (transitively) on node_id."""

    def get_dependents_of_entity(self, entity_id: int) -> list[str]:
        """All nodes that import this SketchUp entity (native or vcad-backed)."""

    def bump_revision(self, node_id: str) -> int:
        """Increment and return next expected revision for node_id."""

    def current_revision(self, node_id: str) -> int: ...
    def mark_applied(self, node_id: str, revision: int) -> None: ...

    def load_persisted_state(self) -> None: ...
    def persist_state(self) -> None: ...
    def reconcile_with_sketchup(self, sketchup_nodes: list[dict[str, Any]]) -> dict[str, list[str]]: ...

    def detect_cycle(self) -> list[str] | None: ...

DAG population

When vcad_place or vcad_place_with_imports is called, the driver registers the node and its imports in the DAG. When vcad_update re-evaluates a node, the DAG entry is refreshed (imports may have changed).

Persistence + reconciliation rules:

  • Any successful place/update/remove updates in-memory DAG and immediately persists state.
  • On driver startup, call load_persisted_state() before accepting MCP vcad operations.
  • Then call reconcile_with_sketchup(list_vcad_nodes()) to classify drift (missing_node, orphan_definition, revision_gap, source_missing).
  • For source_missing, keep node in DAG as degraded and emit SOURCE_FILE_MISSING until source is restored.
  • For reconcilable drift, schedule one deduplicated topological cascade and persist fresh state after apply.

Cascading re-evaluation

@mcp.tool()
def vcad_update_cascade(ctx: McpContext, node_id: str) -> str:
    """Re-evaluate node and all downstream dependents in topological order.
    ADT composition: each node's ADT tree is cached in the sidecar,
    so downstream nodes that import :solid get the fresh ADT directly."""
    dag = get_vcad_dag()
    affected = [node_id] + dag.get_downstream(node_id)
    order = dag.topological_sort(affected)

    results = []
    for nid in order:
        revision = dag.bump_revision(nid)

        # Resolve imports — vcad imports use freshly cached ADTs from earlier in the cascade
        # Eval + update sidecar cache + update SketchUp mesh
        # Apply only if revision is still current (drop stale late results).
        results.append(vcad_update_with_revision(ctx, nid, revision))

    return results

Apply/commit rule for each node update:

  • Implement via a driver-internal helper (e.g. vcad_update_with_revision(ctx, node_id, revision)) so MCP-facing vcad_update signature stays stable.
  • Sidecar eval response is considered tentative until driver checks revision freshness.
  • If stale (revision < dag.current_revision(node_id)): skip SketchUp update_vcad_node, skip viewer publish, increment stale-drop metric.
  • If current: apply in SketchUp, publish to viewer, set applied_revision = revision, update last_entity_id, persist state.

This is a manually triggered tool — the agent (or user) calls it explicitly after editing a source file. Automatic triggering comes in Phase fs-watch-mod-track.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_dag.py -v
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_connection.py -v -k Reconciler

# recovery/reconcile check:
# - seed persisted vcad-state.json, restart driver, reconcile with list_vcad_nodes snapshot
# - verify drift buckets (missing_node, orphan_definition, revision_gap, source_missing)
# - verify source_missing nodes are marked degraded and return SOURCE_FILE_MISSING deterministically

Phase adt-imports: Import system — vcad-backed :solid (ADT composition)

Objective

Enable :solid import from vcad-backed entities. The sidecar caches each node's evaluated Value::Adt tree and injects it as a Loon binding when another node imports it. This allows full CSG composition across vcad nodes — the kernel optimizes the combined tree in one pass. Extends Phase data-imports's import resolution protocol.

Specification

Prerequisites

  • Phase data-imports (import resolution protocol, AST extraction, data bindings)
  • Phase loon-patch (required eval_program_with_env_and_base_dir API in loon-lang)

If loon-patch is not applied, :solid imports must return a structured SOLID_IMPORT_UNAVAILABLE error and this phase remains design-only.

Import model update

Extract type vcad-backed entity
:solid Cached Value::Adt from ADT cache, injected as Loon binding

Non-vcad-backed entities requesting :solid return error (native mesh support in Phase native-mesh).

Import declaration in .cmp.oo

; Import vcad-backed Solid — cached ADT tree, full CSG composition
[let bracket [import :solid "entity:67890"]]
; bracket is Value::Adt (e.g., Fillet(Difference(Cube(...), Cylinder(...)), 2.0))
[pipe [cube 20.0 20.0 50.0]
  [difference bracket]
  [translate 0.0 0.0 10.0]]

ADT composition mechanism

Loon produces pure ADT data — geometry is represented as nested Value::Adt trees (e.g., Fillet(Difference(Cube(...), Cylinder(...)), 2.0)). Cross-node composition works at the Loon evaluation level:

  1. When node A is evaluated, eval_vcad_to_value() produces a Value::Adt tree
  2. Sidecar stores this in AdtCache: adt_cache.set("node-a", adt_value)
  3. When node B imports :solid from node A:
    • Driver resolves import → knows it's vcad-backed with node_id "node-a"
    • Sidecar retrieves adt_cache.get("node-a")Value::Adt(...)
    • The cached ADT is injected as a Loon binding in node B's environment before evaluation
  4. Node B's code operates on the imported ADT with normal cad-lib functions:
    • [difference bracket] produces Value::Adt("Difference", [node_b_solid, imported_adt])
    • The combined ADT tree flows through value_to_document()evaluate_document() as one unit
  5. The kernel evaluates the full CSG tree (including node A's subtree) in one pass

Why ADT-level composition:

  • Value::Adt is what cad-lib functions expect — [difference ...], [fillet ...] etc. take and return ADTs
  • ADT composition lets the kernel optimize the full combined CSG tree, not just use a pre-computed result
  • No modifications to vcad-ir or vcad-eval needed — it's a pure Loon-level operation

Sidecar: eval_with_imports (extends Phase data-imports)

impl Evaluator {
    /// Evaluate with imports injected as Loon bindings.
    /// Extends Phase data-imports eval_with_data_imports with solid ADT injection.
    /// Input source must already be transformed by extract_and_rewrite_imports
    /// so no raw [import ...] calls remain.
    fn eval_with_imports(
        &mut self,
        transformed_source: &str,
        base_dir: Option<&Path>,
        imports: &HashMap<String, ResolvedImport>, // key: import_id
    ) -> Result<EvalResult, String> {
        // 1. Build Loon preamble for data imports using injected symbols
        let mut preamble = String::new();
        for (_import_id, import) in imports {
            match import.extract.as_str() {
                "dimensions" | "bbox" | "transform" => {
                    preamble.push_str(&format_data_binding(&import.injected_symbol, &import.data));
                }
                "solid" => {
                    // Solid imports: injected into Loon Env directly (step 3)
                }
                _ => {}
            }
        }
        let augmented_source = format!("{}\n{}", preamble, transformed_source);

        // 2. Parse the augmented source
        let exprs = loon_lang::parser::parse(&augmented_source)?;

        // 3. Set up Loon environment with solid ADT bindings
        let mut env = loon_lang::interp::Env::new();
        for (_import_id, import) in imports {
            if import.extract == "solid" {
                if let Some(cached_adt) = self.adt_cache.get(&import.vcad_node_id) {
                    env.set(import.injected_symbol.clone(), cached_adt.clone());
                }
            }
        }

        // 4. Evaluate Loon with pre-populated environment (loon-patch API)
        let result_value = loon_lang::interp::eval_program_with_env_and_base_dir(
            &exprs,
            &mut env,
            base_dir,
        )?;

        // 5. Cache the result ADT for future imports
        // adt_cache.set(node_id, result_value.clone());

        // 6. Convert to Document and evaluate to mesh
        let doc = vcad_loon::value_to_document(&result_value)?;
        self.evaluate_and_export(&doc, "import-eval")
    }
}

ADT cache activation

The AdtCache (defined in Phase sidecar, adt_cache.rs) is now actively used:

  • Every eval_code / eval_file call also stores the result ADT via eval_vcad_to_value()
  • eval_with_imports retrieves cached ADTs for :solid bindings
  • Cache invalidation: when a node is re-evaluated, its cache entry is replaced
  • Remove #[allow(dead_code)] around AdtCache once usage is wired.

Ruby resolve tool update

Extend Phase data-imports's resolve_vcad_import with :solid support:

when 'solid'
  if vcad_node_id
    { extract: 'solid', source: 'vcad', vcad_node_id: vcad_node_id }
  else
    raise "Entity #{entity_id} is not vcad-backed — :solid import unavailable (native mesh support in Phase native-mesh)"
  end

MCP tool update

vcad_place_with_imports now also resolves inline :solid import declarations — the driver orchestrates two-phase resolution (data from Ruby bridge, solid from sidecar ADT cache).

Verify

cd /Users/darwin/x/p/supex-ws/loon && cargo test -p loon-lang
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_solid_imports.py -v

Phase fs-watch: Filesystem watcher (.cmp.oo files)

Objective

Automatically re-evaluate vcad nodes when their .cmp.oo source files change on disk. Uses the DAG from Phase dag to cascade downstream updates.

Specification

Filesystem watcher (Rust sidecar, notify crate)

Add notify dependency to vcad/sidecar/Cargo.toml:

notify = { version = "7", features = ["macos_fsevent"] }

New module vcad/sidecar/src/watcher.rs:

use notify::{Watcher, RecursiveMode, Event, EventKind};
use std::path::PathBuf;
use std::sync::mpsc;

pub struct FileWatcher {
    watcher: notify::RecommendedWatcher,
    rx: mpsc::Receiver<Event>,
    watch_dir: Option<PathBuf>,
}

impl FileWatcher {
    pub fn new() -> Self { ... }

    /// Start watching a project directory.
    pub fn watch(&mut self, dir: &Path) -> Result<(), String> { ... }

    /// Poll for changed files. Non-blocking.
    pub fn poll_changes(&self) -> Vec<FileChange> { ... }
}

pub struct FileChange {
    pub path: PathBuf,
    pub kind: FileChangeKind,
}

pub enum FileChangeKind {
    VcadLoon,  // .cmp.oo file
    Loon,      // .loon library (handled in Phase mod-track)
}

Sidecar JSON-RPC methods

vcad.watch_start(dir: string)   — start watching project directory
vcad.watch_stop()               — stop watching
vcad.watch_poll()               — returns list of changed files since last poll

Driver integration

The driver polls the sidecar for file changes (or receives push notifications). When a .cmp.oo file changes:

  1. Find the vcad node whose source_file matches the changed path
  2. Call vcad_update_cascade(node_id) from Phase dag
  3. All downstream nodes are re-evaluated in topological order

Watch lifecycle

  • Watch starts when the first vcad_place is called (auto-detect project root from source_file)
  • Watch stops when driver disconnects or explicitly via vcad.watch_stop()

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_fs_watch.py -v

Phase native-mesh: Native SketchUp solid import

Objective

Enable :solid import from native SketchUp geometry (non-vcad-backed). Ruby bridge extracts the triangulated mesh from SketchUp solids, sidecar wraps it as ImportedMesh ADT. Non-solid entities (open meshes) are strictly rejected.

Specification

Prerequisites

  • Phase adt-imports (import system)
  • Phase vcad-patch (CSG-compatible ImportedMesh support)

Import model update

The :solid extract now handles three cases:

Entity type Behavior
vcad-backed Cached Value::Adt from ADT cache (Phase adt-imports, unchanged)
Native solid (manifold? == true) Mesh extracted, wrapped as ImportedMesh ADT, participates in CSG
Native non-solid (manifold? == false) Error — "entity is not a solid"

Ruby mesh extraction (runtime/src/supex_runtime/vcad_tools.rb)

def extract_solid_mesh(entity)
  defn = entity.respond_to?(:definition) ? entity.definition : entity
  raise "Entity is not a solid" unless defn.entities.manifold?

  positions = []
  indices = []
  normals = []
  vertex_offset = 0

  defn.entities.grep(Sketchup::Face).each do |face|
    mesh = face.mesh(0)  # 0 = no UVs, just geometry
    # PolygonMesh: vertices are 1-based
    mesh.count_points.times do |i|
      pt = mesh.point_at(i + 1)
      positions.push(pt.x.to_mm, pt.y.to_mm, pt.z.to_mm)
      n = mesh.normal_at(i + 1)
      normals.push(n.x, n.y, n.z)
    end
    mesh.count_polygons.times do |i|
      tri = mesh.polygon_at(i + 1)  # returns array of vertex indices (1-based, may be negative)
      tri.each { |vi| indices.push(vi.abs - 1 + vertex_offset) }
    end
    vertex_offset += mesh.count_points
  end

  { positions: positions, indices: indices, normals: normals }
end

Updated resolve_vcad_import

Extends the Phase adt-imports resolve tool with native mesh support:

when 'solid'
  if vcad_node_id
    # vcad-backed: return node_id, sidecar will use cached ADT
    { extract: 'solid', source: 'vcad', vcad_node_id: vcad_node_id }
  elsif defn && defn.entities.manifold?
    # Native SketchUp solid: extract mesh
    mesh_data = extract_solid_mesh(defn)
    { extract: 'solid', source: 'native_mesh', mesh: mesh_data }
  else
    raise "Entity #{entity_id} is not a solid — :solid import unavailable"
  end

Sidecar: native mesh → ImportedMesh ADT

When the driver sends a resolved import with source: "native_mesh", the sidecar wraps the mesh data as a Loon ADT:

fn wrap_native_mesh(mesh: &MeshData) -> Value {
    Value::Adt(
        "ImportedMesh".to_string(),
        vec![
            Value::Vec(mesh.positions.iter().map(|&v| Value::Float(v)).collect()),
            Value::Vec(mesh.indices.iter().map(|&v| Value::Int(v as i64)).collect()),
            Value::Vec(mesh.normals.iter().map(|&v| Value::Float(v)).collect()),
        ],
    )
}

This ADT is injected as a Loon binding, same as vcad-backed imports. User code is identical:

; Works the same whether entity is vcad-backed or native SketchUp solid
[let wall [import :solid "entity:11111"]]
[pipe [cube 200.0 100.0 300.0]
  [difference wall]]

The importing code doesn't need to know the source — both sources behave as Solid ADT values in CSG.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_native_imports.py -v
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_native_imports_mock.py -v

# CSG check:
# - native mesh + difference/union/intersection succeeds

Phase mod-track: Module tracking (.loon library watching)

Objective

Track which .loon library files each vcad node depends on (via [use ...]). When a shared library file changes, identify all affected nodes and cascade re-evaluation.

Specification

Prerequisites

  • Phase fs-watch (filesystem watcher)
  • Phase loon-patch (loon-lang patch — env-seeded eval API + loaded_module_paths)

Module tracker (Rust sidecar, vcad/sidecar/src/module_tracker.rs)

struct ModuleTracker {
    // Maps: node_id -> Set<file_path> (all .loon files loaded via [use ...])
    node_modules: HashMap<String, HashSet<PathBuf>>,
    // Inverse: file_path -> Set<node_id> (which nodes depend on this file)
    file_dependents: HashMap<PathBuf, HashSet<String>>,
}

impl ModuleTracker {
    fn record_evaluation(&mut self, node_id: &str, loaded_paths: Vec<PathBuf>);
    fn get_affected_nodes(&self, changed_file: &Path) -> Vec<String>;
    fn clear_node(&mut self, node_id: &str);  // called before re-eval to rebuild
}

After each evaluation, the sidecar calls record_evaluation(node_id, loaded_paths) with the paths returned from eval_program_with_module_tracking. When a .loon file changes, get_affected_nodes() returns all vcad nodes that need re-evaluation. No AST scanning or regex needed — the tracking comes directly from the interpreter's module resolution.

Integration with filesystem watcher (Phase fs-watch)

The watcher from Phase fs-watch already detects .loon file changes (tagged as FileChangeKind::Loon). In this phase, the driver handles them:

  1. .loon file change detected
  2. Query module tracker: get_affected_nodes(changed_path) → list of node_ids
  3. For each affected node: vcad_update_cascade(node_id) (deduplicated)

Shared code via Loon module system

vcad nodes share constants, helpers and parametric functions through Loon's native [use ...] module system. The user's project is a set of .loon files — Loon resolves them from the project root.

; src/dims.loon — shared dimensions library
[pub let plate-thickness 10.0]
[pub let bolt-radius 4.0]

; base-plate.cmp.oo
[use dims]
[cube 200.0 200.0 dims.plate-thickness]

; bracket.cmp.oo
[use dims]
[pipe [cube 20.0 20.0 50.0]
  [translate 0.0 0.0 dims.plate-thickness]]

No custom export/import forms needed for code sharing — Loon's [use ...] handles it natively. The sidecar passes the project root as base_dir to eval_vcad_file().

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_module_tracking.py -v

Phase su-observer: SketchUp model observer + batch mode

Objective

Detect SketchUp entity modifications and trigger re-evaluation of dependent vcad nodes. Add batch mode (pause/resume) for agent workflows that edit multiple files at once.

Specification

Prerequisites

  • Phase dag (DAG)
  • Phase fs-watch (filesystem watcher)

SketchUp model observer (Ruby, runtime/src/supex_runtime/vcad_observer.rb)

class VcadModelObserver < Sketchup::ModelObserver
  def onTransactionCommit(model)
    # Detect which entity IDs changed and enqueue them into bridge-local buffer.
    # No push transport: driver pulls these IDs via vcad.observer_poll.
  end
end

When a SketchUp entity is modified (moved, scaled, edited), the observer appends changed entity IDs into a deduplicating queue in Ruby runtime. The driver polls this queue and looks up the DAG: get_dependents_of_entity(entity_id) → list of vcad nodes that import this entity → cascade re-evaluation.

Observer queue + poll API

Bridge exposes pull-based observer methods on the existing TCP JSON-RPC channel:

  • vcad.observer_start — attach observer + initialize queue
  • vcad.observer_stop — detach observer + clear queue
  • vcad.observer_poll — return deduplicated changed IDs since last poll

Example vcad.observer_poll response:

{
  "changed_entity_ids": [12345, 98765],
  "dropped": 0,
  "queue_size": 2
}

Queue behavior:

  • deduplicate IDs per poll window
  • bounded queue (VCAD_OBSERVER_MAX_QUEUE, default 2048)
  • overflow increments dropped counter for diagnostics

Driver polling loop

Driver runs a periodic poll (VCAD_OBSERVER_POLL_MS, default 250ms):

  1. ensure startup reconciliation finished (reconcile_with_sketchup) before enabling reactive loop
  2. call vcad.observer_poll
  3. merge with filesystem/module-triggered pending set
  4. append events into trigger-coalescing buffer (VCAD_TRIGGER_COALESCE_MS)
  5. respect vcad_watch_pause() state
  6. when coalescing window closes, trigger one deduplicated topological cascade

Observer lifecycle

  • Observer attached when first vcad node is placed
  • Observer detached when all vcad nodes are removed or driver disconnects
  • Ruby bridge exposes vcad.observer_start / vcad.observer_stop / vcad.observer_poll JSON-RPC methods

Batch mode (pause/resume watching)

Agent or user can suspend reactive re-evaluation for batch operations — e.g. when the agent edits multiple .cmp.oo files in sequence as part of a single task.

@mcp.tool()
def vcad_watch_pause(ctx: McpContext) -> str:
    """Pause reactive watching. Changes accumulate but don't trigger re-evaluation."""

@mcp.tool()
def vcad_watch_resume(ctx: McpContext) -> str:
    """Resume watching and flush: re-evaluate all nodes affected by accumulated changes (deduplicated, single cascade)."""

While paused:

  • File changes and SketchUp entity modifications are collected in a pending set
  • No re-evaluation happens
  • On resume: all accumulated changes are merged, deduplicated, topologically sorted, and executed as a single cascade — avoids redundant intermediate evaluations

Typical agent workflow:

  1. vcad_watch_pause()
  2. Edit src/dims.loon, bracket.cmp.oo, plate.cmp.oo
  3. vcad_watch_resume() — one cascade covers all changes

Unified trigger flow (all change sources combined)

  1. Change detected (SketchUp entity queue via poll, .cmp.oo file, or .loon library)
  2. If watching paused: add to pending set, return
  3. Driver adds event to coalescing window buffer (multi-source)
  4. On coalescing window close, compute union of affected node IDs via DAG + module tracker
  5. For each affected node, bump monotonic revision before enqueueing eval
  6. Topological sort of affected subgraph
  7. Sequential re-evaluation — each step updates sidecar ADT cache before next step
  8. Apply result only if revision is still current; stale late results are dropped
  9. SketchUp meshes updated per-node (re-import via obj_path, currently pointing to .dae artifacts)
  10. Viewer notified to refresh (Phase viewer-live) with same revision

Coalescing safety:

  • no parallel cascades; while one cascade runs, new events accumulate for next coalescing batch
  • bounded wait behavior must prevent starvation under sustained change bursts

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_observer.py -v
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_observer_mock.py -v

# storm/dedup check:
# - apply multiple rapid edits to the same entity inside one poll window
# - vcad.observer_poll returns one deduplicated entity ID
# - verify one cascade run (no re-eval storm)
# coalescing check:
# - emit fs-watch + mod-track + observer changes within one coalesce window
# - verify one merged cascade for unioned node set
# - verify continuous stream still executes bounded batches (no starvation)
# stale-result race check:
# - trigger two updates for one node (older eval intentionally slower than newer)
# - verify only newer revision is applied in SketchUp + viewer
# - verify stale-drop metric/log increments and no rollback flicker appears

Phase ops-observability: Operational telemetry + diagnostics contract

Objective

Add a dedicated observability phase for runtime telemetry, health diagnostics, and trace correlation across MCP driver, sidecar, SketchUp bridge, and viewer relay.

Specification

Prerequisites

  • Phase vcad-conn
  • Phase dag
  • Phase viewer-live
  • Phase su-observer

Metrics contract (driver-owned snapshot)

Expose a machine-readable metrics snapshot with stable names:

Metric Type Meaning
queue_depth gauge current sidecar eval queue depth observed by driver
queue_rejected_total counter rejected evals (queue full)
eval_timeout_total counter eval timeout count
stale_dropped_total counter stale results dropped by revision guard
superseded_dropped_total counter pending jobs marked superseded by same-node enqueue
superseded_skipped_before_eval_total counter superseded jobs skipped by worker before compute starts
coalesce_batches_total counter number of coalesced trigger batches executed
coalesce_merged_events_total counter total source events merged into coalesced batches
coalesce_window_ms_effective gauge currently effective coalescing window in milliseconds
artifact_manifests_written_total counter eval artifact manifests written successfully
artifact_manifests_retained_current gauge currently retained artifact manifests in temp storage
artifact_pair_recovery_total counter incomplete artifact pairs recovered on startup
artifact_pair_incomplete_detected_total counter incomplete pair markers detected during recovery scan
reconcile_runs_total counter startup/recovery reconciliation runs
reconcile_drift_total counter total drift items found by reconcile
degraded_nodes_current gauge nodes currently in degraded state
viewer_reconnect_total counter accepted viewer reconnect handshakes

Rules:

  • Counters are monotonic for one driver process lifetime.
  • Gauges represent current state and can move up/down.
  • Metric names and units are part of the public contract and covered by tests.

Diagnostics MCP tools

New MCP diagnostics module (e.g. driver/src/supex_driver/mcp/vcad_diagnostics.py):

@mcp.tool()
def vcad_health(ctx: McpContext) -> str:
    """Return liveness/readiness + negotiated protocol/capability summary for sidecar and viewer relay."""

@mcp.tool()
def vcad_metrics(ctx: McpContext) -> str:
    """Return current telemetry snapshot (stable JSON schema)."""

@mcp.tool()
def vcad_reconcile_status(ctx: McpContext) -> str:
    """Return last reconcile run: timestamp, drift summary, pending corrective actions."""

Diagnostics response contract:

  • vcad_health: includes status, sidecar.protocol_version, viewer.protocol_version, capabilities, last_error.
  • vcad_metrics: includes all required metrics plus collected_at timestamp.
  • vcad_reconcile_status: includes last_run_at, drift buckets, pending_nodes, and last_outcome.
  • vcad_metrics may include last_artifact_manifest when artifact manifest phase is enabled.
  • diagnostics outputs may include manifest_read_errors for non-fatal per-artifact read/validation issues.
  • diagnostics may include in_progress_artifacts for visibility/debug only; committed lists must exclude them.

Correlation/logging contract

  • Every MCP vcad request carries a request_id (correlation_id) from tool entry to:
    • driver logs
    • sidecar JSON-RPC metadata
    • SketchUp bridge calls
    • viewer relay messages/events
  • Structured log events (JSON) must include at least:
    • timestamp, level, request_id, node_id (when available), event, error_code (when failure)
  • Required event names: eval_enqueued, eval_started, eval_finished, eval_timeout, stale_dropped, reconcile_started, reconcile_finished, viewer_reconnected.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_observability.py -v

# metrics determinism check:
# - run stale-result race and verify stale_dropped_total increments exactly once for one dropped result
# - run queue overflow scenario and verify queue_rejected_total increments with stable counts
# - run same-node burst and verify superseded_dropped_total / superseded_skipped_before_eval_total are deterministic
# - run multi-source burst and verify coalesce_batches_total / coalesce_merged_events_total are deterministic
# - crash-recovery scenario updates artifact_pair_incomplete_detected_total / artifact_pair_recovery_total deterministically

# correlation check:
# - execute one vcad_place flow and verify same request_id appears in driver, sidecar, and relay logs

# recovery diagnostics check:
# - force reconcile drift and verify vcad_reconcile_status reports buckets + pending actions
# committed visibility check:
# - in-progress/staged artifacts never appear in committed artifact outputs
# - optional in_progress_artifacts section reflects pending markers without polluting committed results

Phase schema-contracts: Versioned payload schemas + compatibility tests

Objective

Define explicit, versioned JSON schema contracts for payloads exchanged across MCP, driver, sidecar, SketchUp bridge, and viewer relay to prevent silent integration breakage.

Specification

Prerequisites

  • Phase vcad-conn
  • Phase mcp-tools
  • Phase viewer-live
  • Phase ops-observability

Contract repository layout

Add versioned contract files:

docs/contracts/
  v1/
    handshake.schema.json
    tools-call.schema.json
    vcad-tools-results.schema.json
    viewer-relay.schema.json
    error-envelope.schema.json
    artifact-manifest.schema.json
  examples/
    handshake.ok.json
    handshake.protocol-mismatch.json
    vcad_place.success.json
    vcad_place.error.json
    viewer.ready.ok.json
    scene.snapshot.ok.json
    artifact.applied.json
    artifact.stale_dropped.json
    artifact.superseded.json

Scope of required schemas:

  • handshake.schema.json: hello request/response (protocol_version, capabilities, limits)
  • tools-call.schema.json: tools/call input envelope (name, arguments, request_id)
  • vcad-tools-results.schema.json: canonical success payloads for vcad_* MCP tools
  • viewer-relay.schema.json: relay messages (mesh.update, mesh.remove, scene.snapshot, viewer.ready, screenshot metadata)
  • error-envelope.schema.json: stable error shape (error_code, message, conditionally required details)
  • artifact-manifest.schema.json: deterministic artifact manifest shape (status, paths, hashes, geometry summary, timings)

Manifest schema requirements:

  • required common fields: status, source_hash, obj_path, finished_at
    • note: obj_path is a compatibility field name and currently points to .dae artifacts
  • conditional requirements by status:
    • applied: require revision, bbox, volume, surface_area
    • stale_dropped and superseded: require drop/supersede reason details
  • strict types and RFC3339 timestamp format

Compatibility/evolution policy

  • Additive changes (new optional fields) are allowed within the same protocol_version.
  • Breaking changes require protocol_version major bump and a new contract folder (e.g. docs/contracts/v2/).
  • Deprecated fields must stay readable for one transition window and be documented in schema description.
  • Parser behavior must be forward-tolerant for unknown fields unless schema marks them forbidden.

Runtime validation policy

  • Driver validates incoming sidecar/viewer payloads against negotiated schema version.
  • Sidecar validates tools/call.arguments and handshake payload before execution.
  • Driver-side diagnostics validate loaded artifact manifests against artifact-manifest.schema.json.
  • Diagnostics manifest reads are best-effort: one invalid/missing manifest must not fail the whole diagnostics response.
  • Validation failures return SCHEMA_VALIDATION_FAILED with details.path and details.expected when available.
  • Error messages remain human-friendly, but tests assert only error_code + machine fields.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_schema_contracts.py -v

# schema fixture checks:
# - validate all examples/*.json against docs/contracts/v1/*.schema.json
# - keep golden fixtures for each vcad_* tool success + failure payload
# - validate artifact.*.json fixtures against artifact-manifest.schema.json

# negative validation checks:
# - missing required handshake field -> SCHEMA_VALIDATION_FAILED
# - wrong type in viewer relay payload -> SCHEMA_VALIDATION_FAILED
# - missing required artifact field / invalid status-conditional field -> SCHEMA_VALIDATION_FAILED
# - mixed valid+invalid manifests still return partial diagnostics results with per-item errors

# compatibility checks:
# - previous fixtures remain accepted by current parser when only additive fields changed
# - breaking fixture requires protocol bump and fails deterministically under old version

Phase queue-pruning: Supersede-aware pending eval pruning

Objective

Reduce wasted evaluation work by skipping superseded pending jobs for the same node while preserving deterministic DAG order and stale-result safety.

Implementation note: base behavior already exists in driver/src/supex_driver/connection/vcad_state.py::EvalQueue; this phase wires and hardens that logic in production flow instead of rewriting it from scratch.

Specification

Prerequisites

  • Phase vcad-conn
  • Phase dag
  • Phase ops-observability

Queue semantics

  • Queue tracks job metadata: {job_id, node_id, revision, state} where state in {pending, running, done, superseded}.
  • Enqueue rule: when a new job for node_id arrives, mark older pending jobs for the same node_id as superseded.
  • Worker pre-start rule: immediately before eval compute, if job is superseded, skip compute and emit skip telemetry.
  • Running jobs are not cancelled; their results still pass through stale-result guard before apply.
  • Client-visible behavior is unchanged (no new public error code for supersede pruning).

DAG/topology safety

  • Pruning is allowed only among jobs for the same node_id.
  • Pruning must not reorder dependencies across different nodes in one cascade.
  • For a topological cascade batch, keep per-node latest revision and preserve inter-node order constraints.

Telemetry

  • Increment superseded_dropped_total when older pending job is marked superseded.
  • Increment superseded_skipped_before_eval_total when worker skips superseded job pre-compute.
  • Include queue-pruning events in structured logs with request_id, node_id, revision, event.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_queue_pruning.py -v

# same-node burst check:
# - enqueue rapid edits for one node
# - verify only latest pending revision is evaluated

# topo safety check:
# - run upstream/downstream cascade with overlaps
# - verify dependency order is preserved across different nodes

# race/latency check:
# - combine long-running eval + rapid superseding updates
# - verify no rollback, no starvation, deterministic supersede counters

Phase error-details: Structured error details normalization

Objective

Standardize details payload shape for key error_code values so callers can rely on deterministic machine-readable diagnostics.

Specification

Prerequisites

  • Phase mcp-tools
  • Phase ops-observability
  • Phase schema-contracts

Required details map

For these error codes, details is mandatory and must include the listed keys:

error_code Required details keys
PROTOCOL_MISMATCH expected_protocol, actual_protocol, operation
CAPABILITY_UNAVAILABLE required_capability, negotiated_capabilities, operation
PATH_NOT_ALLOWED path, workspace, operation
SOURCE_FILE_MISSING node_id, source_file, operation
STATE_RECONCILE_REQUIRED drift, pending_nodes, operation
ARTIFACT_READ_FAILED manifest_path, reason, operation

Normalization rules:

  • Keep error_code stable from origin (sidecar/ruby/driver/viewer relay) to MCP response.
  • Driver must not replace upstream error_code; it may only fill missing details.operation.
  • If a required key is missing at boundary output, normalize response to SCHEMA_VALIDATION_FAILED with path info.
  • Diagnostics may return per-item errors (e.g., manifest read failures) while preserving partial successful results.
  • Human-readable message is informative only; agent logic must consume error_code + details.

Contract alignment

  • Reflect required details keys in docs/contracts/v1/error-envelope.schema.json with conditional requirements per error_code.
  • Keep example fixtures for each normalized error in docs/contracts/examples/.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_error_details.py -v

# required-details checks:
# - each mapped error_code returns all required details keys
# - message-only errors without required details fail tests

# passthrough checks:
# - upstream error_code is preserved unchanged through MCP boundary
# - driver enrichment is limited to missing details.operation

# negative checks:
# - omit one required detail key and verify SCHEMA_VALIDATION_FAILED at boundary

Phase trigger-coalescing: Multi-source trigger coalescing window

Objective

Merge rapid trigger bursts from multiple sources into deterministic batched cascades to reduce redundant re-evaluations without changing external APIs.

Implementation note: core coalescer behavior already exists in driver/src/supex_driver/connection/vcad_state.py::TriggerCoalescer; this phase wires it to watcher/module/observer sources and adds contract-level telemetry.

Specification

Prerequisites

  • Phase fs-watch
  • Phase mod-track
  • Phase su-observer
  • Phase dag
  • Phase ops-observability

Coalescing model

  • Configure coalescing window with VCAD_TRIGGER_COALESCE_MS (default 150).
  • Sources merged into one trigger stream:
    • filesystem watcher (.cmp.oo)
    • module tracker (.loon dependencies)
    • SketchUp observer poll
    • manual update triggers
  • Within one window, accumulate a deduplicated event set and compute union of affected node_ids.
  • On window close, execute one topological cascade for that union.

Scheduling rules

  • At most one cascade runs at a time.
  • If new events arrive during an active cascade, buffer them for the next coalescing window.
  • No parallel cascade execution; deterministic batch boundaries only.
  • Implement bounded-wait flush behavior to prevent starvation during continuous event streams.

Telemetry

  • Increment coalesce_batches_total per executed coalesced batch.
  • Increment coalesce_merged_events_total by number of source events merged.
  • Publish effective window via coalesce_window_ms_effective.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_trigger_coalescing.py -v

# multi-source burst check:
# - emit fs-watch/mod-track/observer events in one window
# - verify one cascade over unioned affected nodes

# active-cascade deferral check:
# - inject events during running cascade
# - verify they run in next batch only (no parallel cascade)

# starvation bound check:
# - sustain long event stream and verify bounded flush intervals + forward progress

Phase boundary-error-builder: Centralized error envelope builder

Objective

Introduce one shared boundary error builder pattern so all layers emit the same validated error envelope (error_code, message, details) without ad-hoc formatting.

Specification

Prerequisites

  • Phase error-details
  • Phase schema-contracts

Builder contract

Define a canonical helper in each runtime boundary:

  • Driver: build_error(code, message, details, operation)
  • Sidecar: equivalent helper for JSON-RPC error data
  • Viewer relay: equivalent helper for relay/MCP-facing error payloads

Builder behavior:

  • validates required details keys for mapped error codes (from Phase error-details)
  • auto-fills missing details.operation from current operation context
  • preserves upstream error_code when forwarding across boundaries
  • if internal payload is invalid, converts to SCHEMA_VALIDATION_FAILED with path diagnostics

Integration points

  • Replace manual error envelope assembly in MCP vcad tools with builder calls.
  • Replace sidecar manual error_response(...) payload assembly with builder-backed generation.
  • Replace viewer relay capability/protocol error assembly with builder-backed generation.
  • Keep human-readable message, but machine behavior must depend on error_code + details only.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_error_builder.py -v

# builder unit checks:
# - each mapped error_code validates required details keys
# - missing details.operation is auto-filled from operation context

# regression checks:
# - no boundary path returns message-only error without structured details
# - invalid internal payload normalizes to SCHEMA_VALIDATION_FAILED

# snapshot checks:
# - JSON error envelope shape is stable for representative errors

Phase artifact-manifest: Eval artifact manifest, visibility and diagnostics

Objective

Emit a deterministic manifest for each evaluation artifact, define committed visibility semantics, and make diagnostics resilient to partial manifest failures — so debugging, replay, and post-mortem tooling can reference exact inputs/outputs without inspecting ad-hoc logs.

Specification

Prerequisites

  • Phase sidecar
  • Phase ops-observability
  • Phase queue-pruning
  • Phase trigger-coalescing
  • Phase schema-contracts
  • Phase error-details

Manifest model

For each eval artifact, write <obj_filename>.manifest.json atomically with fields:

  • status (applied, stale_dropped, superseded)
  • node_id, revision, request_id
  • source_file, source_hash
  • imports metadata snapshot (references/types only; no heavy payload duplication)
  • output summary: obj_path, bbox, volume, surface_area
    • note: obj_path may contain a .dae path
  • timings: queued_at, started_at, finished_at

Determinism rules:

  • canonical JSON field ordering and stable timestamp format (RFC3339 UTC)
  • source_hash computed from canonicalized source/document input
  • stale/superseded paths still emit manifest with terminal status and reason context

Schema contract alignment:

  • Manifest payloads must validate against docs/contracts/v1/artifact-manifest.schema.json.
  • Diagnostics that read manifests must return SCHEMA_VALIDATION_FAILED for invalid manifest files.

Retention + pairing

  • Treat mesh artifact (obj_path, currently .dae) + manifest as one artifact set for TTL/count retention.
  • Cleanup must remove pairs together and avoid orphan files.
  • Restart sequence handling must keep unique filenames and pair integrity.

Atomic pair commit + crash recovery

  • Write staged files first: *.<mesh-ext>.tmp and *.manifest.json.tmp.
  • Create a pending pair marker (e.g. *.pair.pending) before publish.
  • Publish by rename sequence, then remove pending marker only after both files are committed.
  • Readers/diagnostics ignore staged files and any pair with pending marker.
  • On sidecar startup, run recovery scan:
    • detect pending markers
    • remove incomplete staged/half-published artifacts
    • remove stale pending markers
    • increment recovery counters

Path policy hardening

  • manifest_path is derived internally from accepted obj_path; no user-supplied manifest paths.
  • Before write/read, canonicalize path and verify it remains under workspace-scoped allowed root.
  • Reject ../ traversal and symlink escapes with PATH_NOT_ALLOWED.
  • Diagnostics lookup must enforce the same policy when opening historical manifest files.

Committed visibility rule

An artifact is committed only when all are true:

  • mesh file referenced by obj_path exists
  • matching .manifest.json exists
  • no matching *.pair.pending marker exists

Visibility behavior:

  • staged files (*.tmp) and pending pairs are excluded from committed listings
  • diagnostics may expose optional in_progress_artifacts for debugging
  • committed artifact queries (last_artifact_manifest, history lookups) must use the same shared commit-check helper
  • retention operates on committed pairs only
  • recovery handles in-progress artifacts and pending markers
  • after recovery, no half-visible committed artifacts may remain

Diagnostics reader behavior

  • Diagnostics manifest reads are best-effort and item-scoped.
  • Valid manifests contribute normal diagnostic data.
  • Invalid/missing/unreadable manifests append entries to manifest_read_errors and processing continues.
  • Staged files and *.pair.pending markers are treated as in-progress artifacts and skipped from normal reads.
  • Response-level failure is reserved for transport/protocol failures, not individual manifest defects.

Per-item error mapping:

  • schema/type violations: SCHEMA_VALIDATION_FAILED
  • missing/unreadable/corrupt file: ARTIFACT_READ_FAILED
  • path traversal/symlink escape outside allowed root: PATH_NOT_ALLOWED

Per-item error payload shape:

  • manifest_path
  • error_code
  • message
  • details (includes at least operation and reason/path context)

Diagnostics integration

  • vcad_metrics includes:
    • normal metrics payload
    • optional last_artifact_manifest
    • optional manifest_read_errors: []
  • Deterministic ordering: manifest_read_errors sorted by manifest_path then error_code.
  • If all manifests fail, diagnostics still return metrics + populated manifest_read_errors.
  • observability counters include successful manifest writes and current retained manifest count.

Verify

cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_artifact_manifest.py -v

# reproducibility check:
# - same input/revision yields stable manifest keys and source_hash

# status-path check:
# - stale/superseded eval paths write manifest with correct terminal status

# retention pairing check:
# - cleanup removes mesh-artifact/manifest pairs together (no orphan files)

# atomic pair commit check:
# - inject failure between publish steps and verify no committed mesh-without-manifest (or inverse)
# - restart sidecar and verify recovery scan resolves pending/incomplete pairs

# path hardening check:
# - traversal/symlink manifest path attempts fail with PATH_NOT_ALLOWED
# - valid internally derived manifest paths still read/write successfully

# committed visibility check:
# - interrupt pair publish mid-sequence -> committed list remains consistent
# - once both files committed and marker removed, artifact appears exactly once
# - after restart/recovery, no half-visible state remains in committed outputs

# diagnostics reader check:
# - prepare valid + invalid + missing manifests -> partial success + per-item errors
# - invalid schema => SCHEMA_VALIDATION_FAILED
# - missing/unreadable file => ARTIFACT_READ_FAILED
# - path escape/symlink outside workspace => PATH_NOT_ALLOWED
# - no crash on malformed manifest files
# - deterministic ordering of manifest_read_errors

# diagnostics integration check:
# - vcad_metrics returns last_artifact_manifest when available

# compatibility check:
# - additive manifest fields remain accepted by current parser/schema without protocol bump

Implementation Notes

Cross-Phase Dependencies

Recommended implementation sequence:

  1. sidecar
  2. vcad-conn
  3. ruby-tools
  4. mcp-tools
  5. su-mock
  6. e2e
  7. data-imports
  8. loon-patch
  9. adt-imports -> dag -> fs-watch
  10. vcad-patch -> native-mesh
  11. mod-track
  12. viewer -> viewer-live
  13. su-observer
  14. ops-observability
  15. schema-contracts
  16. error-details
  17. boundary-error-builder
  18. queue-pruning
  19. trigger-coalescing
  20. artifact-manifest
sidecar ← vcad-conn ← ruby-tools ← mcp-tools ← data-imports ← adt-imports ← dag ← fs-watch ← mod-track
   |           |           |            |               ^              | 
 viewer        |        su-mock --------+          loon-patch          |
   |           |                                                     vcad-patch
 viewer-live ←-+                                                        |
                                                                 native-mesh

su-observer ← dag + fs-watch
ops-observability ← vcad-conn + dag + viewer-live + su-observer
schema-contracts ← vcad-conn + mcp-tools + viewer-live + ops-observability
error-details ← mcp-tools + ops-observability + schema-contracts
boundary-error-builder ← error-details + schema-contracts
queue-pruning ← vcad-conn + dag + ops-observability
trigger-coalescing ← fs-watch + mod-track + su-observer + dag + ops-observability
artifact-manifest ← sidecar + ops-observability + queue-pruning + trigger-coalescing + schema-contracts + error-details
e2e ← sidecar + vcad-conn + ruby-tools + mcp-tools (+ su-mock for CI)
  • Phase sidecar is the foundation — everything else depends on it
  • Phase vcad-conn depends on Phase sidecar (VcadConnection + sidecar lifecycle)
  • Phase ruby-tools depends on existing runtime code and establishes SketchUp-side vcad operations used by later driver orchestration
  • Phase mcp-tools depends on Phase vcad-conn + Phase ruby-tools (driver orchestration of sidecar + SketchUp runtime)
  • Phase su-mock depends on Phase ruby-tools (loads real runtime code including vcad tool dispatch); used for CI-friendly integration tests
  • Phase e2e depends on sidecar + vcad-conn + ruby-tools + mcp-tools; su-mock provides the thorough CI path while real SketchUp remains smoke-only
  • Phase viewer depends on Phase sidecar only (standalone viewer, loads mesh artifacts from files)
  • Phase viewer-live depends on Phase viewer + Phase vcad-conn (driver relay WebSocket + MCP tools)
  • Phase data-imports extends mcp-tools+ruby-tools with import resolution protocol + data extracts (supex only); su-mock-based pytest verification
  • Phase loon-patch is required before adt-imports and mod-track (adds env-seeded eval API and loaded module path tracking)
  • Phase adt-imports depends on data-imports + loon-patch for :solid import via ADT cache composition
  • Phase vcad-patch is a vcad repo patch that provides CSG-compatible ImportedMesh behavior
  • Phase native-mesh depends on adt-imports+vcad-patch (native mesh import needs both import system and CSG-compatible ImportedMesh); su-mock-based pytest verification
  • Phase dag extends adt-imports with DAG data structure + manual cascade (no watchers)
  • Phase fs-watch extends dag with filesystem watching for .cmp.oo files
  • Phase mod-track extends fs-watch+loon-patch with module tracking + .loon library watching
  • Phase su-observer extends fs-watch with SketchUp model observer + batch mode (pause/resume); su-mock-based pytest verification
  • Phase ops-observability depends on vcad-conn+dag+viewer-live+su-observer and defines runtime telemetry + diagnostics contracts
  • Phase schema-contracts depends on vcad-conn+mcp-tools+viewer-live+ops-observability and defines versioned payload schemas + compatibility policy
  • Phase error-details depends on mcp-tools+ops-observability+schema-contracts and normalizes required details keys per error code
  • Phase boundary-error-builder depends on error-details+schema-contracts and centralizes boundary error envelope generation
  • Phase queue-pruning depends on vcad-conn+dag+ops-observability and adds same-node pending-job supersede pruning without API changes
  • Phase trigger-coalescing depends on fs-watch+mod-track+su-observer+dag+ops-observability and batches multi-source trigger bursts
  • Phase artifact-manifest depends on sidecar+ops-observability+queue-pruning+trigger-coalescing+schema-contracts+error-details and covers deterministic eval artifacts, committed visibility semantics, and resilient diagnostics reader

vcad/loon Repo Patches

Phases vcad-patch and loon-patch introduce changes to the vcad/loon repos maintained as patch branches:

vcad patches (Phase vcad-patch):

  • Branch: supex-patches in /Users/darwin/x/p/supex-ws/vcad
  • Files:
    • cad-lib/src/lib.loon (add ImportedMesh ADT variant)
    • crates/vcad-loon/src/convert.rs (add conversion)
    • crates/vcad-eval/src/evaluate.rs (enable ImportedMesh in boolean/CSG branches)
  • Note: CsgOp::ImportedMesh already exists in vcad-ir; evaluator behavior for CSG composition must be explicitly validated

loon-lang patches (Phase loon-patch):

  • Branch: supex-patches in /Users/darwin/x/p/supex-ws/loon
  • Files:
    • crates/loon-lang/src/interp/mod.rs (add eval_program_with_env_and_base_dir(...))
    • crates/loon-lang/src/module.rs (add loaded_module_paths() to ModuleCache)
  • Note: this patch is required for both adt-imports (env-seeded eval) and mod-track (module tracking)

Maintenance:

  • Rebase on upstream updates: git rebase origin/main
  • After rebase: cargo build in supex sidecar to pick up changes

Testing Strategy

Four testing tiers:

  1. Unit tests: cargo test (Rust), minitest (Ruby), pytest with mocks (Python), vitest (viewer TypeScript) — fast, no external processes
  2. Viewer headless tests: vitest + @react-three/test-renderer + happy-dom — scene graph structure, store logic, WebSocket relay client; CI-friendly, no browser or display server needed
  3. Integration tests (su-mock): pytest against su-mock Ruby process — real runtime code, mock SketchUp API, TCP protocol exercised end-to-end, CI-friendly
  4. E2E tests: real SketchUp + sidecar — smoke tests only, not CI-friendly

Per-phase breakdown:

  • Phase sidecar: cargo test unit tests (Loon eval, DAE export, TCP server)
  • Phase vcad-conn: pytest for VcadConnection + sidecar lifecycle
  • Phase viewer: vitest headless (store/logic + @react-three/test-renderer scene graph) + manual npm run tauri dev for visual check
  • Phase viewer-live: vitest headless (DriverRelayClient, revision guard, reconnect/snapshot) + manual npm run tauri dev for live relay check
  • Phase mcp-tools: pytest for MCP vcad tools with mock sidecar
  • Phase su-mock: Ruby self-tests (mock/test/test_mock_api.rb) verifying mock API completeness
  • Phase ruby-tools: minitest in Ruby (vcad_tools module) + pytest against su-mock (-k vcad_ruby_mock)
  • Phase e2e: thorough tests via su-mock; real SketchUp only for smoke tests
  • Phase data-imports: pytest for import extraction + data binding injection; su-mock-based integration tests
  • Phase adt-imports: pytest for ADT cache + solid import composition
  • Phase vcad-patch: cargo test -p vcad-loon + cargo test -p vcad-eval in vcad repo, plus ImportedMesh-in-CSG acceptance test
  • Phase native-mesh: pytest + Ruby tests for mesh extraction; include CSG composition check (native mesh in difference/union/intersection succeeds)
  • Phase dag: pytest for DAG structure + manual cascade
  • Phase fs-watch: pytest for filesystem watcher integration
  • Phase loon-patch: cargo test -p loon-lang in loon repo
  • Phase mod-track: pytest for module tracking + sidecar integration
  • Phase su-observer: pytest + Ruby tests for model observer + batch mode; su-mock-based integration tests
  • Phase ops-observability: pytest for health/metrics/reconcile diagnostics and request-id correlation across layers
  • Phase schema-contracts: schema fixture validation + compatibility/negative payload tests
  • Phase error-details: pytest for required error-details keys, passthrough guarantees, and message-only regression checks
  • Phase boundary-error-builder: unit/snapshot tests for centralized builder and boundary regression checks
  • Phase queue-pruning: pytest for same-node pruning behavior, topology safety, and supersede telemetry counters
  • Phase trigger-coalescing: pytest for merged multi-source batches, deferral during active cascades, and starvation bounds
  • Phase artifact-manifest: pytest for manifest determinism, schema fixture validation, status coverage, retention pairing, committed visibility invariants, partial diagnostics with mixed valid/invalid/missing manifests, and deterministic per-item errors

Key Technical Notes

Loon language:

  • Syntax: square brackets [fn name [args] body], not parentheses
  • Types: ADT via [type Name [Variant fields...]]
  • Subject-last convention: [fillet 2.0 solid] — pipe-compatible
  • Pipe: [pipe expr [fn1 arg1] [fn2 arg2]] (thread-last)
  • Modules: [use module-name] resolves from project root (src/module-name.loon)
  • Public exports: [pub let name val], [pub fn name ...]
  • Values: Value::Adt(tag, fields) — e.g., Adt("Cube", [50.0, 30.0, 5.0])
  • Comments: ; to end of line

vcad evaluation pipeline:

  • vcad_loon::eval_vcad(source: &str, base_dir: Option<&Path>) -> Result<Document, String> — prepends lib.loon, parses, evaluates, converts Value -> Document
  • vcad_loon::eval_vcad_file(path: &Path) -> Result<Document, String> — reads file, derives base_dir automatically
  • vcad_loon::eval_vcad_to_value(source: &str, base_dir: Option<&Path>) -> Result<Value, String> — returns raw Value::Adt (for ADT caching)
  • vcad_loon::value_to_document(value: &Value) -> Result<Document, String> — convert ADT tree to IR Document
  • vcad_eval::evaluate_document(doc: &Document, options: &EvalOptions) -> Result<EvaluatedScene, EvalError>
  • EvalOptions { skip_clash_detection: bool, clock: Option<...> } (current sidecar uses clock: None)
  • EvaluatedScene { parts: Vec<EvaluatedPart>, part_defs, instances, clashes }
  • EvaluatedPart { mesh: EvaluatedMesh, material: String, solid: Option<Solid> }
  • EvaluatedMesh { positions: Vec<f32>, indices: Vec<u32>, normals: Option<Vec<f32>> }

vcad kernel:

  • vcad_kernel::Solid — BRep solid, supports .volume(), .surface_area(), .bounding_box(), .is_empty(), .to_mesh(segments)
  • TriangleMesh { vertices: Vec<f32>, indices: Vec<u32>, normals: Vec<f32> } — note: field is vertices not positions
  • CsgOp::ImportedMesh { positions: Vec<f64>, indices: Vec<u32>, normals: Option<Vec<f64>>, source: Option<String> } — exists in vcad-ir
  • ImportedMesh must participate in boolean/CSG composition; this requirement must be covered by tests
  • Units: mm throughout

loon-lang:

  • loon_lang::parser::parse(source: &str) -> Result<Vec<Expr>, ParseError>
  • loon_lang::interp::eval_program(exprs: &[Expr]) -> IResult (alias for Result<Value, InterpError>)
  • loon_lang::interp::eval_program_with_base_dir(exprs: &[Expr], base_dir: Option<&Path>) -> IResult
  • loon_lang::interp::eval_program_with_env_and_base_dir(exprs: &[Expr], env: &mut Env, base_dir: Option<&Path>) -> IResult (added by Phase loon-patch)
  • Value::Adt(String, Vec<Value>) — ADT constructor values
  • ModuleCache — tracks loaded modules internally in HashMap<PathBuf, ModuleState>; Phase loon-patch exposes loaded_module_paths()

Cargo dependency paths:

  • All path deps are relative from supex-ws/supex/vcad/sidecar/ to sibling supex-ws/vcad/ and supex-ws/loon/ crates
  • Example: vcad-loon = { path = "../../../vcad/crates/vcad-loon" } resolves to supex-ws/vcad/crates/vcad-loon
  • Changes to vcad or loon crates require cargo build in the sidecar
  • The sidecar binary is self-contained after compilation (no runtime deps on source)

Existing supex Signatures (reference for implementation)

Python driver — SketchupConnection (driver/src/supex_driver/connection/sketchup_connection.py):

@dataclass
class SketchupConnection:
    host: str; port: int; timeout: float; agent: str
    token: str | None; workspace: str | None
    sock: socket.socket | None; _identified: bool; _last_activity: float

    def send_command(self, method: str, params: dict[str, Any] | None = None,
                     request_id: Any = None) -> dict[str, Any]: ...

def get_sketchup_connection(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT,
                            agent: str = "unknown") -> SketchupConnection: ...
# Env: SUPEX_HOST, SUPEX_PORT, SUPEX_TIMEOUT, SUPEX_RETRIES, SUPEX_AUTH_TOKEN, SUPEX_WORKSPACE

# VcadConnection follows the same transport shape and additionally negotiates
# `protocol_version` + `capabilities` during `hello`.

Python driver — MCP server (driver/src/supex_driver/mcp/mcp_server.py):

mcp = FastMCP("Supex")

def call_tool(ctx: McpContext, method: str, params: dict[str, Any] | None = None,
              operation: str = "operation") -> str: ...

# Existing tools (all @mcp.tool()):
# check_sketchup_status, export_scene, eval_ruby, console_capture_status,
# eval_ruby_file, get_model_info, list_entities, get_selection, get_layers,
# get_materials, get_camera_info, take_screenshot, take_batch_screenshots,
# open_model, save_model

Ruby runtime — bridge_server (runtime/src/supex_runtime/bridge_server.rb):

# Tool dispatch chain:
def execute_tool(tool_name, args, workspace)
  execute_core_tool(tool_name, args, workspace) ||
  execute_introspection_tool(tool_name, args, workspace) ||
  (raise "Unknown tool: #{tool_name}")
end
# To add vcad tools: add execute_vcad_tool as third fallback before raise

Ruby runtime — tools (runtime/src/supex_runtime/tools.rb):

module SupexRuntime::Tools
  # Existing: model_info, list_entities, selection_info, layers_info,
  # materials_info, camera_info, take_screenshot, batch_screenshot,
  # open_model, save_model
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment