diff --git a/Cargo.lock b/Cargo.lock index 395668e4..eb4d5a6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2347,6 +2347,7 @@ dependencies = [ "chrono", "clap", "futures", + "hashbrown 0.14.5", "hex", "hostname", "jsonwebtoken", diff --git a/Dockerfile b/Dockerfile index 2eb43933..42b1607d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y \ # Copy manifests COPY Cargo.toml Cargo.lock ./ COPY crates ./crates +COPY examples ./examples # Build release with NATS sync support RUN --mount=type=cache,id=ordo-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ diff --git a/crates/ordo-core/src/rule/compiled_executor.rs b/crates/ordo-core/src/rule/compiled_executor.rs index bd6ced7c..12d6f14b 100644 --- a/crates/ordo-core/src/rule/compiled_executor.rs +++ b/crates/ordo-core/src/rule/compiled_executor.rs @@ -289,10 +289,11 @@ impl CompiledRuleExecutor { let mut child_data = std::collections::HashMap::with_capacity(bindings.len()); for binding in bindings { let name = ruleset.get_string(binding.name)?; - child_data.insert( - name.to_string(), - self.evaluate_expr(ruleset, binding.expr, parent_ctx)?, - ); + if let Some(value) = + self.evaluate_sub_rule_binding(ruleset, binding.expr, parent_ctx)? + { + child_data.insert(name.to_string(), value); + } } self.execute_sub_graph( @@ -303,6 +304,23 @@ impl CompiledRuleExecutor { ) } + fn evaluate_sub_rule_binding( + &self, + ruleset: &CompiledRuleSet, + expr_idx: u32, + ctx: &Context, + ) -> Result> { + match self.evaluate_expr(ruleset, expr_idx, ctx) { + Ok(value) => Ok(Some(value)), + Err(OrdoError::FieldNotFound { .. }) + if ruleset.metadata.field_missing == FIELD_MISSING_LENIENT => + { + Ok(None) + } + Err(error) => Err(error), + } + } + fn copy_sub_rule_outputs( &self, ruleset: &CompiledRuleSet, @@ -891,6 +909,92 @@ mod tests { assert_eq!(result.output.get_path("tier"), Some(&Value::string("gold"))); } + #[test] + fn compiled_sub_rule_bindings_follow_lenient_missing_field_behavior() { + let mut sub_steps = hashbrown::HashMap::new(); + sub_steps.insert( + "check_score".to_string(), + Step::decision("check_score", "Check Score") + .branch( + crate::rule::Condition::from_string("score >= 90"), + "tier_gold", + ) + .default("tier_silver") + .build(), + ); + sub_steps.insert( + "tier_gold".to_string(), + Step::action( + "tier_gold", + "Gold", + vec![Action { + kind: ActionKind::SetVariable { + name: "tier".to_string(), + value: Expr::literal("gold"), + }, + description: String::new(), + }], + "done", + ), + ); + sub_steps.insert( + "tier_silver".to_string(), + Step::action( + "tier_silver", + "Silver", + vec![Action { + kind: ActionKind::SetVariable { + name: "tier".to_string(), + value: Expr::literal("silver"), + }, + description: String::new(), + }], + "done", + ), + ); + sub_steps.insert( + "done".to_string(), + Step::terminal("done", "Done", TerminalResult::new("OK")), + ); + + let mut ruleset = RuleSet::new("compiled_sub_rule_lenient", "start"); + ruleset.add_sub_rule( + "classify", + SubRuleGraph { + entry_step: "check_score".to_string(), + steps: sub_steps, + }, + ); + ruleset.add_step(Step { + id: "start".to_string(), + name: "Start".to_string(), + kind: StepKind::SubRule { + ref_name: "classify".to_string(), + bindings: vec![("score".to_string(), Expr::field("score"))], + outputs: vec![("result_tier".to_string(), "tier".to_string())], + next_step: "done".to_string(), + }, + }); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("DONE").with_output("tier", Expr::field("$result_tier")), + )); + + ruleset.validate().unwrap(); + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + let executor = CompiledRuleExecutor::new(); + + let input = serde_json::from_str(r#"{}"#).unwrap(); + let result = executor.execute(&compiled, input).unwrap(); + + assert_eq!(result.code, "DONE"); + assert_eq!( + result.output.get_path("tier"), + Some(&Value::string("silver")) + ); + } + #[test] fn compiled_executor_preserves_object_payloads_for_external_calls() { let mut ruleset = RuleSet::new("compiled_external_payload_test", "invoke"); diff --git a/crates/ordo-core/src/rule/executor.rs b/crates/ordo-core/src/rule/executor.rs index c4e56be1..54dd4458 100644 --- a/crates/ordo-core/src/rule/executor.rs +++ b/crates/ordo-core/src/rule/executor.rs @@ -9,7 +9,7 @@ use crate::capability::{CapabilityInvoker, CapabilityRequest}; use crate::context::{Context, Value}; use crate::error::{OrdoError, Result}; use crate::expr::{Evaluator, ExprParser}; -use crate::trace::{ExecutionTrace, StepTrace, TraceConfig}; +use crate::trace::{ExecutionTrace, StepTrace, SubRuleCallTrace, SubRuleOutputTrace, TraceConfig}; use rayon::prelude::*; use std::sync::Arc; @@ -279,74 +279,99 @@ impl RuleExecutor { // Execute step — branch on tracing to avoid Instant syscalls in the hot path. // When tracing is off (default), zero Instant calls per step. - let (step_result, step_duration, sub_frames) = if let StepKind::SubRule { - ref_name, - bindings, - outputs, - next_step, - } = &step.kind - { - // SubRule: execute inline sub-graph, then map outputs back to parent context - if remaining_call_depth == 0 { - return Err(OrdoError::eval_error(format!( - "SubRule max nesting depth ({}) exceeded calling '{}'", - self.max_call_depth, ref_name - ))); - } - let graph = ruleset.sub_rules.get(ref_name.as_str()).ok_or_else(|| { - OrdoError::eval_error(format!("Sub-rule '{}' not found", ref_name)) - })?; - let mut child_data = hashbrown::HashMap::new(); - for (field, expr) in bindings { - child_data.insert( - std::sync::Arc::from(field.as_str()), - self.evaluator.eval(expr, &ctx)?, - ); - } - let child_input = Value::object_optimized(child_data); - let step_start = if tracing { Some(Instant::now()) } else { None }; - let (child_ctx, sub_trace) = self.execute_sub_graph( - &ruleset.sub_rules, - graph, - child_input, - &ruleset.config.field_missing, - tracing, - remaining_call_depth - 1, - )?; - let dur = step_start - .map(|t| t.elapsed().as_micros() as u64) - .unwrap_or(0); - for (parent_var, child_var) in outputs { - if let Some(val) = child_ctx.variables().get(child_var.as_str()) { - ctx.set_variable(parent_var.clone(), val.clone()); + let (step_result, step_duration, sub_frames, sub_rule_call) = + if let StepKind::SubRule { + ref_name, + bindings, + outputs, + next_step, + } = &step.kind + { + // SubRule: execute inline sub-graph, then map outputs back to parent context + if remaining_call_depth == 0 { + return Err(OrdoError::eval_error(format!( + "SubRule max nesting depth ({}) exceeded calling '{}'", + self.max_call_depth, ref_name + ))); } - } - let frames = if tracing { Some(sub_trace) } else { None }; - ( - StepResult::Continue { - next_step: next_step.as_str(), - }, - dur, - frames, - ) - } else if tracing { - let step_start = Instant::now(); - let result = self.execute_step( - step, - &mut ctx, - &ruleset.config.field_missing, - remaining_call_depth, - )?; - (result, step_start.elapsed().as_micros() as u64, None) - } else { - let result = self.execute_step( - step, - &mut ctx, - &ruleset.config.field_missing, - remaining_call_depth, - )?; - (result, 0, None) - }; + let graph = ruleset.sub_rules.get(ref_name.as_str()).ok_or_else(|| { + OrdoError::eval_error(format!("Sub-rule '{}' not found", ref_name)) + })?; + let mut child_data = hashbrown::HashMap::new(); + for (field, expr) in bindings { + if let Some(value) = self.evaluate_sub_rule_binding( + expr, + &ctx, + &ruleset.config.field_missing, + )? { + child_data.insert(std::sync::Arc::from(field.as_str()), value); + } + } + let child_input = Value::object_optimized(child_data); + let traced_child_input = if tracing { + Some(child_input.clone()) + } else { + None + }; + let step_start = if tracing { Some(Instant::now()) } else { None }; + let (child_ctx, sub_trace) = self.execute_sub_graph( + &ruleset.sub_rules, + graph, + child_input, + &ruleset.config.field_missing, + tracing, + remaining_call_depth - 1, + )?; + let dur = step_start + .map(|t| t.elapsed().as_micros() as u64) + .unwrap_or(0); + let mut output_trace = Vec::new(); + for (parent_var, child_var) in outputs { + let value = child_ctx.variables().get(child_var.as_str()).cloned(); + if let Some(val) = &value { + ctx.set_variable(parent_var.clone(), val.clone()); + } + if tracing { + output_trace.push(SubRuleOutputTrace { + parent_var: parent_var.clone(), + child_var: child_var.clone(), + missing: value.is_none(), + value, + }); + } + } + let frames = if tracing { Some(sub_trace) } else { None }; + let call_trace = traced_child_input.map(|input| SubRuleCallTrace { + ref_name: ref_name.clone(), + input, + outputs: output_trace, + }); + ( + StepResult::Continue { + next_step: next_step.as_str(), + }, + dur, + frames, + call_trace, + ) + } else if tracing { + let step_start = Instant::now(); + let result = self.execute_step( + step, + &mut ctx, + &ruleset.config.field_missing, + remaining_call_depth, + )?; + (result, step_start.elapsed().as_micros() as u64, None, None) + } else { + let result = self.execute_step( + step, + &mut ctx, + &ruleset.config.field_missing, + remaining_call_depth, + )?; + (result, 0, None, None) + }; // Record trace (only when enabled — zero overhead otherwise) if let Some(ref mut trace) = trace { @@ -376,6 +401,9 @@ impl RuleExecutor { if let Some(frames) = sub_frames { step_trace.sub_rule_frames = Some(frames); } + if let Some(call) = sub_rule_call { + step_trace.sub_rule_call = Some(call); + } trace.add_step(step_trace); } @@ -561,7 +589,7 @@ impl RuleExecutor { step_id: current.clone(), })?; - let (result, dur, sub_frames) = if let StepKind::SubRule { + let (result, dur, sub_frames, sub_rule_call) = if let StepKind::SubRule { ref_name, bindings, outputs, @@ -579,16 +607,23 @@ impl RuleExecutor { })?; let mut child_data = hashbrown::HashMap::new(); for (field, expr) in bindings { - child_data.insert( - std::sync::Arc::from(field.as_str()), - self.evaluator.eval(expr, &ctx)?, - ); + if let Some(value) = + self.evaluate_sub_rule_binding(expr, &ctx, field_missing)? + { + child_data.insert(std::sync::Arc::from(field.as_str()), value); + } } + let child_input = Value::object_optimized(child_data); + let traced_child_input = if tracing { + Some(child_input.clone()) + } else { + None + }; let step_start = if tracing { Some(Instant::now()) } else { None }; let (child_ctx, child_frames) = self.execute_sub_graph( sub_rules, graph, - Value::object_optimized(child_data), + child_input, field_missing, tracing, remaining_call_depth - 1, @@ -596,27 +631,44 @@ impl RuleExecutor { let dur = step_start .map(|t| t.elapsed().as_micros() as u64) .unwrap_or(0); + let mut output_trace = Vec::new(); for (parent_var, child_var) in outputs { - if let Some(val) = child_ctx.variables().get(child_var.as_str()) { + let value = child_ctx.variables().get(child_var.as_str()).cloned(); + if let Some(val) = &value { ctx.set_variable(parent_var.clone(), val.clone()); } + if tracing { + output_trace.push(SubRuleOutputTrace { + parent_var: parent_var.clone(), + child_var: child_var.clone(), + missing: value.is_none(), + value, + }); + } } + let call_trace = traced_child_input.map(|input| SubRuleCallTrace { + ref_name: ref_name.clone(), + input, + outputs: output_trace, + }); ( StepResult::Continue { next_step: next_step.as_str(), }, dur, if tracing { Some(child_frames) } else { None }, + call_trace, ) } else if tracing { let t = Instant::now(); let r = self.execute_step(step, &mut ctx, field_missing, remaining_call_depth)?; - (r, t.elapsed().as_micros() as u64, None) + (r, t.elapsed().as_micros() as u64, None, None) } else { ( self.execute_step(step, &mut ctx, field_missing, remaining_call_depth)?, 0, None, + None, ) }; @@ -636,6 +688,9 @@ impl RuleExecutor { if let Some(frames) = sub_frames { st.sub_rule_frames = Some(frames); } + if let Some(call) = sub_rule_call { + st.sub_rule_call = Some(call); + } frames.push(st); } @@ -695,6 +750,23 @@ impl RuleExecutor { } } + fn evaluate_sub_rule_binding( + &self, + expr: &crate::expr::Expr, + ctx: &Context, + field_missing: &FieldMissingBehavior, + ) -> Result> { + match self.evaluator.eval(expr, ctx) { + Ok(value) => Ok(Some(value)), + Err(OrdoError::FieldNotFound { .. }) + if *field_missing == FieldMissingBehavior::Lenient => + { + Ok(None) + } + Err(error) => Err(error), + } + } + /// Execute an action fn execute_action( &self, @@ -1530,6 +1602,15 @@ mod tests { result.output.get_path("tier"), Some(&Value::string("silver")) ); + + // Missing binding field should preserve lenient branch semantics. + let input: Value = serde_json::from_str(r#"{}"#).unwrap(); + let result = executor.execute(&ruleset, input).unwrap(); + assert_eq!(result.code, "DONE"); + assert_eq!( + result.output.get_path("tier"), + Some(&Value::string("silver")) + ); } #[test] @@ -1653,8 +1734,20 @@ mod tests { assert_eq!(result.output.get_path("tier"), Some(&Value::string("gold"))); let trace = result.trace.unwrap(); + let call = trace.steps[0].sub_rule_call.as_ref().unwrap(); + assert_eq!(call.ref_name, "classify_score"); + assert_eq!(call.input.get_path("score"), Some(&Value::int(95))); + assert_eq!(call.outputs[0].parent_var, "tier"); + assert_eq!(call.outputs[0].child_var, "tier"); + assert_eq!(call.outputs[0].value, Some(Value::string("gold"))); let top_frames = trace.steps[0].sub_rule_frames.as_ref().unwrap(); assert_eq!(top_frames[0].step_id, "normalize"); + let nested_call = top_frames[0].sub_rule_call.as_ref().unwrap(); + assert_eq!(nested_call.ref_name, "normalize_score"); + assert_eq!( + nested_call.input.get_path("raw_score"), + Some(&Value::int(95)) + ); assert!(top_frames[0].sub_rule_frames.is_some()); } diff --git a/crates/ordo-core/src/trace/mod.rs b/crates/ordo-core/src/trace/mod.rs index efdb92b1..f19bbd1a 100644 --- a/crates/ordo-core/src/trace/mod.rs +++ b/crates/ordo-core/src/trace/mod.rs @@ -184,6 +184,42 @@ pub struct StepTrace { /// Inner step traces when this is a sub-rule invocation #[serde(skip_serializing_if = "Option::is_none")] pub sub_rule_frames: Option>, + + /// Sub-rule invocation details when this step calls a sub-rule + #[serde(skip_serializing_if = "Option::is_none")] + pub sub_rule_call: Option, +} + +/// Sub-rule invocation trace details. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubRuleCallTrace { + /// Referenced sub-rule name + pub ref_name: String, + + /// Input object passed into the child context after binding evaluation + pub input: Value, + + /// Output mappings applied back to the parent context + #[serde(default)] + pub outputs: Vec, +} + +/// One output mapping from child context back to parent context. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubRuleOutputTrace { + /// Variable name written in the parent context + pub parent_var: String, + + /// Variable name read from the child context + pub child_var: String, + + /// Value copied back to the parent, if present + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + + /// True when the child variable was not present + #[serde(default)] + pub missing: bool, } impl StepTrace { @@ -198,6 +234,7 @@ impl StepTrace { next_step: None, is_terminal: false, sub_rule_frames: None, + sub_rule_call: None, } } @@ -212,6 +249,7 @@ impl StepTrace { next_step: Some(next_step.to_string()), is_terminal: false, sub_rule_frames: None, + sub_rule_call: None, } } @@ -226,6 +264,7 @@ impl StepTrace { next_step: None, is_terminal: true, sub_rule_frames: None, + sub_rule_call: None, } } } diff --git a/crates/ordo-platform/Cargo.toml b/crates/ordo-platform/Cargo.toml index 497bae66..67c73a00 100644 --- a/crates/ordo-platform/Cargo.toml +++ b/crates/ordo-platform/Cargo.toml @@ -40,6 +40,7 @@ sqlx.workspace = true url = "2" sha2.workspace = true hex.workspace = true +hashbrown.workspace = true ordo-core = { path = "../ordo-core", default-features = false, features = ["derive"] } ordo-protocol = { path = "../ordo-protocol" } diff --git a/crates/ordo-platform/migrations/0017_sub_rule_assets.sql b/crates/ordo-platform/migrations/0017_sub_rule_assets.sql new file mode 100644 index 00000000..d774c04d --- /dev/null +++ b/crates/ordo-platform/migrations/0017_sub_rule_assets.sql @@ -0,0 +1,53 @@ +CREATE TABLE sub_rule_assets ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK (scope IN ('org', 'project')), + name TEXT NOT NULL, + display_name TEXT, + description TEXT, + draft JSONB NOT NULL, + input_schema JSONB NOT NULL DEFAULT '[]', + output_schema JSONB NOT NULL DEFAULT '[]', + draft_seq BIGINT NOT NULL DEFAULT 1, + draft_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + draft_updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, + published_version TEXT, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + CHECK ( + (scope = 'org' AND project_id IS NULL) + OR (scope = 'project' AND project_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX sub_rule_assets_org_unique + ON sub_rule_assets(org_id, name) + WHERE scope = 'org'; + +CREATE UNIQUE INDEX sub_rule_assets_project_unique + ON sub_rule_assets(project_id, name) + WHERE scope = 'project'; + +CREATE INDEX sub_rule_assets_org_lookup + ON sub_rule_assets(org_id, scope, name); + +CREATE INDEX sub_rule_assets_project_lookup + ON sub_rule_assets(project_id, name); + +CREATE TABLE sub_rule_versions ( + id TEXT PRIMARY KEY, + asset_id TEXT NOT NULL REFERENCES sub_rule_assets(id) ON DELETE CASCADE, + version TEXT NOT NULL, + snapshot JSONB NOT NULL, + input_schema JSONB NOT NULL DEFAULT '[]', + output_schema JSONB NOT NULL DEFAULT '[]', + release_note TEXT, + published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_by TEXT REFERENCES users(id) ON DELETE SET NULL, + UNIQUE (asset_id, version) +); + +CREATE INDEX sub_rule_versions_asset_lookup + ON sub_rule_versions(asset_id, published_at DESC); diff --git a/crates/ordo-platform/migrations/0018_drop_sub_rule_versions.sql b/crates/ordo-platform/migrations/0018_drop_sub_rule_versions.sql new file mode 100644 index 00000000..b8d0f35e --- /dev/null +++ b/crates/ordo-platform/migrations/0018_drop_sub_rule_versions.sql @@ -0,0 +1,7 @@ +-- Sub-rules no longer have a separate publish/version system. +-- They are snapshotted inline when the parent ruleset is published. +DROP TABLE IF EXISTS sub_rule_versions; + +ALTER TABLE sub_rule_assets + DROP COLUMN IF EXISTS published_version, + DROP COLUMN IF EXISTS published_at; diff --git a/crates/ordo-platform/src/lib.rs b/crates/ordo-platform/src/lib.rs index 1d8797f4..07819b99 100644 --- a/crates/ordo-platform/src/lib.rs +++ b/crates/ordo-platform/src/lib.rs @@ -29,6 +29,7 @@ pub mod ruleset_history; pub mod server_registry; pub mod store; pub mod sub_org_member; +pub mod sub_rules; pub mod sync; pub mod template; pub mod templates_api; @@ -41,6 +42,7 @@ pub struct AppState { pub config: Arc, pub http_client: reqwest::Client, pub templates: Arc, + pub nats_client: Option, pub sync_publisher: Option>, pub marketplace_cache: Arc, } @@ -170,10 +172,11 @@ pub async fn build_app_state( }), ); - let sync_publisher = if let Some(nats_url) = config.nats_url.as_deref() { - let jetstream = sync::connect(nats_url) + let (nats_client, sync_publisher) = if let Some(nats_url) = config.nats_url.as_deref() { + let nats_client = sync::connect_client(nats_url) .await .map_err(|e| anyhow::anyhow!("Failed to connect to NATS at {}: {}", nats_url, e))?; + let jetstream = async_nats::jetstream::new(nats_client.clone()); sync::ensure_stream(&jetstream, &config.nats_subject_prefix) .await .map_err(|e| anyhow::anyhow!("Failed to ensure NATS stream: {}", e))?; @@ -190,13 +193,16 @@ pub async fn build_app_state( sync::start_registry_subscriber(registry_consumer, store.clone()); } - Some(Arc::new(sync::NatsPublisher::new( - jetstream, - config.nats_subject_prefix.clone(), - config.resolve_instance_id(), - ))) + ( + Some(nats_client), + Some(Arc::new(sync::NatsPublisher::new( + jetstream, + config.nats_subject_prefix.clone(), + config.resolve_instance_id(), + ))), + ) } else { - None + (None, None) }; Ok(AppState { @@ -204,6 +210,7 @@ pub async fn build_app_state( config, http_client, templates, + nats_client, sync_publisher, marketplace_cache: github::MarketplaceCache::new(), }) diff --git a/crates/ordo-platform/src/main.rs b/crates/ordo-platform/src/main.rs index ffc7b3c6..d9df2944 100644 --- a/crates/ordo-platform/src/main.rs +++ b/crates/ordo-platform/src/main.rs @@ -19,7 +19,7 @@ use ordo_platform::{ connect_platform_store, contract, environment, github, i18n, init_tracing, member, middleware::require_auth, notification, org, project, proxy, publish_existing_tenants, release, ruleset_draft, ruleset_history, server_registry, start_server_registry_maintenance, - sub_org_member, templates_api, testing, + sub_org_member, sub_rules, templates_api, testing, }; #[tokio::main] @@ -181,6 +181,10 @@ async fn main() -> anyhow::Result<()> { "/api/v1/projects/:pid/rulesets/:name/tests/:tid/run", post(testing::run_one_test), ) + .route( + "/api/v1/projects/:pid/tests/run-ad-hoc", + post(testing::run_ad_hoc_test), + ) .route( "/api/v1/projects/:pid/tests/run", get(testing::run_project_tests).post(testing::run_project_tests), @@ -318,6 +322,27 @@ async fn main() -> anyhow::Result<()> { "/api/v1/orgs/:oid/releases/pending-for-me", get(notification::list_pending_approvals_for_me), ) + // Managed SubRule assets + .route( + "/api/v1/orgs/:oid/sub-rules", + get(sub_rules::list_org_sub_rules), + ) + .route( + "/api/v1/orgs/:oid/sub-rules/:name", + get(sub_rules::get_org_sub_rule) + .put(sub_rules::save_org_sub_rule) + .delete(sub_rules::delete_org_sub_rule), + ) + .route( + "/api/v1/orgs/:oid/projects/:pid/sub-rules", + get(sub_rules::list_project_sub_rules), + ) + .route( + "/api/v1/orgs/:oid/projects/:pid/sub-rules/:name", + get(sub_rules::get_project_sub_rule) + .put(sub_rules::save_project_sub_rule) + .delete(sub_rules::delete_project_sub_rule), + ) // Draft rulesets .route( "/api/v1/orgs/:oid/projects/:pid/rulesets", diff --git a/crates/ordo-platform/src/models.rs b/crates/ordo-platform/src/models.rs index e86900e9..d89595ba 100644 --- a/crates/ordo-platform/src/models.rs +++ b/crates/ordo-platform/src/models.rs @@ -18,6 +18,8 @@ mod rbac; mod release; #[path = "models/servers.rs"] mod servers; +#[path = "models/sub_rules.rs"] +mod sub_rules; pub use auth::*; pub use catalog::*; @@ -28,3 +30,4 @@ pub use notifications::*; pub use rbac::*; pub use release::*; pub use servers::*; +pub use sub_rules::*; diff --git a/crates/ordo-platform/src/models/catalog.rs b/crates/ordo-platform/src/models/catalog.rs index cde1102f..1170d1b4 100644 --- a/crates/ordo-platform/src/models/catalog.rs +++ b/crates/ordo-platform/src/models/catalog.rs @@ -156,6 +156,8 @@ pub struct TestRunResult { pub passed: bool, #[serde(default)] pub failures: Vec, + #[serde(default)] + pub failure_details: Vec, pub duration_us: u64, #[serde(default)] pub actual_code: Option, @@ -167,6 +169,30 @@ pub struct TestRunResult { pub trace: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestFailureDetail { + pub message: String, + pub kind: TestFailureKind, + #[serde(default)] + pub step_id: Option, + #[serde(default)] + pub sub_rule_ref: Option, + #[serde(default)] + pub trace_path: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TestFailureKind { + Reference, + Contract, + Binding, + SubRule, + Output, + Assertion, + Execution, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestExecutionTrace { pub trace_id: String, @@ -192,6 +218,24 @@ pub struct TestExecutionTraceStep { pub input_snapshot: Option, #[serde(default)] pub variables_snapshot: Option, + #[serde(default)] + pub sub_rule_ref: Option, + #[serde(default)] + pub sub_rule_input: Option, + #[serde(default)] + pub sub_rule_outputs: Vec, + #[serde(default)] + pub sub_rule_frames: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestSubRuleOutputTrace { + pub parent_var: String, + pub child_var: String, + #[serde(default)] + pub value: Option, + #[serde(default)] + pub missing: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/ordo-platform/src/models/release.rs b/crates/ordo-platform/src/models/release.rs index 7606178f..c3a91b41 100644 --- a/crates/ordo-platform/src/models/release.rs +++ b/crates/ordo-platform/src/models/release.rs @@ -1,3 +1,4 @@ +use super::SubRuleScope; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -249,12 +250,38 @@ pub struct ReleaseContentDiffSummary { pub added_groups: Vec, pub removed_groups: Vec, pub modified_groups: Vec, + #[serde(default)] + pub added_sub_rules: Vec, + #[serde(default)] + pub removed_sub_rules: Vec, + #[serde(default)] + pub modified_sub_rules: Vec, pub input_schema_changed: bool, pub output_schema_changed: bool, pub tags_changed: bool, pub description_changed: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseSubRuleDependency { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub scope: SubRuleScope, + pub asset_id: String, + pub draft_seq: i64, + pub content_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseSubRuleDiffItem { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step_count: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ReleaseRequestSnapshot { pub requester_id: String, @@ -273,6 +300,8 @@ pub struct ReleaseRequestSnapshot { pub affected_instance_count: i32, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_ruleset_snapshot: Option, + #[serde(default)] + pub sub_rule_dependencies: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/ordo-platform/src/models/sub_rules.rs b/crates/ordo-platform/src/models/sub_rules.rs new file mode 100644 index 00000000..d7c8805f --- /dev/null +++ b/crates/ordo-platform/src/models/sub_rules.rs @@ -0,0 +1,74 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SubRuleScope { + Org, + Project, +} + +impl std::fmt::Display for SubRuleScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SubRuleScope::Org => write!(f, "org"), + SubRuleScope::Project => write!(f, "project"), + } + } +} + +impl std::str::FromStr for SubRuleScope { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "org" => Ok(SubRuleScope::Org), + "project" => Ok(SubRuleScope::Project), + other => Err(format!("invalid sub-rule scope: {}", other)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubRuleAssetMeta { + pub id: String, + pub org_id: String, + pub project_id: Option, + pub scope: SubRuleScope, + pub name: String, + pub display_name: Option, + pub description: Option, + pub draft_seq: i64, + pub draft_updated_at: DateTime, + pub draft_updated_by: Option, + pub created_at: DateTime, + pub created_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubRuleAsset { + #[serde(flatten)] + pub meta: SubRuleAssetMeta, + pub draft: JsonValue, + #[serde(default)] + pub input_schema: JsonValue, + #[serde(default)] + pub output_schema: JsonValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SaveSubRuleAssetRequest { + pub name: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub description: Option, + pub draft: JsonValue, + #[serde(default)] + pub input_schema: JsonValue, + #[serde(default)] + pub output_schema: JsonValue, + #[serde(default)] + pub expected_seq: Option, +} diff --git a/crates/ordo-platform/src/release.rs b/crates/ordo-platform/src/release.rs index 9a8ee9c6..c199f5ae 100644 --- a/crates/ordo-platform/src/release.rs +++ b/crates/ordo-platform/src/release.rs @@ -17,6 +17,7 @@ use crate::{ PERM_RELEASE_REQUEST_CREATE, PERM_RELEASE_REQUEST_REJECT, PERM_RELEASE_REQUEST_VIEW, PERM_RELEASE_RESUME, PERM_RELEASE_ROLLBACK, }, + ruleset_draft::inline_sub_rules_with_manifest, sync::SyncEvent, AppState, }; @@ -42,6 +43,7 @@ mod requests; #[path = "release/reviews.rs"] mod reviews; +pub(crate) use diff::hash_json_value; use diff::*; pub(crate) fn user_history_actor(claims: &Claims, user: Option<&User>) -> ReleaseHistoryActor { diff --git a/crates/ordo-platform/src/release/diff.rs b/crates/ordo-platform/src/release/diff.rs index 2b8ac4c4..d56ad349 100644 --- a/crates/ordo-platform/src/release/diff.rs +++ b/crates/ordo-platform/src/release/diff.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::ReleaseSubRuleDiffItem; pub(super) fn build_release_content_diff( baseline: Option<&JsonValue>, @@ -9,6 +10,8 @@ pub(super) fn build_release_content_diff( let after_steps = extract_steps(target); let before_groups = baseline.map(extract_groups).unwrap_or_default(); let after_groups = extract_groups(target); + let before_sub_rules = baseline.map(extract_sub_rules).unwrap_or_default(); + let after_sub_rules = extract_sub_rules(target); let before_ids: BTreeSet<_> = before_steps.keys().cloned().collect(); let after_ids: BTreeSet<_> = after_steps.keys().cloned().collect(); @@ -58,6 +61,31 @@ pub(super) fn build_release_content_diff( }) .collect(); + let before_sub_rule_names: BTreeSet<_> = before_sub_rules.keys().cloned().collect(); + let after_sub_rule_names: BTreeSet<_> = after_sub_rules.keys().cloned().collect(); + let added_sub_rules = after_sub_rule_names + .difference(&before_sub_rule_names) + .filter_map(|name| after_sub_rules.get(name)) + .map(|item| item.descriptor()) + .collect(); + let removed_sub_rules = before_sub_rule_names + .difference(&after_sub_rule_names) + .filter_map(|name| before_sub_rules.get(name)) + .map(|item| item.descriptor()) + .collect(); + let modified_sub_rules = before_sub_rule_names + .intersection(&after_sub_rule_names) + .filter_map(|name| { + let before = before_sub_rules.get(name)?; + let after = after_sub_rules.get(name)?; + if before.content_hash != after.content_hash { + Some(after.descriptor()) + } else { + None + } + }) + .collect(); + ReleaseContentDiffSummary { baseline_version: baseline_version.map(str::to_string), step_count_before: before_steps.len() as i32, @@ -70,6 +98,9 @@ pub(super) fn build_release_content_diff( added_groups, removed_groups, modified_groups, + added_sub_rules, + removed_sub_rules, + modified_sub_rules, input_schema_changed: extract_schema_len(baseline, "inputSchema") != extract_schema_len(Some(target), "inputSchema"), output_schema_changed: extract_schema_len(baseline, "outputSchema") @@ -95,6 +126,23 @@ struct StepSnapshot { canonical: String, } +#[derive(Clone)] +struct SubRuleSnapshot { + name: String, + content_hash: String, + step_count: i32, +} + +impl SubRuleSnapshot { + fn descriptor(&self) -> ReleaseSubRuleDiffItem { + ReleaseSubRuleDiffItem { + name: self.name.clone(), + content_hash: Some(self.content_hash.clone()), + step_count: Some(self.step_count), + } + } +} + impl StepSnapshot { fn descriptor(&self) -> ReleaseStepDiffItem { ReleaseStepDiffItem { @@ -167,6 +215,42 @@ fn extract_groups(snapshot: &JsonValue) -> BTreeMap { items } +fn extract_sub_rules(snapshot: &JsonValue) -> BTreeMap { + let mut items = BTreeMap::new(); + let Some(JsonValue::Object(sub_rules)) = snapshot.get("sub_rules") else { + return items; + }; + + for (name, graph) in sub_rules { + let content_hash = hash_json_value(graph); + let step_count = graph + .get("steps") + .map(|steps| match steps { + JsonValue::Array(items) => items.len(), + JsonValue::Object(items) => items.len(), + _ => 0, + }) + .unwrap_or(0) as i32; + items.insert( + name.clone(), + SubRuleSnapshot { + name: name.clone(), + content_hash, + step_count, + }, + ); + } + + items +} + +pub(crate) fn hash_json_value(value: &JsonValue) -> String { + use sha2::{Digest, Sha256}; + + let bytes = serde_json::to_vec(value).unwrap_or_default(); + hex::encode(Sha256::digest(bytes)) +} + fn extract_schema_len(snapshot: Option<&JsonValue>, field: &str) -> usize { snapshot .and_then(|value| value.get("config")) @@ -198,3 +282,51 @@ pub(super) fn extract_ruleset_version(snapshot: &JsonValue) -> Option<&str> { .and_then(|config| config.get("version")) .and_then(|value| value.as_str()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_release_content_diff_detects_sub_rule_changes() { + let baseline = serde_json::json!({ + "config": { "version": "1.0.0" }, + "steps": [], + "groups": [], + "sub_rules": { + "review_gate": { + "entry_step": "start", + "steps": { + "start": { "id": "start", "name": "Start", "type": "terminal", "code": "OK" } + } + } + } + }); + let target = serde_json::json!({ + "config": { "version": "1.0.1" }, + "steps": [], + "groups": [], + "sub_rules": { + "review_gate": { + "entry_step": "start", + "steps": { + "start": { "id": "start", "name": "Start", "type": "terminal", "code": "UPDATED" } + } + }, + "fraud_gate": { + "entry_step": "gate", + "steps": { + "gate": { "id": "gate", "name": "Gate", "type": "terminal", "code": "OK" } + } + } + } + }); + + let diff = build_release_content_diff(Some(&baseline), &target, Some("1.0.0")); + assert_eq!(diff.added_sub_rules.len(), 1); + assert_eq!(diff.added_sub_rules[0].name, "fraud_gate"); + assert_eq!(diff.modified_sub_rules.len(), 1); + assert_eq!(diff.modified_sub_rules[0].name, "review_gate"); + assert!(diff.removed_sub_rules.is_empty()); + } +} diff --git a/crates/ordo-platform/src/release/executions.rs b/crates/ordo-platform/src/release/executions.rs index a0cc523e..fed0610f 100644 --- a/crates/ordo-platform/src/release/executions.rs +++ b/crates/ordo-platform/src/release/executions.rs @@ -1,6 +1,4 @@ use super::*; -use ordo_core::rule::RuleSet; -use ordo_protocol::StudioRuleSet; use std::time::Duration; use tracing::{error, info, warn}; @@ -2773,7 +2771,8 @@ async fn publish_release_via_nats( .as_ref() .ok_or_else(|| anyhow::anyhow!("NATS publisher is not configured"))?; - let normalized_ruleset = normalize_release_ruleset_json(ruleset_json)?; + let concepts = state.store.get_concepts("", project_id).await?; + let normalized_ruleset = normalize_release_ruleset_json_with_concepts(ruleset_json, &concepts)?; let json_str = serde_json::to_string(&normalized_ruleset)?; let event = SyncEvent::RulePut { tenant_id: project_id.to_string(), @@ -2809,15 +2808,22 @@ fn looks_like_studio_ruleset(ruleset: &JsonValue) -> bool { && ruleset.get("steps").is_some_and(JsonValue::is_array) } +#[cfg(test)] fn normalize_release_ruleset_json(ruleset: &JsonValue) -> anyhow::Result { + normalize_release_ruleset_json_with_concepts(ruleset, &[]) +} + +fn normalize_release_ruleset_json_with_concepts( + ruleset: &JsonValue, + concepts: &[crate::models::ConceptDefinition], +) -> anyhow::Result { if looks_like_engine_ruleset(ruleset) { return Ok(ruleset.clone()); } if looks_like_studio_ruleset(ruleset) { - let studio: StudioRuleSet = serde_json::from_value(ruleset.clone())?; - let engine: RuleSet = studio.try_into()?; - return Ok(serde_json::to_value(&engine)?); + return crate::ruleset_draft::studio_draft_to_engine_json_with_concepts(ruleset, concepts) + .map_err(anyhow::Error::from); } Err(anyhow::anyhow!( diff --git a/crates/ordo-platform/src/release/requests.rs b/crates/ordo-platform/src/release/requests.rs index 550b5246..46a2e06d 100644 --- a/crates/ordo-platform/src/release/requests.rs +++ b/crates/ordo-platform/src/release/requests.rs @@ -202,7 +202,10 @@ pub async fn create_release_request( ) .await? }; - let target_snapshot = draft.draft.clone(); + // Inline sub-rule assets before storing the snapshot so the snapshot is self-contained. + let inlined = + inline_sub_rules_with_manifest(&state, &org_id, &project_id, draft.draft.clone()).await?; + let target_snapshot = inlined.draft.clone(); let approver_users = { let mut items = Vec::new(); @@ -256,6 +259,7 @@ pub async fn create_release_request( rollback_policy: policy.rollback_policy.clone(), affected_instance_count: req.affected_instance_count.unwrap_or_default(), target_ruleset_snapshot: Some(target_snapshot.clone()), + sub_rule_dependencies: inlined.dependencies.clone(), }; let mut create_req = req; @@ -304,6 +308,7 @@ pub async fn create_release_request( "affected_instance_count": request_snapshot.affected_instance_count, "version_diff": version_diff, "content_diff": content_diff, + "sub_rule_dependencies": request_snapshot.sub_rule_dependencies, }), ) .await diff --git a/crates/ordo-platform/src/ruleset_draft.rs b/crates/ordo-platform/src/ruleset_draft.rs index 441cad94..2263d7aa 100644 --- a/crates/ordo-platform/src/ruleset_draft.rs +++ b/crates/ordo-platform/src/ruleset_draft.rs @@ -2,22 +2,37 @@ use ordo_core::{ context::Value as CoreValue, - rule::{ExecutionOptions, RuleExecutor, RuleSet}, + expr::{Expr, ExprParser}, + rule::{ + Action, ActionKind, Condition, ExecutionOptions, RuleExecutor, RuleSet, Step, StepKind, + }, trace::ExecutionTrace, }; -use ordo_protocol::StudioRuleSet; +use ordo_protocol::{ + types::{ + condition::StudioCondition, + expr::StudioExpr, + ruleset::StudioSubRuleGraph, + step::{ + StudioAssignment, StudioBranch, StudioOutputField, StudioStep, StudioStepKind, + StudioSubRuleOutput, + }, + }, + StudioRuleSet, +}; use crate::{ error::{ApiResult, PlatformError}, models::{ - Claims, DeploymentStatus, DraftConflictResponse, ProjectRuleset, ProjectRulesetMeta, - PublishRequest, RedeployRequest, RulesetDeployment, RulesetHistoryEntry, - RulesetHistorySource, SaveDraftRequest, + Claims, ConceptDefinition, DeploymentStatus, DraftConflictResponse, ProjectRuleset, + ProjectRulesetMeta, PublishRequest, RedeployRequest, ReleaseSubRuleDependency, + RulesetDeployment, RulesetHistoryEntry, RulesetHistorySource, SaveDraftRequest, }, rbac::{ require_project_permission, PERM_DEPLOYMENT_REDEPLOY, PERM_DEPLOYMENT_VIEW, PERM_RULESET_EDIT, PERM_RULESET_PUBLISH, PERM_RULESET_VIEW, }, + release::hash_json_value, sync::SyncEvent, AppState, }; @@ -205,6 +220,12 @@ pub async fn trace_draft( .map_err(|e: ordo_protocol::ConvertError| { PlatformError::bad_request(format!("Ruleset conversion failed: {}", e)) })?; + let concepts = state + .store + .get_concepts(&org_id, &project_id) + .await + .map_err(PlatformError::Internal)?; + materialize_concepts_into_engine_ruleset(&mut ruleset, &concepts)?; ruleset .compile() @@ -262,12 +283,18 @@ pub async fn convert_draft_ruleset( require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_VIEW) .await?; - let engine: RuleSet = req - .ruleset - .try_into() - .map_err(|e: ordo_protocol::ConvertError| { - PlatformError::bad_request(format!("Ruleset conversion failed: {}", e)) - })?; + let mut engine: RuleSet = + req.ruleset + .try_into() + .map_err(|e: ordo_protocol::ConvertError| { + PlatformError::bad_request(format!("Ruleset conversion failed: {}", e)) + })?; + let concepts = state + .store + .get_concepts(&org_id, &project_id) + .await + .map_err(PlatformError::Internal)?; + materialize_concepts_into_engine_ruleset(&mut engine, &concepts)?; let engine_json = serde_json::to_value(&engine) .map_err(|e| PlatformError::internal(format!("Engine serialization failed: {}", e)))?; @@ -339,9 +366,15 @@ pub async fn publish_draft( .await .map_err(PlatformError::Internal)?; - // Convert studio-format draft to engine format for NATS publish. - // ordo-server expects engine format (steps as HashMap, expression strings). - let engine_json = studio_draft_to_engine_json(&draft.draft)?; + // Inline referenced sub-rule assets, then convert studio → engine format. + let inlined = + inline_sub_rules_into_draft(&state, &org_id, &project_id, draft.draft.clone()).await?; + let concepts = state + .store + .get_concepts(&org_id, &project_id) + .await + .map_err(PlatformError::Internal)?; + let engine_json = studio_draft_to_engine_json_with_concepts(&inlined, &concepts)?; // Publish via NATS let publish_result = publish_via_nats( @@ -521,7 +554,14 @@ pub async fn redeploy( .await .map_err(PlatformError::Internal)?; - let snapshot_engine_json = studio_draft_to_engine_json(&original.snapshot)?; + // Redeploy uses the stored snapshot (already contains inlined sub-rules). + let concepts = state + .store + .get_concepts(&org_id, &project_id) + .await + .map_err(PlatformError::Internal)?; + let snapshot_engine_json = + studio_draft_to_engine_json_with_concepts(&original.snapshot, &concepts)?; let result = publish_via_nats( &state, @@ -557,16 +597,943 @@ pub async fn redeploy( // ── Internal helpers ────────────────────────────────────────────────────────── -/// Convert a stored studio-format draft JSON to engine-format JSON for NATS publish. -fn studio_draft_to_engine_json(draft: &serde_json::Value) -> ApiResult { - let studio: StudioRuleSet = serde_json::from_value(draft.clone()).map_err(|e| { +/// Scan a studio-format ruleset draft, fetch all referenced sub-rule assets from +/// the platform store, and inline them as `subRules` entries. The result is a +/// self-contained studio JSON that `studio_draft_to_engine_json` can convert +/// without any missing sub-rule references. +/// +/// Sub-sub-rules are resolved iteratively up to MAX_SUBRULE_INLINE_DEPTH levels. +pub(crate) struct InlineSubRuleSnapshot { + pub draft: serde_json::Value, + pub dependencies: Vec, +} + +pub(crate) async fn inline_sub_rules_with_manifest( + state: &AppState, + org_id: &str, + project_id: &str, + draft: serde_json::Value, +) -> ApiResult { + use crate::models::SubRuleScope; + use ordo_protocol::types::{ + ruleset::StudioSubRuleGraph, + step::{StudioStep, StudioStepKind}, + }; + + const MAX_DEPTH: usize = 8; + const MAX_REFS: usize = 64; + + let mut ruleset: StudioRuleSet = serde_json::from_value(draft).map_err(|e| { + PlatformError::bad_request(format!("Draft is not valid studio format: {}", e)) + })?; + + // Collect sub-rule refNames from a step list. + fn collect_refs(steps: &[StudioStep]) -> Vec { + steps + .iter() + .filter_map(|s| { + if let StudioStepKind::SubRule { ref_name, .. } = &s.kind { + Some(ref_name.clone()) + } else { + None + } + }) + .collect() + } + + let mut queue: Vec<(String, usize)> = collect_refs(&ruleset.steps) + .into_iter() + .map(|n| (n, 0)) + .collect(); + let mut visited = std::collections::HashSet::new(); + let mut dependencies = Vec::new(); + + while let Some((ref_name, depth)) = queue.pop() { + if depth >= MAX_DEPTH + || visited.contains(&ref_name) + || ruleset.sub_rules.contains_key(&ref_name) + { + continue; + } + if visited.len() >= MAX_REFS { + return Err(PlatformError::bad_request( + "Too many sub-rule references (max 64)", + )); + } + visited.insert(ref_name.clone()); + + // Try project scope first, then org scope. + let (asset, scope) = match state + .store + .get_sub_rule_asset(org_id, SubRuleScope::Project, Some(project_id), &ref_name) + .await + .map_err(PlatformError::Internal)? + { + Some(asset) => (asset, SubRuleScope::Project), + None => ( + state + .store + .get_sub_rule_asset(org_id, SubRuleScope::Org, None, &ref_name) + .await + .map_err(PlatformError::Internal)? + .ok_or_else(|| { + PlatformError::bad_request(format!("Sub-rule '{}' not found", ref_name)) + })?, + SubRuleScope::Org, + ), + }; + + let asset_draft = asset.draft.clone(); + let sub: StudioRuleSet = serde_json::from_value(asset_draft.clone()).map_err(|e| { + PlatformError::bad_request(format!("Sub-rule '{}' has invalid draft: {}", ref_name, e)) + })?; + + dependencies.push(ReleaseSubRuleDependency { + name: ref_name.clone(), + display_name: asset.meta.display_name.clone(), + scope, + asset_id: asset.meta.id.clone(), + draft_seq: asset.meta.draft_seq, + content_hash: hash_json_value(&asset_draft), + }); + + // Enqueue nested references. + for nested in collect_refs(&sub.steps) { + queue.push((nested, depth + 1)); + } + + ruleset.sub_rules.insert( + ref_name, + StudioSubRuleGraph { + entry_step: sub.start_step_id, + steps: sub.steps, + input_schema: None, + output_schema: None, + }, + ); + } + + let draft = serde_json::to_value(&ruleset) + .map_err(|e| PlatformError::internal(format!("Serialization failed: {}", e)))?; + dependencies.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(InlineSubRuleSnapshot { + draft, + dependencies, + }) +} + +pub(crate) async fn inline_sub_rules_into_draft( + state: &AppState, + org_id: &str, + project_id: &str, + draft: serde_json::Value, +) -> ApiResult { + Ok( + inline_sub_rules_with_manifest(state, org_id, project_id, draft) + .await? + .draft, + ) +} + +fn safe_runtime_name(value: &str) -> String { + let cleaned = value + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' { + ch + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string(); + if cleaned.is_empty() { + "value".to_string() + } else { + cleaned + } +} + +fn literal_string_expr(value: impl Into) -> StudioExpr { + StudioExpr::Literal { + value: serde_json::Value::String(value.into()), + value_type: Some("string".to_string()), + } +} + +fn variable_expr(path: impl Into) -> StudioExpr { + StudioExpr::Variable { path: path.into() } +} + +struct TerminalReturnBridge { + child_steps: Vec, + parent_steps: Vec, + next_step_id: String, + outputs: Vec, +} + +fn create_terminal_return_bridge( + sub_rule_step_id: &str, + source_steps: &[StudioStep], +) -> Option { + let terminals: Vec = source_steps + .iter() + .filter(|step| matches!(step.kind, StudioStepKind::Terminal { .. })) + .cloned() + .collect(); + if terminals.is_empty() { + return None; + } + + let prefix = format!("__ordo_sub_{}", safe_runtime_name(sub_rule_step_id)); + let terminal_id_var = format!("{}_terminal_id", prefix); + let return_step_id = format!("{}__return_to_parent", sub_rule_step_id); + let mut outputs = Vec::new(); + let mut output_var_by_terminal = std::collections::HashMap::>::new(); + + let mut child_steps = Vec::with_capacity(source_steps.len() + 1); + for step in source_steps.iter().cloned() { + if let StudioStepKind::Terminal { output, .. } = &step.kind { + let mut assignments = Vec::new(); + if terminals.len() > 1 { + assignments.push(StudioAssignment { + name: terminal_id_var.clone(), + value: literal_string_expr(step.id.clone()), + }); + outputs.push(StudioSubRuleOutput { + parent_var: terminal_id_var.clone(), + child_var: terminal_id_var.clone(), + }); + } + + let mut output_vars = Vec::new(); + for (index, field) in output.iter().enumerate() { + let output_var = format!( + "{}_{}_{}_{}", + prefix, + safe_runtime_name(&step.id), + safe_runtime_name(&field.name), + index + ); + output_vars.push(output_var.clone()); + outputs.push(StudioSubRuleOutput { + parent_var: output_var.clone(), + child_var: output_var.clone(), + }); + assignments.push(StudioAssignment { + name: output_var, + value: field.value.clone(), + }); + } + output_var_by_terminal.insert(step.id.clone(), output_vars); + + child_steps.push(StudioStep { + id: step.id, + name: step.name, + description: step.description, + position: step.position, + system_generated: Some("sub_rule_runtime".to_string()), + kind: StudioStepKind::Action { + assignments, + external_calls: Vec::new(), + logging: None, + next_step_id: return_step_id.clone(), + }, + }); + } else { + child_steps.push(step); + } + } + + child_steps.push(StudioStep { + id: return_step_id.clone(), + name: "Return to parent".to_string(), + description: None, + position: None, + system_generated: Some("sub_rule_runtime".to_string()), + kind: StudioStepKind::Terminal { + code: "OK".to_string(), + message: None, + output: Vec::new(), + }, + }); + + let parent_terminals = terminals + .iter() + .map(|terminal| { + let StudioStepKind::Terminal { + code, + message, + output, + } = &terminal.kind + else { + unreachable!(); + }; + let output_vars = output_var_by_terminal + .get(&terminal.id) + .cloned() + .unwrap_or_default(); + StudioStep { + id: format!( + "{}__terminal_{}", + sub_rule_step_id, + safe_runtime_name(&terminal.id) + ), + name: terminal.name.clone(), + description: terminal.description.clone(), + position: None, + system_generated: Some("sub_rule_runtime".to_string()), + kind: StudioStepKind::Terminal { + code: code.clone(), + message: message.clone(), + output: output + .iter() + .enumerate() + .map(|(index, field)| StudioOutputField { + name: field.name.clone(), + value: variable_expr(format!("${}", output_vars[index])), + }) + .collect(), + }, + } + }) + .collect::>(); + + if parent_terminals.len() == 1 { + return Some(TerminalReturnBridge { + child_steps, + parent_steps: parent_terminals.clone(), + next_step_id: parent_terminals[0].id.clone(), + outputs, + }); + } + + let dispatcher_id = format!("{}__return_dispatch", sub_rule_step_id); + let dispatcher = StudioStep { + id: dispatcher_id.clone(), + name: "Sub-rule return dispatch".to_string(), + description: None, + position: None, + system_generated: Some("sub_rule_runtime".to_string()), + kind: StudioStepKind::Decision { + branches: terminals + .iter() + .skip(1) + .enumerate() + .map(|(index, terminal)| StudioBranch { + id: format!("{}_b_{}", dispatcher_id, index), + label: Some(match &terminal.kind { + StudioStepKind::Terminal { code, .. } => code.clone(), + _ => String::new(), + }), + condition: StudioCondition::Simple { + left: variable_expr(format!("${}", terminal_id_var)), + operator: "eq".to_string(), + right: literal_string_expr(terminal.id.clone()), + }, + next_step_id: format!( + "{}__terminal_{}", + sub_rule_step_id, + safe_runtime_name(&terminal.id) + ), + }) + .collect(), + default_next_step_id: Some(parent_terminals[0].id.clone()), + }, + }; + + Some(TerminalReturnBridge { + child_steps, + parent_steps: std::iter::once(dispatcher) + .chain(parent_terminals) + .collect(), + next_step_id: dispatcher_id, + outputs, + }) +} + +fn merge_sub_rule_outputs( + existing: Vec, + generated: Vec, +) -> Vec { + let mut merged = existing; + for output in generated { + if !merged + .iter() + .any(|item| item.parent_var == output.parent_var && item.child_var == output.child_var) + { + merged.push(output); + } + } + merged +} + +fn materialize_terminal_propagation_for_steps( + steps: &mut Vec, + sub_rules: &mut hashbrown::HashMap, +) { + let mut step_ids = steps + .iter() + .map(|step| step.id.clone()) + .collect::>(); + let mut appended_steps = Vec::new(); + + for step in steps.iter_mut() { + let StudioStepKind::SubRule { + ref mut ref_name, + bindings: _, + ref mut outputs, + ref mut return_policy, + ref mut next_step_id, + } = step.kind + else { + continue; + }; + + let should_propagate_terminal = + matches!(return_policy.as_deref(), Some("propagate_terminal")) + || next_step_id.is_empty() + || !step_ids.contains(next_step_id); + if !should_propagate_terminal { + continue; + } + + let Some(graph) = sub_rules.get(ref_name).cloned() else { + continue; + }; + if !graph + .steps + .iter() + .any(|child| matches!(child.kind, StudioStepKind::Terminal { .. })) + { + continue; + } + + let Some(bridge) = create_terminal_return_bridge(&step.id, &graph.steps) else { + continue; + }; + + let bridged_ref_name = format!( + "{}__{}_terminal_return", + ref_name, + safe_runtime_name(&step.id) + ); + sub_rules.insert( + bridged_ref_name.clone(), + StudioSubRuleGraph { + entry_step: graph.entry_step, + steps: bridge.child_steps, + input_schema: graph.input_schema, + output_schema: graph.output_schema, + }, + ); + + for parent_step in &bridge.parent_steps { + step_ids.insert(parent_step.id.clone()); + } + appended_steps.extend(bridge.parent_steps); + *ref_name = bridged_ref_name; + *return_policy = Some("continue".to_string()); + *next_step_id = bridge.next_step_id; + *outputs = merge_sub_rule_outputs(outputs.clone(), bridge.outputs); + } + + if !appended_steps.is_empty() { + steps.extend(appended_steps); + } +} + +fn materialize_sub_rule_runtime(draft: &mut StudioRuleSet) { + const MAX_DEPTH: usize = 8; + + for _ in 0..MAX_DEPTH { + let before = draft.sub_rules.len(); + materialize_terminal_propagation_for_steps(&mut draft.steps, &mut draft.sub_rules); + + let names = draft.sub_rules.keys().cloned().collect::>(); + for name in names { + if let Some(mut graph) = draft.sub_rules.remove(&name) { + materialize_terminal_propagation_for_steps(&mut graph.steps, &mut draft.sub_rules); + draft.sub_rules.insert(name, graph); + } + } + + if before == draft.sub_rules.len() { + break; + } + } +} + +const CONCEPT_PRELUDE_STEP_ID: &str = "__ordo_concepts_prelude"; + +fn normalize_concept_ref(path: &str) -> String { + path.strip_prefix("$.") + .or_else(|| path.strip_prefix('$')) + .unwrap_or(path) + .to_string() +} + +fn scan_expression_refs(expression: &str, refs: &mut std::collections::HashSet) { + let mut chars = expression.char_indices().peekable(); + let mut quote: Option = None; + + while let Some((idx, ch)) = chars.next() { + if let Some(q) = quote { + if ch == '\\' { + chars.next(); + } else if ch == q { + quote = None; + } + continue; + } + + if ch == '"' || ch == '\'' { + quote = Some(ch); + continue; + } + + if !(ch.is_ascii_alphabetic() || ch == '_' || ch == '$') { + continue; + } + + let mut end = idx + ch.len_utf8(); + while let Some((next_idx, next)) = chars.peek().copied() { + if next.is_ascii_alphanumeric() || next == '_' || next == '.' || next == '$' { + end = next_idx + next.len_utf8(); + chars.next(); + } else { + break; + } + } + + let token = &expression[idx..end]; + let normalized = normalize_concept_ref(token); + let next_non_ws = expression[end..].chars().find(|c| !c.is_whitespace()); + if matches!( + normalized.as_str(), + "true" | "false" | "null" | "undefined" | "and" | "or" | "not" | "in" + ) || next_non_ws == Some('(') + { + continue; + } + refs.insert(normalized); + } +} + +fn collect_expr_refs(expr: &Expr, refs: &mut std::collections::HashSet) { + match expr { + Expr::Field(path) => { + refs.insert(normalize_concept_ref(path)); + } + Expr::Binary { left, right, .. } => { + collect_expr_refs(left, refs); + collect_expr_refs(right, refs); + } + Expr::Unary { operand, .. } => collect_expr_refs(operand, refs), + Expr::Call { args, .. } | Expr::Array(args) | Expr::Coalesce(args) => { + for arg in args { + collect_expr_refs(arg, refs); + } + } + Expr::Conditional { + condition, + then_branch, + else_branch, + } => { + collect_expr_refs(condition, refs); + collect_expr_refs(then_branch, refs); + collect_expr_refs(else_branch, refs); + } + Expr::Object(entries) => { + for (_, value) in entries { + collect_expr_refs(value, refs); + } + } + Expr::Literal(_) | Expr::Exists(_) => {} + } +} + +fn collect_condition_refs(condition: &Condition, refs: &mut std::collections::HashSet) { + match condition { + Condition::Always => {} + Condition::Expression(expr) => collect_expr_refs(expr, refs), + Condition::ExpressionString(expression) => match ExprParser::parse(expression) { + Ok(expr) => collect_expr_refs(&expr, refs), + Err(_) => scan_expression_refs(expression, refs), + }, + } +} + +fn collect_action_refs(action: &Action, refs: &mut std::collections::HashSet) { + match &action.kind { + ActionKind::SetVariable { value, .. } | ActionKind::Metric { value, .. } => { + collect_expr_refs(value, refs); + } + ActionKind::CallRuleSet { input_mapping, .. } => { + if let Some(expr) = input_mapping { + collect_expr_refs(expr, refs); + } + } + ActionKind::ExternalCall { params, .. } => { + for (_, expr) in params { + collect_expr_refs(expr, refs); + } + } + ActionKind::Log { .. } => {} + } +} + +fn collect_step_refs( + steps: &hashbrown::HashMap, +) -> std::collections::HashSet { + let mut refs = std::collections::HashSet::new(); + + for step in steps.values() { + match &step.kind { + StepKind::Decision { branches, .. } => { + for branch in branches { + collect_condition_refs(&branch.condition, &mut refs); + for action in &branch.actions { + collect_action_refs(action, &mut refs); + } + } + } + StepKind::Action { actions, .. } => { + for action in actions { + collect_action_refs(action, &mut refs); + } + } + StepKind::Terminal { result } => { + for (_, expr) in &result.output { + collect_expr_refs(expr, &mut refs); + } + } + StepKind::SubRule { bindings, .. } => { + for (_, expr) in bindings { + collect_expr_refs(expr, &mut refs); + } + } + } + } + + refs +} + +fn rewrite_expression_string_concept_refs( + expression: &str, + concept_names: &std::collections::HashSet, +) -> String { + let mut output = String::with_capacity(expression.len()); + let mut chars = expression.char_indices().peekable(); + let mut quote: Option = None; + + while let Some((idx, ch)) = chars.next() { + if let Some(q) = quote { + output.push(ch); + if ch == '\\' { + if let Some((_, escaped)) = chars.next() { + output.push(escaped); + } + } else if ch == q { + quote = None; + } + continue; + } + + if ch == '"' || ch == '\'' { + quote = Some(ch); + output.push(ch); + continue; + } + + if !(ch.is_ascii_alphabetic() || ch == '_' || ch == '$') { + output.push(ch); + continue; + } + + let mut end = idx + ch.len_utf8(); + while let Some((next_idx, next)) = chars.peek().copied() { + if next.is_ascii_alphanumeric() || next == '_' || next == '.' || next == '$' { + end = next_idx + next.len_utf8(); + chars.next(); + } else { + break; + } + } + + let token = &expression[idx..end]; + let normalized = normalize_concept_ref(token); + let next_non_ws = expression[end..].chars().find(|c| !c.is_whitespace()); + if concept_names.contains(&normalized) + && token != format!("${}", normalized) + && next_non_ws != Some('(') + { + output.push('$'); + output.push_str(&normalized); + } else { + output.push_str(token); + } + } + + output +} + +fn rewrite_expr_concept_refs(expr: &mut Expr, concept_names: &std::collections::HashSet) { + match expr { + Expr::Field(path) => { + let normalized = normalize_concept_ref(path); + if concept_names.contains(&normalized) && *path != format!("${}", normalized) { + *path = format!("${}", normalized); + } + } + Expr::Binary { left, right, .. } => { + rewrite_expr_concept_refs(left, concept_names); + rewrite_expr_concept_refs(right, concept_names); + } + Expr::Unary { operand, .. } => rewrite_expr_concept_refs(operand, concept_names), + Expr::Call { args, .. } | Expr::Array(args) | Expr::Coalesce(args) => { + for arg in args { + rewrite_expr_concept_refs(arg, concept_names); + } + } + Expr::Conditional { + condition, + then_branch, + else_branch, + } => { + rewrite_expr_concept_refs(condition, concept_names); + rewrite_expr_concept_refs(then_branch, concept_names); + rewrite_expr_concept_refs(else_branch, concept_names); + } + Expr::Object(entries) => { + for (_, value) in entries { + rewrite_expr_concept_refs(value, concept_names); + } + } + Expr::Literal(_) | Expr::Exists(_) => {} + } +} + +fn rewrite_condition_concept_refs( + condition: &mut Condition, + concept_names: &std::collections::HashSet, +) { + match condition { + Condition::Always => {} + Condition::Expression(expr) => rewrite_expr_concept_refs(expr, concept_names), + Condition::ExpressionString(expression) => { + *expression = rewrite_expression_string_concept_refs(expression, concept_names); + } + } +} + +fn rewrite_action_concept_refs( + action: &mut Action, + concept_names: &std::collections::HashSet, +) { + match &mut action.kind { + ActionKind::SetVariable { value, .. } | ActionKind::Metric { value, .. } => { + rewrite_expr_concept_refs(value, concept_names); + } + ActionKind::CallRuleSet { input_mapping, .. } => { + if let Some(expr) = input_mapping { + rewrite_expr_concept_refs(expr, concept_names); + } + } + ActionKind::ExternalCall { params, .. } => { + for (_, expr) in params { + rewrite_expr_concept_refs(expr, concept_names); + } + } + ActionKind::Log { .. } => {} + } +} + +fn rewrite_steps_concept_refs( + steps: &mut hashbrown::HashMap, + concept_names: &std::collections::HashSet, +) { + for step in steps.values_mut() { + match &mut step.kind { + StepKind::Decision { branches, .. } => { + for branch in branches { + rewrite_condition_concept_refs(&mut branch.condition, concept_names); + for action in &mut branch.actions { + rewrite_action_concept_refs(action, concept_names); + } + } + } + StepKind::Action { actions, .. } => { + for action in actions { + rewrite_action_concept_refs(action, concept_names); + } + } + StepKind::Terminal { result } => { + for (_, expr) in &mut result.output { + rewrite_expr_concept_refs(expr, concept_names); + } + } + StepKind::SubRule { bindings, .. } => { + for (_, expr) in bindings { + rewrite_expr_concept_refs(expr, concept_names); + } + } + } + } +} + +fn concept_expression_refs(concept: &ConceptDefinition) -> std::collections::HashSet { + let mut refs = concept + .dependencies + .iter() + .map(|dep| normalize_concept_ref(dep)) + .collect::>(); + match ExprParser::parse(&concept.expression) { + Ok(expr) => collect_expr_refs(&expr, &mut refs), + Err(_) => scan_expression_refs(&concept.expression, &mut refs), + } + refs +} + +fn resolve_concept_order( + roots: &std::collections::HashSet, + concepts: &[ConceptDefinition], +) -> ApiResult> { + let by_name = concepts + .iter() + .cloned() + .map(|concept| (concept.name.clone(), concept)) + .collect::>(); + let mut order = Vec::new(); + let mut visiting = std::collections::HashSet::::new(); + let mut visited = std::collections::HashSet::::new(); + + fn visit( + name: &str, + by_name: &std::collections::HashMap, + visiting: &mut std::collections::HashSet, + visited: &mut std::collections::HashSet, + order: &mut Vec, + ) -> ApiResult<()> { + let Some(concept) = by_name.get(name) else { + return Ok(()); + }; + if visited.contains(name) { + return Ok(()); + } + if !visiting.insert(name.to_string()) { + return Err(PlatformError::bad_request(format!( + "Concept dependency cycle detected at '{}'", + name + ))); + } + for dep in concept_expression_refs(concept) { + if by_name.contains_key(&dep) { + visit(&dep, by_name, visiting, visited, order)?; + } + } + visiting.remove(name); + visited.insert(name.to_string()); + order.push(concept.clone()); + Ok(()) + } + + for root in roots { + visit(root, &by_name, &mut visiting, &mut visited, &mut order)?; + } + + Ok(order) +} + +fn materialize_concepts_for_step_graph( + entry_step: &mut String, + steps: &mut hashbrown::HashMap, + concepts: &[ConceptDefinition], +) -> ApiResult<()> { + if concepts.is_empty() { + return Ok(()); + } + + steps.remove(CONCEPT_PRELUDE_STEP_ID); + if *entry_step == CONCEPT_PRELUDE_STEP_ID { + *entry_step = steps.keys().next().cloned().unwrap_or_default(); + } + + let concept_names = concepts + .iter() + .map(|concept| concept.name.clone()) + .collect::>(); + let roots = collect_step_refs(steps) + .into_iter() + .filter(|name| concept_names.contains(name)) + .collect::>(); + let order = resolve_concept_order(&roots, concepts)?; + + rewrite_steps_concept_refs(steps, &concept_names); + + if order.is_empty() { + return Ok(()); + } + + let mut actions = Vec::with_capacity(order.len()); + for concept in order { + let mut expr = ExprParser::parse(&concept.expression).map_err(|e| { + PlatformError::bad_request(format!( + "Concept '{}' expression failed to parse: {}", + concept.name, e + )) + })?; + rewrite_expr_concept_refs(&mut expr, &concept_names); + actions.push(Action::set_var(concept.name, expr)); + } + + let original_entry = entry_step.clone(); + steps.insert( + CONCEPT_PRELUDE_STEP_ID.to_string(), + Step::action( + CONCEPT_PRELUDE_STEP_ID, + "Compute Concepts", + actions, + original_entry, + ), + ); + *entry_step = CONCEPT_PRELUDE_STEP_ID.to_string(); + Ok(()) +} + +fn materialize_concepts_into_engine_ruleset( + ruleset: &mut RuleSet, + concepts: &[ConceptDefinition], +) -> ApiResult<()> { + materialize_concepts_for_step_graph( + &mut ruleset.config.entry_step, + &mut ruleset.steps, + concepts, + )?; + + for graph in ruleset.sub_rules.values_mut() { + materialize_concepts_for_step_graph(&mut graph.entry_step, &mut graph.steps, concepts)?; + } + + Ok(()) +} + +pub(crate) fn studio_draft_to_engine_json_with_concepts( + draft: &serde_json::Value, + concepts: &[ConceptDefinition], +) -> ApiResult { + let mut studio: StudioRuleSet = serde_json::from_value(draft.clone()).map_err(|e| { PlatformError::bad_request(format!("Draft is not valid studio format: {}", e)) })?; - let engine: RuleSet = studio + materialize_sub_rule_runtime(&mut studio); + let mut engine: RuleSet = studio .try_into() .map_err(|e: ordo_protocol::ConvertError| { PlatformError::bad_request(format!("Draft conversion failed: {}", e)) })?; + materialize_concepts_into_engine_ruleset(&mut engine, concepts)?; serde_json::to_value(&engine) .map_err(|e| PlatformError::internal(format!("Engine serialization failed: {}", e))) } @@ -647,3 +1614,90 @@ impl axum::response::IntoResponse for DraftSaveResponse { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::FactDataType; + + #[test] + fn studio_conversion_materializes_referenced_concepts() { + let draft = serde_json::json!({ + "config": { + "name": "coupon", + "version": "1.0.0" + }, + "startStepId": "start", + "steps": [ + { + "id": "start", + "name": "Check", + "type": "decision", + "branches": [ + { + "id": "b1", + "condition": { + "type": "expression", + "expression": "$.vip_eligible == true" + }, + "nextStepId": "ok" + } + ], + "defaultNextStepId": "no" + }, + { + "id": "ok", + "name": "OK", + "type": "terminal", + "code": "OK", + "message": { + "type": "literal", + "value": "ok", + "valueType": "string" + }, + "output": [] + }, + { + "id": "no", + "name": "NO", + "type": "terminal", + "code": "NO", + "message": { + "type": "literal", + "value": "no", + "valueType": "string" + }, + "output": [] + } + ] + }); + let concepts = vec![ConceptDefinition { + name: "vip_eligible".to_string(), + data_type: FactDataType::Boolean, + expression: "user_vip_level >= 2 && cart_amount >= 200".to_string(), + dependencies: vec![], + description: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }]; + + let engine = studio_draft_to_engine_json_with_concepts(&draft, &concepts).unwrap(); + + assert_eq!( + engine["config"]["entry_step"].as_str(), + Some(CONCEPT_PRELUDE_STEP_ID) + ); + assert_eq!( + engine["steps"][CONCEPT_PRELUDE_STEP_ID]["next_step"].as_str(), + Some("start") + ); + assert_eq!( + engine["steps"][CONCEPT_PRELUDE_STEP_ID]["actions"][0]["name"].as_str(), + Some("vip_eligible") + ); + assert_eq!( + engine["steps"]["start"]["branches"][0]["condition"].as_str(), + Some("$vip_eligible == true") + ); + } +} diff --git a/crates/ordo-platform/src/server_registry.rs b/crates/ordo-platform/src/server_registry.rs index 54bf03b5..f313b46d 100644 --- a/crates/ordo-platform/src/server_registry.rs +++ b/crates/ordo-platform/src/server_registry.rs @@ -8,8 +8,8 @@ //! GET /api/v1/servers — list all visible servers //! GET /api/v1/servers/:id — server detail //! DELETE /api/v1/servers/:id — remove server (admin+) -//! GET /api/v1/servers/:id/metrics — proxy to /metrics -//! GET /api/v1/servers/:id/health — proxy to /health +//! GET /api/v1/servers/:id/metrics — request metrics over NATS +//! GET /api/v1/servers/:id/health — request health over NATS //! PUT /api/v1/orgs/:oid/projects/:pid/server — bind project to server use crate::{ @@ -17,17 +17,18 @@ use crate::{ models::{derive_server_id, Claims, Role, ServerInfo, ServerNode, ServerStatus}, org::load_org_and_check_role, proxy::find_project_membership, - AppState, + sync, AppState, }; use axum::{ body::Body, extract::{Path, State}, - http::{HeaderMap, StatusCode}, + http::{header, HeaderMap, StatusCode}, response::Response, Extension, Json, }; use chrono::Utc; use serde::{Deserialize, Serialize}; +use std::time::Duration; // ── Internal (token-auth) ───────────────────────────────────────────────────── @@ -230,7 +231,45 @@ pub async fn delete_server( Ok(StatusCode::NO_CONTENT) } -/// GET /api/v1/servers/:id/metrics — proxy to server's Prometheus /metrics endpoint +const SERVER_RPC_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Deserialize)] +struct ServerRpcResponse { + status: u16, + content_type: Option, + body: serde_json::Value, + error: Option, +} + +async fn request_server_rpc( + state: &AppState, + server: &ServerNode, + endpoint: &str, +) -> ApiResult { + let client = state.nats_client.as_ref().ok_or_else(|| { + PlatformError::internal("NATS is not configured for server control requests") + })?; + let subject = sync::server_rpc_subject(&state.config.nats_subject_prefix, &server.id, endpoint); + let response = tokio::time::timeout( + SERVER_RPC_TIMEOUT, + client.request(subject.clone(), Vec::new().into()), + ) + .await + .map_err(|_| PlatformError::internal(format!("NATS request timed out: {}", subject)))? + .map_err(|e| PlatformError::internal(format!("NATS request failed: {}", e)))?; + + serde_json::from_slice(&response.payload) + .map_err(|e| PlatformError::internal(format!("Invalid NATS response: {}", e))) +} + +fn rpc_body_as_text(body: &serde_json::Value) -> String { + match body { + serde_json::Value::String(value) => value.clone(), + value => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()), + } +} + +/// GET /api/v1/servers/:id/metrics — request server Prometheus metrics over NATS pub async fn get_server_metrics( State(state): State, Extension(_claims): Extension, @@ -243,34 +282,20 @@ pub async fn get_server_metrics( .map_err(PlatformError::Internal)? .ok_or_else(|| PlatformError::not_found("Server not found"))?; - let resp = state - .http_client - .get(format!("{}/metrics", server.url)) - .send() - .await - .map_err(|e| PlatformError::internal(format!("Server unreachable: {}", e)))?; - - let status = axum::http::StatusCode::from_u16(resp.status().as_u16()) - .unwrap_or(axum::http::StatusCode::BAD_GATEWAY); - let content_type = resp - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or("text/plain") - .to_string(); - let body = resp - .bytes() - .await - .map_err(|e| PlatformError::internal(format!("Failed to read metrics: {}", e)))?; + let rpc = request_server_rpc(&state, &server, "metrics").await?; + let status = + axum::http::StatusCode::from_u16(rpc.status).unwrap_or(axum::http::StatusCode::BAD_GATEWAY); + let content_type = rpc.content_type.unwrap_or_else(|| "text/plain".to_string()); + let body = rpc.error.unwrap_or_else(|| rpc_body_as_text(&rpc.body)); Ok(Response::builder() .status(status) - .header("content-type", content_type) + .header(header::CONTENT_TYPE, content_type) .body(Body::from(body)) .unwrap()) } -/// GET /api/v1/servers/:id/health — proxy to server's /health endpoint +/// GET /api/v1/servers/:id/health — request server health over NATS pub async fn get_server_health( State(state): State, Extension(_claims): Extension, @@ -283,22 +308,26 @@ pub async fn get_server_health( .map_err(PlatformError::Internal)? .ok_or_else(|| PlatformError::not_found("Server not found"))?; - match state - .http_client - .get(format!("{}/health", server.url)) - .send() - .await - { - Ok(resp) => { - let ok = resp.status().is_success(); - let text = resp.text().await.unwrap_or_default(); - Ok(Json( - serde_json::json!({ "online": ok, "response": text, "url": server.url }), - )) + match request_server_rpc(&state, &server, "health").await { + Ok(rpc) => { + let online = (200..300).contains(&rpc.status); + let mut payload = serde_json::json!({ + "online": online, + "response": rpc_body_as_text(&rpc.body), + "url": server.url, + "transport": "nats", + }); + if let Some(error) = rpc.error { + payload["error"] = serde_json::Value::String(error); + } + Ok(Json(payload)) } - Err(e) => Ok(Json( - serde_json::json!({ "online": false, "error": e.to_string(), "url": server.url }), - )), + Err(e) => Ok(Json(serde_json::json!({ + "online": false, + "error": e.to_string(), + "url": server.url, + "transport": "nats", + }))), } } diff --git a/crates/ordo-platform/src/store.rs b/crates/ordo-platform/src/store.rs index c89a7376..e95c0d76 100644 --- a/crates/ordo-platform/src/store.rs +++ b/crates/ordo-platform/src/store.rs @@ -11,8 +11,9 @@ use crate::models::{ ReleasePolicyScope, ReleasePolicyTargetType, ReleaseRequest, ReleaseRequestHistoryEntry, ReleaseRequestSnapshot, ReleaseRequestStatus, ReleaseVersionDiff, Role, RollbackPolicy, RolloutStrategy, RulesetDeployment, RulesetHistoryEntry, RulesetHistorySource, ServerNode, - ServerStatus, TestCase, TestExpectation, UpdateEnvironmentRequest, UpdateReleasePolicyRequest, - UpdateRoleRequest, User, UserRoleAssignment, + ServerStatus, SubRuleAsset, SubRuleAssetMeta, SubRuleScope, TestCase, TestExpectation, + UpdateEnvironmentRequest, UpdateReleasePolicyRequest, UpdateRoleRequest, User, + UserRoleAssignment, }; use anyhow::Result; use serde_json::Value as JsonValue; @@ -32,6 +33,7 @@ mod releases; mod rows; mod rulesets; mod servers; +mod sub_rules; mod users; use self::codec::*; diff --git a/crates/ordo-platform/src/store/sub_rules.rs b/crates/ordo-platform/src/store/sub_rules.rs new file mode 100644 index 00000000..56216310 --- /dev/null +++ b/crates/ordo-platform/src/store/sub_rules.rs @@ -0,0 +1,231 @@ +use super::*; +use std::str::FromStr; + +impl PlatformStore { + pub async fn list_project_sub_rules( + &self, + org_id: &str, + project_id: &str, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, org_id, project_id, scope, name, display_name, description, + draft_seq, draft_updated_at, draft_updated_by, + created_at, created_by + FROM sub_rule_assets + WHERE org_id = $1 AND (scope = 'org' OR project_id = $2) + ORDER BY scope, name", + ) + .bind(org_id) + .bind(project_id) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(row_to_sub_rule_meta).collect() + } + + pub async fn list_org_sub_rules(&self, org_id: &str) -> Result> { + let rows = sqlx::query( + "SELECT id, org_id, project_id, scope, name, display_name, description, + draft_seq, draft_updated_at, draft_updated_by, + created_at, created_by + FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'org' + ORDER BY name", + ) + .bind(org_id) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(row_to_sub_rule_meta).collect() + } + + pub async fn get_sub_rule_asset( + &self, + org_id: &str, + scope: SubRuleScope, + project_id: Option<&str>, + name: &str, + ) -> Result> { + let row = match scope { + SubRuleScope::Org => { + sqlx::query( + "SELECT id, org_id, project_id, scope, name, display_name, description, + draft, input_schema, output_schema, + draft_seq, draft_updated_at, draft_updated_by, + created_at, created_by + FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'org' AND name = $2", + ) + .bind(org_id) + .bind(name) + .fetch_optional(&self.pool) + .await? + } + SubRuleScope::Project => { + let project_id = project_id.ok_or_else(|| { + anyhow::anyhow!("project_id is required for project-scoped sub-rule") + })?; + sqlx::query( + "SELECT id, org_id, project_id, scope, name, display_name, description, + draft, input_schema, output_schema, + draft_seq, draft_updated_at, draft_updated_by, + created_at, created_by + FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'project' AND project_id = $2 AND name = $3", + ) + .bind(org_id) + .bind(project_id) + .bind(name) + .fetch_optional(&self.pool) + .await? + } + }; + + row.map(row_to_sub_rule_asset).transpose() + } + + #[allow(clippy::too_many_arguments)] + pub async fn upsert_sub_rule_asset( + &self, + id: &str, + org_id: &str, + project_id: Option<&str>, + scope: SubRuleScope, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + draft: &JsonValue, + input_schema: &JsonValue, + output_schema: &JsonValue, + expected_seq: Option, + user_id: &str, + ) -> Result { + let existing = self + .get_sub_rule_asset(org_id, scope.clone(), project_id, name) + .await?; + + if let Some(existing) = existing { + if let Some(expected_seq) = expected_seq { + if existing.meta.draft_seq != expected_seq { + return Err(anyhow::anyhow!("conflict")); + } + } + + sqlx::query( + "UPDATE sub_rule_assets SET + display_name = $1, + description = $2, + draft = $3, + input_schema = $4, + output_schema = $5, + draft_seq = draft_seq + 1, + draft_updated_at = NOW(), + draft_updated_by = $6 + WHERE id = $7", + ) + .bind(display_name) + .bind(description) + .bind(sqlx::types::Json(draft)) + .bind(sqlx::types::Json(input_schema)) + .bind(sqlx::types::Json(output_schema)) + .bind(user_id) + .bind(&existing.meta.id) + .execute(&self.pool) + .await?; + } else { + sqlx::query( + "INSERT INTO sub_rule_assets ( + id, org_id, project_id, scope, name, display_name, description, + draft, input_schema, output_schema, draft_seq, + draft_updated_at, draft_updated_by, created_at, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1, NOW(), $11, NOW(), $11)", + ) + .bind(id) + .bind(org_id) + .bind(project_id) + .bind(scope.to_string()) + .bind(name) + .bind(display_name) + .bind(description) + .bind(sqlx::types::Json(draft)) + .bind(sqlx::types::Json(input_schema)) + .bind(sqlx::types::Json(output_schema)) + .bind(user_id) + .execute(&self.pool) + .await?; + } + + self.get_sub_rule_asset(org_id, scope, project_id, name) + .await? + .ok_or_else(|| anyhow::anyhow!("sub-rule asset was not found after upsert")) + } + + pub async fn delete_sub_rule_asset( + &self, + org_id: &str, + scope: SubRuleScope, + project_id: Option<&str>, + name: &str, + ) -> Result { + let result = match scope { + SubRuleScope::Org => { + sqlx::query( + "DELETE FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'org' AND name = $2", + ) + .bind(org_id) + .bind(name) + .execute(&self.pool) + .await? + } + SubRuleScope::Project => { + let project_id = project_id.ok_or_else(|| { + anyhow::anyhow!("project_id is required for project-scoped sub-rule") + })?; + sqlx::query( + "DELETE FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'project' AND project_id = $2 AND name = $3", + ) + .bind(org_id) + .bind(project_id) + .bind(name) + .execute(&self.pool) + .await? + } + }; + + Ok(result.rows_affected() > 0) + } +} + +fn row_to_sub_rule_meta(row: sqlx::postgres::PgRow) -> Result { + let scope: String = row.get("scope"); + Ok(SubRuleAssetMeta { + id: row.get("id"), + org_id: row.get("org_id"), + project_id: row.get("project_id"), + scope: SubRuleScope::from_str(&scope).map_err(anyhow::Error::msg)?, + name: row.get("name"), + display_name: row.get("display_name"), + description: row.get("description"), + draft_seq: row.get("draft_seq"), + draft_updated_at: row.get("draft_updated_at"), + draft_updated_by: row.get("draft_updated_by"), + created_at: row.get("created_at"), + created_by: row.get("created_by"), + }) +} + +fn row_to_sub_rule_asset(row: sqlx::postgres::PgRow) -> Result { + let draft: sqlx::types::Json = row.get("draft"); + let input_schema: sqlx::types::Json = row.get("input_schema"); + let output_schema: sqlx::types::Json = row.get("output_schema"); + + Ok(SubRuleAsset { + meta: row_to_sub_rule_meta(row)?, + draft: draft.0, + input_schema: input_schema.0, + output_schema: output_schema.0, + }) +} diff --git a/crates/ordo-platform/src/sub_rules.rs b/crates/ordo-platform/src/sub_rules.rs new file mode 100644 index 00000000..17935f67 --- /dev/null +++ b/crates/ordo-platform/src/sub_rules.rs @@ -0,0 +1,217 @@ +//! Managed SubRule asset API. +//! +//! Sub-rules are standalone decision graphs referenced by parent rulesets. +//! Their content is snapshotted inline when the parent ruleset is published — +//! no separate sub-rule publish/version flow is needed. + +use crate::{ + error::{ApiResult, PlatformError}, + models::{Claims, SaveSubRuleAssetRequest, SubRuleAsset, SubRuleAssetMeta, SubRuleScope}, + rbac::{require_permission, require_project_permission, PERM_RULESET_EDIT, PERM_RULESET_VIEW}, + AppState, +}; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Extension, Json, +}; +use uuid::Uuid; + +#[derive(Debug, serde::Deserialize)] +pub struct ListProjectSubRulesQuery { + #[serde(default = "default_include_org")] + pub include_org: bool, +} + +fn default_include_org() -> bool { + true +} + +/// GET /api/v1/orgs/:oid/sub-rules +pub async fn list_org_sub_rules( + State(state): State, + Extension(claims): Extension, + Path(org_id): Path, +) -> ApiResult>> { + require_permission(&state, &org_id, &claims.sub, PERM_RULESET_VIEW).await?; + let assets = state + .store + .list_org_sub_rules(&org_id) + .await + .map_err(PlatformError::Internal)?; + Ok(Json(assets)) +} + +/// PUT /api/v1/orgs/:oid/sub-rules/:name +pub async fn save_org_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, name)): Path<(String, String)>, + Json(req): Json, +) -> ApiResult> { + require_permission(&state, &org_id, &claims.sub, PERM_RULESET_EDIT).await?; + let name = normalize_name(&name, &req.name)?; + let asset = state + .store + .upsert_sub_rule_asset( + &Uuid::new_v4().to_string(), + &org_id, + None, + SubRuleScope::Org, + &name, + req.display_name.as_deref(), + req.description.as_deref(), + &req.draft, + &req.input_schema, + &req.output_schema, + req.expected_seq, + &claims.sub, + ) + .await + .map_err(map_conflict)?; + Ok(Json(asset)) +} + +/// GET /api/v1/orgs/:oid/sub-rules/:name +pub async fn get_org_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, name)): Path<(String, String)>, +) -> ApiResult> { + require_permission(&state, &org_id, &claims.sub, PERM_RULESET_VIEW).await?; + let asset = state + .store + .get_sub_rule_asset(&org_id, SubRuleScope::Org, None, &name) + .await + .map_err(PlatformError::Internal)? + .ok_or_else(|| PlatformError::not_found("SubRule not found"))?; + Ok(Json(asset)) +} + +/// DELETE /api/v1/orgs/:oid/sub-rules/:name +pub async fn delete_org_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, name)): Path<(String, String)>, +) -> ApiResult { + require_permission(&state, &org_id, &claims.sub, PERM_RULESET_EDIT).await?; + let deleted = state + .store + .delete_sub_rule_asset(&org_id, SubRuleScope::Org, None, &name) + .await + .map_err(PlatformError::Internal)?; + if !deleted { + return Err(PlatformError::not_found("SubRule not found")); + } + Ok(StatusCode::NO_CONTENT) +} + +/// GET /api/v1/orgs/:oid/projects/:pid/sub-rules +pub async fn list_project_sub_rules( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id)): Path<(String, String)>, + Query(q): Query, +) -> ApiResult>> { + require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_VIEW) + .await?; + let mut assets = state + .store + .list_project_sub_rules(&org_id, &project_id) + .await + .map_err(PlatformError::Internal)?; + if !q.include_org { + assets.retain(|a| a.scope == SubRuleScope::Project); + } + Ok(Json(assets)) +} + +/// PUT /api/v1/orgs/:oid/projects/:pid/sub-rules/:name +pub async fn save_project_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id, name)): Path<(String, String, String)>, + Json(req): Json, +) -> ApiResult> { + require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_EDIT) + .await?; + let name = normalize_name(&name, &req.name)?; + let asset = state + .store + .upsert_sub_rule_asset( + &Uuid::new_v4().to_string(), + &org_id, + Some(&project_id), + SubRuleScope::Project, + &name, + req.display_name.as_deref(), + req.description.as_deref(), + &req.draft, + &req.input_schema, + &req.output_schema, + req.expected_seq, + &claims.sub, + ) + .await + .map_err(map_conflict)?; + Ok(Json(asset)) +} + +/// GET /api/v1/orgs/:oid/projects/:pid/sub-rules/:name +pub async fn get_project_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id, name)): Path<(String, String, String)>, +) -> ApiResult> { + require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_VIEW) + .await?; + let asset = state + .store + .get_sub_rule_asset(&org_id, SubRuleScope::Project, Some(&project_id), &name) + .await + .map_err(PlatformError::Internal)? + .ok_or_else(|| PlatformError::not_found("SubRule not found"))?; + Ok(Json(asset)) +} + +/// DELETE /api/v1/orgs/:oid/projects/:pid/sub-rules/:name +pub async fn delete_project_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id, name)): Path<(String, String, String)>, +) -> ApiResult { + require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_EDIT) + .await?; + let deleted = state + .store + .delete_sub_rule_asset(&org_id, SubRuleScope::Project, Some(&project_id), &name) + .await + .map_err(PlatformError::Internal)?; + if !deleted { + return Err(PlatformError::not_found("SubRule not found")); + } + Ok(StatusCode::NO_CONTENT) +} + +fn normalize_name(path_name: &str, body_name: &str) -> ApiResult { + let path_name = path_name.trim(); + let body_name = body_name.trim(); + if path_name.is_empty() || body_name.is_empty() { + return Err(PlatformError::bad_request("SubRule name is required")); + } + if path_name != body_name { + return Err(PlatformError::bad_request( + "SubRule path name and body name must match", + )); + } + Ok(path_name.to_string()) +} + +fn map_conflict(err: anyhow::Error) -> PlatformError { + let message = err.to_string(); + if message == "conflict" { + PlatformError::conflict("SubRule draft has changed") + } else { + PlatformError::Internal(err) + } +} diff --git a/crates/ordo-platform/src/sync.rs b/crates/ordo-platform/src/sync.rs index d5b81dec..7cb97096 100644 --- a/crates/ordo-platform/src/sync.rs +++ b/crates/ordo-platform/src/sync.rs @@ -177,6 +177,38 @@ pub async fn connect(nats_url: &str) -> Result Result { + let (options, server_addr) = connect_options_and_addr(nats_url)?; + Ok(options.connect(server_addr.as_str()).await?) +} + +fn rpc_prefix(subject_prefix: &str) -> String { + let safe_prefix = subject_prefix + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect::(); + if safe_prefix.is_empty() { + "_ORDO_RPC.default".to_string() + } else { + format!("_ORDO_RPC.{}", safe_prefix) + } +} + +pub fn server_rpc_subject(subject_prefix: &str, server_id: &str, endpoint: &str) -> String { + format!( + "{}.servers.{}.{}", + rpc_prefix(subject_prefix), + server_id, + endpoint + ) +} + pub async fn ensure_stream( jetstream: &jetstream::Context, subject_prefix: &str, @@ -303,14 +335,11 @@ pub fn start_registry_subscriber( let existing = store.get_server(&effective_server_id).await; match existing { Ok(existing_opt) => { - let preserved_token = if token.is_empty() { - existing_opt - .as_ref() - .map(|s| s.token.clone()) - .unwrap_or_default() - } else { - token - }; + let preserved_token = resolve_server_registry_token( + token, + existing_opt.as_ref().map(|s| s.token.as_str()), + &effective_server_id, + ); // Reject if a different server already owns this token if !preserved_token.is_empty() { match store @@ -569,3 +598,46 @@ pub fn start_registry_subscriber( } }) } + +fn resolve_server_registry_token( + incoming_token: String, + existing_token: Option<&str>, + server_id: &str, +) -> String { + if !incoming_token.is_empty() { + incoming_token + } else if let Some(existing_token) = existing_token.filter(|token| !token.is_empty()) { + existing_token.to_string() + } else { + format!("nats:{}", server_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_rpc_subject_uses_non_stream_prefix() { + assert_eq!( + server_rpc_subject("ordo.rules", "srv_abc", "health"), + "_ORDO_RPC.ordo_rules.servers.srv_abc.health" + ); + } + + #[test] + fn nats_server_registration_gets_stable_synthetic_token() { + assert_eq!( + resolve_server_registry_token(String::new(), None, "srv_abc"), + "nats:srv_abc" + ); + assert_eq!( + resolve_server_registry_token(String::new(), Some("existing"), "srv_abc"), + "existing" + ); + assert_eq!( + resolve_server_registry_token("incoming".to_string(), Some("existing"), "srv_abc"), + "incoming" + ); + } +} diff --git a/crates/ordo-platform/src/testing.rs b/crates/ordo-platform/src/testing.rs index a5a91a8c..396b0542 100644 --- a/crates/ordo-platform/src/testing.rs +++ b/crates/ordo-platform/src/testing.rs @@ -16,7 +16,8 @@ use crate::{ error::{ApiResult, PlatformError}, models::{ Claims, ProjectTestRunResult, Role, RulesetTestSummary, TestCase, TestExecutionTrace, - TestExecutionTraceStep, TestExpectation, TestRunResult, + TestExecutionTraceStep, TestExpectation, TestFailureDetail, TestFailureKind, TestRunResult, + TestSubRuleOutputTrace, }, AppState, }; @@ -29,7 +30,7 @@ use chrono::Utc; use ordo_core::{ context::Value as CoreValue, rule::{ExecutionOptions, RuleExecutor, RuleSet}, - trace::ExecutionTrace, + trace::{ExecutionTrace, StepTrace, TraceConfig}, }; use ordo_protocol::StudioRuleSet; use serde::{Deserialize, Serialize}; @@ -80,6 +81,30 @@ fn default_include_trace() -> bool { true } +#[derive(Debug, Deserialize)] +pub struct AdHocTestRunRequest { + pub ruleset: JsonValue, + #[serde(default = "default_ad_hoc_test_name")] + pub name: String, + pub input: JsonValue, + #[serde(default = "default_expectation")] + pub expect: TestExpectation, + #[serde(default = "default_include_trace")] + pub include_trace: bool, +} + +fn default_ad_hoc_test_name() -> String { + "Ad-hoc test".to_string() +} + +fn default_expectation() -> TestExpectation { + TestExpectation { + code: None, + message: None, + output: None, + } +} + // ── ordo-cli compatible export format ──────────────────────────────────────── /// Matches the TestCase format expected by ordo-cli's test_runner.rs. @@ -305,6 +330,39 @@ pub async fn run_one_test( Ok(Json(results.remove(0))) } +/// POST /api/v1/projects/:pid/tests/run-ad-hoc +pub async fn run_ad_hoc_test( + State(state): State, + Extension(claims): Extension, + Path(project_id): Path, + Json(req): Json, +) -> ApiResult> { + let (_org_id, _role) = resolve_project(&state, &project_id, &claims.sub, None).await?; + let now = Utc::now(); + let tc = TestCase { + id: Uuid::new_v4().to_string(), + name: req.name, + description: None, + input: req.input, + expect: req.expect, + tags: vec!["ad-hoc".to_string()], + created_at: now, + updated_at: now, + created_by: claims.sub, + }; + + let mut results = execute_tests( + &state, + &project_id, + "__ad_hoc__", + std::slice::from_ref(&tc), + Some(&req.ruleset), + req.include_trace, + ) + .await?; + Ok(Json(results.remove(0))) +} + /// GET /api/v1/projects/:pid/tests/run pub async fn run_project_tests( State(state): State, @@ -345,13 +403,15 @@ pub async fn run_project_tests( ) .await .unwrap_or_else(|err| { + let message = err.to_string(); tests .iter() .map(|t| TestRunResult { test_id: t.id.clone(), test_name: t.name.clone(), passed: false, - failures: vec![err.to_string()], + failures: vec![message.clone()], + failure_details: vec![failure_detail(&message, None)], duration_us: 0, actual_code: None, actual_message: None, @@ -473,7 +533,11 @@ async fn execute_tests( e )) })?; - let executor = RuleExecutor::new(); + let executor = if include_trace { + RuleExecutor::with_trace(TraceConfig::full()) + } else { + RuleExecutor::new() + }; let mut results = Vec::with_capacity(tests.len()); for tc in tests { @@ -481,11 +545,13 @@ async fn execute_tests( let input: CoreValue = match serde_json::from_value(tc.input.clone()) { Ok(input) => input, Err(e) => { + let message = format!("Invalid test input: {}", e); results.push(TestRunResult { test_id: tc.id.clone(), test_name: tc.name.clone(), passed: false, - failures: vec![format!("Invalid test input: {}", e)], + failures: vec![message.clone()], + failure_details: vec![failure_detail(&message, None)], duration_us: start.elapsed().as_micros() as u64, actual_code: None, actual_message: None, @@ -506,18 +572,26 @@ async fn execute_tests( let actual_message = Some(result.message.clone()); let actual_output = serde_json::to_value(&result.output).ok(); let trace = result.trace.as_ref().map(map_trace); - let failures = compare_expectation( - &tc.expect, - actual_code.as_deref(), - actual_message.as_deref(), - actual_output.as_ref(), + let failure_details = attach_trace_target( + compare_expectation( + &tc.expect, + actual_code.as_deref(), + actual_message.as_deref(), + actual_output.as_ref(), + ), + trace.as_ref(), ); + let failures = failure_details + .iter() + .map(|detail| detail.message.clone()) + .collect(); TestRunResult { test_id: tc.id.clone(), test_name: tc.name.clone(), - passed: failures.is_empty(), + passed: failure_details.is_empty(), failures, + failure_details, duration_us: elapsed_us.max(result.duration_us), actual_code, actual_message, @@ -525,17 +599,21 @@ async fn execute_tests( trace, } } - Err(e) => TestRunResult { - test_id: tc.id.clone(), - test_name: tc.name.clone(), - passed: false, - failures: vec![format!("Execution failed: {}", e)], - duration_us: elapsed_us, - actual_code: None, - actual_message: None, - actual_output: None, - trace: None, - }, + Err(e) => { + let message = format!("Execution failed: {}", e); + TestRunResult { + test_id: tc.id.clone(), + test_name: tc.name.clone(), + passed: false, + failures: vec![message.clone()], + failure_details: vec![failure_detail(&message, None)], + duration_us: elapsed_us, + actual_code: None, + actual_message: None, + actual_output: None, + trace: None, + } + } }; results.push(run_result); @@ -614,35 +692,171 @@ fn compile_ruleset(ruleset: &JsonValue) -> anyhow::Result { RuleSet::from_json_compiled(&ruleset_json).map_err(|e| anyhow::anyhow!("{}", e)) } +fn failure_detail(message: &str, step_id: Option) -> TestFailureDetail { + TestFailureDetail { + message: message.to_string(), + kind: classify_failure(message), + step_id, + sub_rule_ref: extract_sub_rule_ref(message), + trace_path: Vec::new(), + } +} + +fn classify_failure(message: &str) -> TestFailureKind { + let lower = message.to_lowercase(); + if lower.contains("sub-rule") && lower.contains("not found") { + TestFailureKind::Reference + } else if lower.contains("contract") || lower.contains("schema") { + TestFailureKind::Contract + } else if lower.contains("field not found") || lower.contains("evaluation error") { + TestFailureKind::Binding + } else if lower.contains("output") || lower.contains("write") { + TestFailureKind::Output + } else if lower.contains("subrule") || lower.contains("sub-rule") { + TestFailureKind::SubRule + } else if lower.contains("execution failed") || lower.contains("invalid test input") { + TestFailureKind::Execution + } else { + TestFailureKind::Assertion + } +} + +fn extract_sub_rule_ref(message: &str) -> Option { + let marker = "Sub-rule '"; + let start = message.find(marker)? + marker.len(); + let rest = &message[start..]; + let end = rest.find('\'')?; + Some(rest[..end].to_string()) +} + +fn attach_trace_target( + mut failures: Vec, + trace: Option<&TestExecutionTrace>, +) -> Vec { + for failure in &mut failures { + if let Some(trace) = trace { + if let Some(target) = locate_failure_trace_target(trace, failure) { + failure.step_id = Some(target.step_id); + failure.trace_path = target.path; + if failure.sub_rule_ref.is_none() { + failure.sub_rule_ref = target.sub_rule_ref; + } + continue; + } + if failure.step_id.is_none() { + failure.step_id = trace.steps.last().map(|step| step.id.clone()); + } + if failure.trace_path.is_empty() { + failure.trace_path = trace.path.clone(); + } + } + } + failures +} + +#[derive(Debug, Clone)] +struct FailureTraceTarget { + step_id: String, + path: Vec, + sub_rule_ref: Option, +} + +fn locate_failure_trace_target( + trace: &TestExecutionTrace, + failure: &TestFailureDetail, +) -> Option { + if let Some(step_id) = failure.step_id.as_deref() { + return find_trace_target_by_step_id(&trace.steps, step_id, &[]); + } + + if matches!( + failure.kind, + TestFailureKind::Binding | TestFailureKind::Output | TestFailureKind::SubRule + ) { + return find_deepest_trace_target(&trace.steps, &[]); + } + + None +} + +fn find_trace_target_by_step_id( + steps: &[TestExecutionTraceStep], + step_id: &str, + path_prefix: &[String], +) -> Option { + for step in steps { + let mut path = path_prefix.to_vec(); + path.push(step.id.clone()); + + if step.id == step_id { + return Some(FailureTraceTarget { + step_id: step.id.clone(), + path, + sub_rule_ref: step.sub_rule_ref.clone(), + }); + } + + if let Some(found) = find_trace_target_by_step_id(&step.sub_rule_frames, step_id, &path) { + return Some(found); + } + } + None +} + +fn find_deepest_trace_target( + steps: &[TestExecutionTraceStep], + path_prefix: &[String], +) -> Option { + let step = steps.last()?; + let mut path = path_prefix.to_vec(); + path.push(step.id.clone()); + + if let Some(found) = find_deepest_trace_target(&step.sub_rule_frames, &path) { + return Some(found); + } + + Some(FailureTraceTarget { + step_id: step.id.clone(), + path, + sub_rule_ref: step.sub_rule_ref.clone(), + }) +} + fn compare_expectation( expect: &TestExpectation, actual_code: Option<&str>, actual_message: Option<&str>, actual_output: Option<&JsonValue>, -) -> Vec { +) -> Vec { let mut failures = Vec::new(); if let Some(expected_code) = &expect.code { match actual_code { Some(actual) if actual == expected_code => {} - Some(actual) => failures.push(format!( - "code: expected \"{}\", got \"{}\"", - expected_code, actual + Some(actual) => failures.push(failure_detail( + &format!("code: expected \"{}\", got \"{}\"", expected_code, actual), + None, + )), + None => failures.push(failure_detail( + &format!("code: expected \"{}\", got none", expected_code), + None, )), - None => failures.push(format!("code: expected \"{}\", got none", expected_code)), } } if let Some(expected_message) = &expect.message { match actual_message { Some(actual) if actual == expected_message => {} - Some(actual) => failures.push(format!( - "message: expected \"{}\", got \"{}\"", - expected_message, actual + Some(actual) => failures.push(failure_detail( + &format!( + "message: expected \"{}\", got \"{}\"", + expected_message, actual + ), + None, )), - None => failures.push(format!( - "message: expected \"{}\", got none", - expected_message + None => failures.push(failure_detail( + &format!("message: expected \"{}\", got none", expected_message), + None, )), } } @@ -656,30 +870,42 @@ fn compare_expectation( for (key, expected_val) in expected_fields { match actual_fields.get(key) { Some(actual) if actual == expected_val => {} - Some(actual) => failures.push(format!( - "output.{}: expected {}, got {}", - key, - json_string(expected_val), - json_string(actual) + Some(actual) => failures.push(failure_detail( + &format!( + "output.{}: expected {}, got {}", + key, + json_string(expected_val), + json_string(actual) + ), + None, )), - None => failures.push(format!( - "output.{}: expected {}, got missing", - key, - json_string(expected_val) + None => failures.push(failure_detail( + &format!( + "output.{}: expected {}, got missing", + key, + json_string(expected_val) + ), + None, )), } } } _ => match actual_output { Some(actual) if actual == expected_output => {} - Some(actual) => failures.push(format!( - "output: expected {}, got {}", - json_string(expected_output), - json_string(actual) + Some(actual) => failures.push(failure_detail( + &format!( + "output: expected {}, got {}", + json_string(expected_output), + json_string(actual) + ), + None, )), - None => failures.push(format!( - "output: expected {}, got none", - json_string(expected_output) + None => failures.push(failure_detail( + &format!( + "output: expected {}, got none", + json_string(expected_output) + ), + None, )), }, } @@ -704,24 +930,139 @@ fn map_trace(trace: &ExecutionTrace) -> TestExecutionTrace { result_code: trace.result_code.clone(), total_duration_us: trace.total_duration_us, error: trace.error.clone(), - steps: trace - .steps - .iter() - .map(|step| TestExecutionTraceStep { - id: step.step_id.clone(), - name: step.step_name.clone(), - duration_us: step.duration_us, - next_step: step.next_step.clone(), - is_terminal: step.is_terminal, - input_snapshot: step - .input_snapshot - .as_ref() - .and_then(|value| serde_json::to_value(value).ok()), - variables_snapshot: step - .variables_snapshot - .as_ref() - .and_then(|value| serde_json::to_value(value).ok()), + steps: trace.steps.iter().map(map_trace_step).collect(), + } +} + +fn map_trace_step(step: &StepTrace) -> TestExecutionTraceStep { + TestExecutionTraceStep { + id: step.step_id.clone(), + name: step.step_name.clone(), + duration_us: step.duration_us, + next_step: step.next_step.clone(), + is_terminal: step.is_terminal, + input_snapshot: step + .input_snapshot + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + variables_snapshot: step + .variables_snapshot + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + sub_rule_ref: step + .sub_rule_call + .as_ref() + .map(|call| call.ref_name.clone()), + sub_rule_input: step + .sub_rule_call + .as_ref() + .and_then(|call| serde_json::to_value(&call.input).ok()), + sub_rule_outputs: step + .sub_rule_call + .as_ref() + .map(|call| { + call.outputs + .iter() + .map(|output| TestSubRuleOutputTrace { + parent_var: output.parent_var.clone(), + child_var: output.child_var.clone(), + value: output + .value + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + missing: output.missing, + }) + .collect() }) - .collect(), + .unwrap_or_default(), + sub_rule_frames: step + .sub_rule_frames + .as_ref() + .map(|frames| frames.iter().map(map_trace_step).collect()) + .unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ordo_core::trace::{SubRuleCallTrace, SubRuleOutputTrace}; + + #[test] + fn map_trace_preserves_parent_sub_rule_call_details() { + let child_output: CoreValue = serde_json::from_value(serde_json::json!("gold")).unwrap(); + let child_input: CoreValue = + serde_json::from_value(serde_json::json!({ "score": 95 })).unwrap(); + + let child_terminal = StepTrace::terminal("grade_gold", "Grade gold", 7); + let mut parent_call = + StepTrace::continued("call_classifier", "Call classifier", 21, "finish"); + parent_call.sub_rule_call = Some(SubRuleCallTrace { + ref_name: "classify_score".to_string(), + input: child_input, + outputs: vec![SubRuleOutputTrace { + parent_var: "customer_tier".to_string(), + child_var: "tier".to_string(), + value: Some(child_output), + missing: false, + }], + }); + parent_call.sub_rule_frames = Some(vec![child_terminal]); + + let mut trace = ExecutionTrace::new("checkout_policy"); + trace.add_step(parent_call); + trace.set_result("APPROVED", 32); + + let mapped = map_trace(&trace); + assert_eq!(mapped.path, vec!["call_classifier"]); + assert_eq!(mapped.result_code, "APPROVED"); + + let step = &mapped.steps[0]; + assert_eq!(step.sub_rule_ref.as_deref(), Some("classify_score")); + assert_eq!(step.sub_rule_input.as_ref().unwrap()["score"], 95); + assert_eq!(step.sub_rule_outputs.len(), 1); + assert_eq!(step.sub_rule_outputs[0].parent_var, "customer_tier"); + assert_eq!(step.sub_rule_outputs[0].child_var, "tier"); + assert_eq!( + step.sub_rule_outputs[0].value.as_ref().unwrap(), + &serde_json::json!("gold") + ); + assert!(!step.sub_rule_outputs[0].missing); + + assert_eq!(step.sub_rule_frames.len(), 1); + assert_eq!(step.sub_rule_frames[0].id, "grade_gold"); + assert!(step.sub_rule_frames[0].is_terminal); + } + + #[test] + fn attach_trace_target_prefers_deepest_sub_rule_frame() { + let mut top = StepTrace::continued("check_parent", "Check parent", 12, "finish"); + top.sub_rule_call = Some(SubRuleCallTrace { + ref_name: "child_rule".to_string(), + input: serde_json::from_value(serde_json::json!({ "score": 95 })).unwrap(), + outputs: Vec::new(), + }); + + let child_terminal = StepTrace::terminal("child_terminal", "Child terminal", 4); + top.sub_rule_frames = Some(vec![child_terminal]); + + let mut trace = ExecutionTrace::new("parent_rule"); + trace.add_step(top); + trace.set_result("NO_COUPON", 18); + let mapped = map_trace(&trace); + + let failures = attach_trace_target( + vec![failure_detail( + "Execution failed: Field not found: score", + None, + )], + Some(&mapped), + ); + + assert_eq!(failures[0].step_id.as_deref(), Some("child_terminal")); + assert_eq!( + failures[0].trace_path, + vec!["check_parent".to_string(), "child_terminal".to_string()] + ); } } diff --git a/crates/ordo-protocol/src/convert.rs b/crates/ordo-protocol/src/convert.rs index ce88f2ea..4d9a1a85 100644 --- a/crates/ordo-protocol/src/convert.rs +++ b/crates/ordo-protocol/src/convert.rs @@ -179,6 +179,7 @@ fn convert_step_kind(kind: StudioStepKind, step_id: &str) -> Result { let converted_bindings: Result, _> = bindings @@ -400,6 +401,7 @@ mod tests { name: id.to_string(), description: None, position: None, + system_generated: None, kind: StudioStepKind::Terminal { code: code.to_string(), message: None, @@ -418,6 +420,7 @@ mod tests { name: "Done".to_string(), description: None, position: None, + system_generated: None, kind: StudioStepKind::Terminal { code: "OK".to_string(), message: Some(StudioTerminalMessage::Expr(StudioExpr::Literal { @@ -456,6 +459,7 @@ mod tests { name: "Done".to_string(), description: None, position: None, + system_generated: None, kind: StudioStepKind::Terminal { code: "OK".to_string(), message: Some(StudioTerminalMessage::String("plain message".to_string())), @@ -489,6 +493,7 @@ mod tests { name: "Decide".to_string(), description: None, position: None, + system_generated: None, kind: StudioStepKind::Decision { branches: vec![StudioBranch { id: "b1".to_string(), @@ -548,6 +553,7 @@ mod tests { name: "Act".to_string(), description: None, position: None, + system_generated: None, kind: StudioStepKind::Action { assignments: vec![StudioAssignment { name: "result".to_string(), diff --git a/crates/ordo-protocol/src/types/ruleset.rs b/crates/ordo-protocol/src/types/ruleset.rs index f21f9110..4931d718 100644 --- a/crates/ordo-protocol/src/types/ruleset.rs +++ b/crates/ordo-protocol/src/types/ruleset.rs @@ -53,4 +53,8 @@ pub struct StudioConfig { pub struct StudioSubRuleGraph { pub entry_step: String, pub steps: Vec, + #[serde(default)] + pub input_schema: Option, + #[serde(default)] + pub output_schema: Option, } diff --git a/crates/ordo-protocol/src/types/step.rs b/crates/ordo-protocol/src/types/step.rs index 968581fb..c077a1c4 100644 --- a/crates/ordo-protocol/src/types/step.rs +++ b/crates/ordo-protocol/src/types/step.rs @@ -17,6 +17,8 @@ pub struct StudioStep { // position is ignored during conversion (visual-only) #[serde(default, skip_serializing_if = "Option::is_none")] pub position: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_generated: Option, #[serde(flatten)] pub kind: StudioStepKind, } @@ -56,6 +58,8 @@ pub enum StudioStepKind { bindings: Vec, #[serde(default)] outputs: Vec, + #[serde(rename = "returnPolicy", default)] + return_policy: Option, #[serde(rename = "nextStepId")] next_step_id: String, }, diff --git a/crates/ordo-server/src/main.rs b/crates/ordo-server/src/main.rs index d01d6e06..789f994b 100644 --- a/crates/ordo-server/src/main.rs +++ b/crates/ordo-server/src/main.rs @@ -555,6 +555,8 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "nats-sync")] let mut nats_subscriber_handle: Option> = None; #[cfg(feature = "nats-sync")] + let mut nats_rpc_handle: Option> = None; + #[cfg(feature = "nats-sync")] let mut nats_jetstream: Option = None; #[cfg(feature = "nats-sync")] if let Some(ref nats_url) = config.nats_url { @@ -565,8 +567,9 @@ async fn main() -> anyhow::Result<()> { nats_url, instance_id, config.nats_subject_prefix ); - match sync::nats_sync::connect(nats_url).await { - Ok(jetstream) => { + match sync::nats_sync::connect_client(nats_url).await { + Ok(nats_client) => { + let jetstream = async_nats::jetstream::new(nats_client.clone()); if let Err(e) = sync::nats_sync::ensure_stream(&jetstream, &config.nats_subject_prefix).await { @@ -618,6 +621,36 @@ async fn main() -> anyhow::Result<()> { } } + let rpc_state = AppState { + store: store.clone(), + audit_logger: audit_logger.clone(), + metric_sink: metric_sink.clone(), + executor: executor.clone(), + config: config.clone(), + signature_verifier: signature_verifier.clone(), + debug_sessions: debug_sessions.clone(), + tenant_manager: tenant_manager.clone(), + rate_limiter: rate_limiter.clone(), + webhook_manager: webhook_manager.clone(), + }; + match start_server_control_rpc_responder( + nats_client.clone(), + config.nats_subject_prefix.clone(), + server_id.clone(), + rpc_state, + shutdown_rx.clone(), + ) + .await + { + Ok(handle) => { + nats_rpc_handle = Some(handle); + info!("NATS server control RPC responder started"); + } + Err(e) => { + warn!("Failed to start NATS server control RPC responder: {}", e); + } + } + nats_jetstream = Some(jetstream.clone()); } } @@ -851,6 +884,12 @@ async fn main() -> anyhow::Result<()> { let _ = handle.await; } + #[cfg(feature = "nats-sync")] + if let Some(handle) = nats_rpc_handle { + handle.abort(); + let _ = handle.await; + } + // Flush and shut down OpenTelemetry spans before process exit. if let Some(provider) = otel_provider { telemetry::shutdown(provider); @@ -1141,6 +1180,92 @@ async fn start_grpc_server( Ok(()) } +#[cfg(feature = "nats-sync")] +#[derive(serde::Serialize)] +struct ServerControlRpcResponse { + status: u16, + content_type: &'static str, + body: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[cfg(feature = "nats-sync")] +async fn start_server_control_rpc_responder( + client: async_nats::Client, + subject_prefix: String, + server_id: String, + state: AppState, + mut shutdown_rx: watch::Receiver, +) -> anyhow::Result> { + let subject = sync::nats_sync::server_rpc_wildcard_subject(&subject_prefix, &server_id); + let mut subscriber = client.subscribe(subject.clone()).await?; + Ok(tokio::spawn(async move { + use futures::StreamExt; + + info!( + subject = %subject, + "NATS server control RPC responder listening" + ); + + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + info!("NATS server control RPC responder: shutdown signal received"); + break; + } + message = subscriber.next() => { + let Some(message) = message else { + warn!("NATS server control RPC responder subscription ended"); + break; + }; + let Some(reply) = message.reply else { + continue; + }; + + let endpoint = message.subject.as_str().rsplit('.').next().unwrap_or(""); + let rpc_response = match endpoint { + "health" => { + let (status_code, body) = build_readiness_payload(&state).await; + ServerControlRpcResponse { + status: status_code.as_u16(), + content_type: "application/json", + body, + error: None, + } + } + "metrics" => ServerControlRpcResponse { + status: axum::http::StatusCode::OK.as_u16(), + content_type: PROMETHEUS_CONTENT_TYPE, + body: serde_json::Value::String(build_prometheus_metrics_text(&state).await), + error: None, + }, + other => ServerControlRpcResponse { + status: axum::http::StatusCode::NOT_FOUND.as_u16(), + content_type: "text/plain; charset=utf-8", + body: serde_json::Value::String(String::new()), + error: Some(format!("unknown server control RPC endpoint: {}", other)), + }, + }; + + match serde_json::to_vec(&rpc_response) { + Ok(payload) => { + if let Err(e) = client.publish(reply, payload.into()).await { + warn!("Failed to publish NATS server control RPC response: {}", e); + } + } + Err(e) => { + warn!("Failed to serialize NATS server control RPC response: {}", e); + } + } + } + } + } + })) +} + +const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; + /// Liveness probe — confirms the process is running. /// Always returns 200; use for Kubernetes liveness probes. async fn liveness_check() -> impl IntoResponse { @@ -1156,6 +1281,11 @@ async fn liveness_check() -> impl IntoResponse { /// Returns 503 if any check fails. Use for Kubernetes readiness probes /// and load balancer health checks. async fn readiness_check(State(state): State) -> impl IntoResponse { + let (status_code, payload) = build_readiness_payload(&state).await; + (status_code, Json(payload)) +} + +async fn build_readiness_payload(state: &AppState) -> (axum::http::StatusCode, serde_json::Value) { let store_result = tokio::time::timeout(Duration::from_secs(2), state.store.read()).await; let (store_lock_ok, rules_count, storage_mode) = match store_result { @@ -1193,7 +1323,7 @@ async fn readiness_check(State(state): State) -> impl IntoResponse { ( status_code, - Json(serde_json::json!({ + serde_json::json!({ "status": if is_ready { "ready" } else { "not_ready" }, "version": ordo_core::VERSION, "role": state.config.role.to_string(), @@ -1212,12 +1342,20 @@ async fn readiness_check(State(state): State) -> impl IntoResponse { "last_reload_timestamp": metrics::LAST_RELOAD_TIMESTAMP.get(), }, "debug_mode": state.config.debug_enabled() - })), + }), ) } /// Prometheus metrics endpoint async fn prometheus_metrics(State(state): State) -> impl IntoResponse { + let metrics = build_prometheus_metrics_text(&state).await; + ( + [(axum::http::header::CONTENT_TYPE, PROMETHEUS_CONTENT_TYPE)], + metrics, + ) +} + +async fn build_prometheus_metrics_text(state: &AppState) -> String { // Update rules count before encoding let store = state.store.read().await; metrics::set_rules_count(store.len() as i64); @@ -1227,14 +1365,7 @@ async fn prometheus_metrics(State(state): State) -> impl IntoResponse let standard_metrics = metrics::encode_metrics(); let custom_metrics = state.metric_sink.encode_custom_metrics(); - // Return Prometheus text format - ( - [( - axum::http::header::CONTENT_TYPE, - "text/plain; version=0.0.4; charset=utf-8", - )], - format!("{}\n{}", standard_metrics, custom_metrics), - ) + format!("{}\n{}", standard_metrics, custom_metrics) } /// Replay uncommitted WAL entries into the store before `load_from_dir`. diff --git a/crates/ordo-server/src/sync/nats_sync.rs b/crates/ordo-server/src/sync/nats_sync.rs index ccff11fd..1dac1352 100644 --- a/crates/ordo-server/src/sync/nats_sync.rs +++ b/crates/ordo-server/src/sync/nats_sync.rs @@ -616,11 +616,33 @@ fn connect_options_and_addr( Ok((options, url.to_string())) } -pub async fn connect(nats_url: &str) -> Result { +pub async fn connect_client(nats_url: &str) -> Result { let (options, server_addr) = connect_options_and_addr(nats_url)?; let client = options.connect(server_addr.as_str()).await?; info!("Connected to NATS at {}", nats_url); - Ok(jetstream::new(client)) + Ok(client) +} + +fn rpc_prefix(subject_prefix: &str) -> String { + let safe_prefix = subject_prefix + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect::(); + if safe_prefix.is_empty() { + "_ORDO_RPC.default".to_string() + } else { + format!("_ORDO_RPC.{}", safe_prefix) + } +} + +pub fn server_rpc_wildcard_subject(subject_prefix: &str, server_id: &str) -> String { + format!("{}.servers.{}.*", rpc_prefix(subject_prefix), server_id) } pub async fn publish_immediate( @@ -668,4 +690,12 @@ mod tests { fn test_stream_name_constant() { assert_eq!(STREAM_NAME, "ordo-rules"); } + + #[test] + fn test_server_rpc_wildcard_uses_non_stream_prefix() { + assert_eq!( + server_rpc_wildcard_subject(DEFAULT_SUBJECT_PREFIX, "srv_abc"), + "_ORDO_RPC.ordo_rules.servers.srv_abc.*" + ); + } } diff --git a/crates/ordo-wasm/src/lib.rs b/crates/ordo-wasm/src/lib.rs index e2c05d6a..02759ca7 100644 --- a/crates/ordo-wasm/src/lib.rs +++ b/crates/ordo-wasm/src/lib.rs @@ -5,6 +5,7 @@ use ordo_core::expr::{BinaryOp, Expr}; use ordo_core::prelude::*; use ordo_core::rule::{ActionKind, CompiledRuleExecutor, Condition, RuleSetCompiler}; +use ordo_core::trace::StepTrace; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -49,6 +50,72 @@ pub struct WasmStepTrace { pub duration_us: u64, /// Step result (for decision steps) pub result: Option, + /// Next step ID when execution continues. + pub next_step: Option, + /// Whether this step ended execution. + pub is_terminal: bool, + /// Input data snapshot for this step. + pub input_snapshot: Option, + /// Variable snapshot for this step. + pub variables_snapshot: Option, + /// Referenced sub-rule name when this step invokes a sub-rule. + pub sub_rule_ref: Option, + /// Input object passed into the sub-rule. + pub sub_rule_input: Option, + /// Output mappings copied from child context to parent context. + pub sub_rule_outputs: Vec, + /// Nested execution frames produced by a sub-rule invocation. + pub sub_rule_frames: Vec, +} + +/// Sub-rule output mapping trace information. +#[derive(Serialize, Deserialize)] +pub struct WasmSubRuleOutputTrace { + pub parent_var: String, + pub child_var: String, + pub value: Option, + pub missing: bool, +} + +fn map_step_trace(step: &StepTrace) -> WasmStepTrace { + WasmStepTrace { + id: step.step_id.clone(), + name: step.step_name.clone(), + duration_us: step.duration_us, + result: None, + next_step: step.next_step.clone(), + is_terminal: step.is_terminal, + input_snapshot: step.input_snapshot.clone(), + variables_snapshot: step + .variables_snapshot + .as_ref() + .and_then(|snapshot| serde_json::to_value(snapshot).ok()), + sub_rule_ref: step + .sub_rule_call + .as_ref() + .map(|call| call.ref_name.clone()), + sub_rule_input: step.sub_rule_call.as_ref().map(|call| call.input.clone()), + sub_rule_outputs: step + .sub_rule_call + .as_ref() + .map(|call| { + call.outputs + .iter() + .map(|output| WasmSubRuleOutputTrace { + parent_var: output.parent_var.clone(), + child_var: output.child_var.clone(), + value: output.value.clone(), + missing: output.missing, + }) + .collect() + }) + .unwrap_or_default(), + sub_rule_frames: step + .sub_rule_frames + .as_ref() + .map(|frames| frames.iter().map(map_step_trace).collect()) + .unwrap_or_default(), + } } /// Execute a ruleset with given input @@ -70,18 +137,6 @@ pub fn execute_ruleset( let ruleset: RuleSet = serde_json::from_str(ruleset_json) .map_err(|e| JsValue::from_str(&format!("Failed to parse ruleset: {}", e)))?; - // Debug: log parsed ruleset structure - web_sys::console::log_1( - &format!( - "[WASM DEBUG] Parsed ruleset steps: {:?}", - ruleset.steps.keys().collect::>() - ) - .into(), - ); - for (step_id, step) in &ruleset.steps { - web_sys::console::log_1(&format!("[WASM DEBUG] Step {}: {:?}", step_id, step.kind).into()); - } - // Parse input let input: Value = serde_json::from_str(input_json) .map_err(|e| JsValue::from_str(&format!("Failed to parse input: {}", e)))?; @@ -103,16 +158,7 @@ pub fn execute_ruleset( // Convert trace if present let trace = result.trace.as_ref().map(|t| WasmExecutionTrace { path: t.path_string(), - steps: t - .steps - .iter() - .map(|s| WasmStepTrace { - id: s.step_id.clone(), - name: s.step_name.clone(), - duration_us: s.duration_us, - result: None, - }) - .collect(), + steps: t.steps.iter().map(map_step_trace).collect(), }); // Convert output to serde_json::Value diff --git a/deploy/nomad/devcontainer-entrypoint.sh b/deploy/nomad/devcontainer-entrypoint.sh index edd1bd83..a3b15684 100755 --- a/deploy/nomad/devcontainer-entrypoint.sh +++ b/deploy/nomad/devcontainer-entrypoint.sh @@ -144,7 +144,7 @@ start_platform_watch() { -w crates \ -w Cargo.toml \ -w Cargo.lock \ - -x "run -p ordo-platform -- --addr 0.0.0.0:${PLATFORM_PORT} --database-url ${ORDO_DATABASE_URL} --engine-url ${ORDO_ENGINE_URL} --jwt-secret ${ORDO_JWT_SECRET} --templates-dir ${ORDO_PLATFORM_TEMPLATES_DIR}" + -x "run -p ordo-platform --bin ordo-platform -- --addr 0.0.0.0:${PLATFORM_PORT} --database-url ${ORDO_DATABASE_URL} --engine-url ${ORDO_ENGINE_URL} --jwt-secret ${ORDO_JWT_SECRET} --templates-dir ${ORDO_PLATFORM_TEMPLATES_DIR}" ) 2>&1 | tee "$LOG_DIR/ordo-platform.log" & PLATFORM_PID=$! } diff --git a/ordo-editor/apps/docs/.vitepress/config.mts b/ordo-editor/apps/docs/.vitepress/config.mts index f39eed1d..e3cecc82 100644 --- a/ordo-editor/apps/docs/.vitepress/config.mts +++ b/ordo-editor/apps/docs/.vitepress/config.mts @@ -51,7 +51,8 @@ export default withMermaid(defineConfig({ link: '/en/', themeConfig: { nav: [ - { text: 'Guide', link: '/en/guide/getting-started' }, + { text: 'Platform', link: '/en/platform/overview' }, + { text: 'Engine', link: '/en/guide/what-is-ordo' }, { text: 'API', link: '/en/api/http-api' }, { text: 'Reference', link: '/en/reference/cli' }, { text: 'Roadmap', link: '/en/roadmap' }, @@ -62,6 +63,39 @@ export default withMermaid(defineConfig({ }, ], sidebar: { + '/en/platform/': [ + { + text: 'Platform', + items: [ + { text: 'Overview', link: '/en/platform/overview' }, + { text: 'Organizations & Projects', link: '/en/platform/organizations' }, + { text: 'Studio Editor', link: '/en/platform/studio' }, + ] + }, + { + text: 'Modeling', + items: [ + { text: 'Fact Catalog', link: '/en/platform/catalog' }, + { text: 'Decision Contracts', link: '/en/platform/contracts' }, + { text: 'Sub-Rule Assets', link: '/en/platform/sub-rules' }, + ] + }, + { + text: 'Delivery', + items: [ + { text: 'Rule Drafts', link: '/en/platform/drafts' }, + { text: 'Release Pipeline', link: '/en/platform/releases' }, + { text: 'Test Management', link: '/en/platform/testing' }, + ] + }, + { + text: 'Operations', + items: [ + { text: 'Server Registry', link: '/en/platform/server-registry' }, + { text: 'GitHub Integration', link: '/en/platform/github' }, + ] + } + ], '/en/guide/': [ { text: 'Introduction', @@ -146,7 +180,8 @@ export default withMermaid(defineConfig({ description: "高性能规则引擎与可视化编辑器", themeConfig: { nav: [ - { text: '指南', link: '/zh/guide/getting-started' }, + { text: '平台', link: '/zh/platform/overview' }, + { text: '引擎', link: '/zh/guide/what-is-ordo' }, { text: 'API', link: '/zh/api/http-api' }, { text: '参考', link: '/zh/reference/cli' }, { text: '路线图', link: '/zh/roadmap' }, @@ -157,6 +192,39 @@ export default withMermaid(defineConfig({ }, ], sidebar: { + '/zh/platform/': [ + { + text: '平台', + items: [ + { text: '概览', link: '/zh/platform/overview' }, + { text: '组织与项目', link: '/zh/platform/organizations' }, + { text: 'Studio 编辑器', link: '/zh/platform/studio' }, + ] + }, + { + text: '建模', + items: [ + { text: '事实目录', link: '/zh/platform/catalog' }, + { text: '决策契约', link: '/zh/platform/contracts' }, + { text: '子规则资产', link: '/zh/platform/sub-rules' }, + ] + }, + { + text: '交付', + items: [ + { text: '规则草稿', link: '/zh/platform/drafts' }, + { text: '发布流程', link: '/zh/platform/releases' }, + { text: '测试管理', link: '/zh/platform/testing' }, + ] + }, + { + text: '运维', + items: [ + { text: '服务器注册', link: '/zh/platform/server-registry' }, + { text: 'GitHub 集成', link: '/zh/platform/github' }, + ] + } + ], '/zh/guide/': [ { text: '介绍', diff --git a/ordo-editor/apps/docs/en/index.md b/ordo-editor/apps/docs/en/index.md index 921f1421..047df284 100644 --- a/ordo-editor/apps/docs/en/index.md +++ b/ordo-editor/apps/docs/en/index.md @@ -4,7 +4,7 @@ layout: home hero: name: 'Ordo' text: 'Open-Source Decision Platform' - tagline: Author, test, and govern business rules — with Studio, platform governance, and a fast engine under the hood. + tagline: A unified decision infrastructure — Studio for authoring, Platform for governance, Engine for execution. Three layers, clean separation of concerns. image: src: /logo.png alt: Ordo @@ -13,33 +13,65 @@ hero: text: Get Started link: /en/guide/getting-started - theme: alt - text: Try Playground - link: https://ordo-engine.github.io/Ordo/ + text: Platform Docs + link: /en/platform/overview - theme: alt - text: View on GitHub + text: Engine Docs + link: /en/guide/what-is-ordo + - theme: alt + text: GitHub link: https://github.com/Ordo-Engine/Ordo features: - - icon: 🏛️ - title: Decision Platform - details: Org & project management, fact catalog, typed decision contracts, and full version history. Own your decision logic — don't scatter it across codebases and spreadsheets. - - icon: 🎨 - title: Studio - details: Drag-and-drop flow editor, decision tables, one-click template instantiation, and test case management. Author rules without friction. - - icon: 🧪 - title: Test Management - details: Create, run, and export test suites per ruleset. CI-compatible YAML. Know your rules work before they ship. - - icon: ⚡ - title: Fast Engine - details: Sub-microsecond execution with Cranelift JIT. Runs as HTTP · gRPC · WASM · CLI or embedded in any Rust application. - - icon: 🛡️ - title: Governance - details: Typed input/output contracts, audit logging, Ed25519 rule signing, and rollback. Traceable and compliant by default. - - icon: 🔌 - title: Runs Everywhere - details: Single binary server, browser via WebAssembly, embedded in Rust apps. One engine across every deployment target. + - title: Decision Platform + details: Organizations, projects, members & RBAC, fact catalog, concept registry, typed contracts, approval & release pipelines, multi-environment rollouts and rollback — built for team-scale decision governance. + link: /en/platform/overview + linkText: Platform overview + - title: Studio Editor + details: Three authoring modes (flow / form / JSON), decision tables, sub-rules, template instantiation, test suite management, and execution trace panels. + link: /en/platform/studio + linkText: Studio guide + - title: Releases & Environments + details: Draft → review → release → canary → rollback. Configurable approval policies, change diffs, per-environment delivery, every action recorded in the audit log. + link: /en/platform/releases + linkText: Release pipeline + - title: High-Performance Engine + details: Sub-microsecond rule execution. Bytecode VM plus Cranelift JIT, expression optimizer. Reach it over HTTP, gRPC, Unix Socket, or WASM. + link: /en/guide/execution-model + linkText: Execution model + - title: Types & Contracts + details: Project-scoped fact catalog, reusable concepts, typed input/output contracts. Studio and CLI consume the same contract definitions. + link: /en/platform/catalog + linkText: Facts & contracts + - title: Multi-Region Deployment + details: Central platform governance plus regional engine clusters. Server registry, health checks, per-project execution proxy. Single-binary or containerized deployment. + link: /en/platform/server-registry + linkText: Server registry --- +## Architecture + +```mermaid +flowchart TB + Studio["Studio (browser)"] + CLI["ordo-cli"] + SDK["SDK / business app"] + Platform["ordo-platform
governance · drafts · review · release"] + Server["ordo-server cluster
HTTP · gRPC · UDS"] + Core["ordo-core engine
VM + JIT + sub-rules + trace"] + + Studio --> Platform + CLI --> Platform + SDK --> Server + Platform -- "release events (NATS / direct sync)" --> Server + Server --> Core +``` + +The documentation is organized into two tracks: + +- **Platform** — for teams using Ordo Platform / Studio to govern decisions: organization modeling, contracts, release flow, test management. +- **Engine** — for developers integrating ordo-core / ordo-server directly: rule structure, expression syntax, HTTP / gRPC / WASM APIs. + ## Quick Example ```json diff --git a/ordo-editor/apps/docs/zh/index.md b/ordo-editor/apps/docs/zh/index.md index ec74f180..8edbbc80 100644 --- a/ordo-editor/apps/docs/zh/index.md +++ b/ordo-editor/apps/docs/zh/index.md @@ -4,7 +4,7 @@ layout: home hero: name: 'Ordo' text: '开源决策平台' - tagline: 编写、测试、治理业务规则 — Studio 可视化编辑、平台级治理,底层引擎快到感觉不到。 + tagline: 由治理平台与高性能引擎组成的一体化决策基础设施。Studio 编排、平台审计、引擎执行——三层职责清晰分离。 image: src: /logo.png alt: Ordo @@ -13,33 +13,65 @@ hero: text: 开始使用 link: /zh/guide/getting-started - theme: alt - text: 尝试演练场 - link: https://ordo-engine.github.io/Ordo/ + text: 平台篇 + link: /zh/platform/overview + - theme: alt + text: 引擎篇 + link: /zh/guide/what-is-ordo - theme: alt text: GitHub link: https://github.com/Ordo-Engine/Ordo features: - - icon: 🏛️ - title: 决策平台 - details: 组织与项目管理、事实目录、带类型的决策契约,以及完整的版本历史。让团队真正拥有自己的决策逻辑,而不是散落在代码库和电子表格里。 - - icon: 🎨 - title: Studio - details: 拖拽式流程编辑器、决策表、一键实例化模板,以及测试用例管理。低摩擦地编写规则。 - - icon: 🧪 - title: 测试管理 - details: 为每个规则集创建、运行、导出测试套件。兼容 ordo-cli 的 YAML 格式,直接接入 CI/CD。上线前确保规则正确。 - - icon: ⚡ - title: 高性能引擎 - details: 亚微秒级执行,Cranelift JIT 编译。支持 HTTP · gRPC · WASM · CLI,或嵌入任意 Rust 应用。 - - icon: 🛡️ - title: 治理 - details: 带类型的输入/输出契约、审计日志、Ed25519 规则签名与一键回滚。默认可追溯、合规。 - - icon: 🔌 - title: 随处运行 - details: 单二进制服务器、浏览器端 WebAssembly、嵌入式 Rust 集成。同一个引擎覆盖所有部署场景。 + - title: 决策平台 + details: 组织 / 项目 / 成员与角色(RBAC)、事实目录、概念注册、决策契约、审批与发布流水线、多环境与回滚——为团队级决策治理而生。 + link: /zh/platform/overview + linkText: 查看平台文档 + - title: Studio 编辑器 + details: 三种编辑模式(流程图 / 表单 / JSON)、决策表、子规则、模板实例化、测试套件管理与执行追踪面板。 + linkText: 查看 Studio + link: /zh/platform/studio + - title: 发布与环境治理 + details: 草稿 → 审批 → 发布 → 灰度 → 回滚。可配置的审批策略、变更对比、按环境分别下发,所有动作进入审计日志。 + link: /zh/platform/releases + linkText: 发布流程 + - title: 高性能引擎 + details: 亚微秒级规则执行,字节码 VM + Cranelift JIT、表达式优化器。HTTP / gRPC / Unix Socket / WASM 多协议接入。 + link: /zh/guide/execution-model + linkText: 执行模型 + - title: 类型与契约 + details: 项目级事实目录、可复用概念、带类型的输入/输出契约。Studio 与 CLI 共用同一份契约定义。 + link: /zh/platform/catalog + linkText: 事实与契约 + - title: 多区域部署 + details: 平台中央治理 + 区域化引擎集群。服务器注册、健康检查、按项目路由的执行代理,支持单 binary 与容器化部署。 + link: /zh/platform/server-registry + linkText: 服务器注册 --- +## 架构概览 + +```mermaid +flowchart TB + Studio["Studio (浏览器)"] + CLI["ordo-cli"] + SDK["SDK / 业务系统"] + Platform["ordo-platform
治理 · 草稿 · 审批 · 发布"] + Server["ordo-server 集群
HTTP · gRPC · UDS"] + Core["ordo-core 引擎
VM + JIT + 子规则 + 追踪"] + + Studio --> Platform + CLI --> Platform + SDK --> Server + Platform -- "发布事件 (NATS / 直接同步)" --> Server + Server --> Core +``` + +Ordo 的文档分为两大部分: + +- **平台篇**——面向使用 Ordo Platform / Studio 治理决策的团队:组织建模、契约、发布流程、测试管理。 +- **引擎篇**——面向需要直接集成 ordo-core / ordo-server 的开发者:规则结构、表达式语法、HTTP / gRPC / WASM API。 + ## 快速示例 ```json diff --git a/ordo-editor/apps/studio/package.json b/ordo-editor/apps/studio/package.json index 4694301b..cd991aec 100644 --- a/ordo-editor/apps/studio/package.json +++ b/ordo-editor/apps/studio/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc --noEmit && vite build", + "build": "pnpm --filter @ordo-engine/wasm build && vue-tsc --noEmit && vite build", "preview": "vite preview", "typecheck": "vue-tsc --noEmit" }, diff --git a/ordo-editor/apps/studio/src/api/platform-client.ts b/ordo-editor/apps/studio/src/api/platform-client.ts index a7963969..58e15198 100644 --- a/ordo-editor/apps/studio/src/api/platform-client.ts +++ b/ordo-editor/apps/studio/src/api/platform-client.ts @@ -32,6 +32,7 @@ import type { ProjectRulesetMeta, ProjectTestRunResult, PublishRequest, + AdHocTestRunRequest, RedeployRequest, ReleaseExecution, ReleaseExecutionEvent, @@ -48,6 +49,10 @@ import type { SaveDraftRequest, ServerInfo, SetCanaryRequest, + SaveSubRuleAssetRequest, + SubRuleAsset, + SubRuleAssetMeta, + SubRuleScope, TemplateDetail, TemplateMetadata, TestCase, @@ -440,6 +445,14 @@ export const testApi = { }); }, + runAdHoc(token: string, projectId: string, req: AdHocTestRunRequest): Promise { + return request(`/projects/${projectId}/tests/run-ad-hoc`, { + method: 'POST', + token, + body: JSON.stringify(req), + }); + }, + /** Returns a download URL (use window.open or anchor href). */ exportUrl(projectId: string, rulesetName: string, format: 'yaml' | 'json' = 'yaml'): string { return `${BASE}/projects/${projectId}/rulesets/${encodeURIComponent( @@ -734,6 +747,75 @@ export const rulesetDraftApi = { }, }; +// ── Managed SubRule Assets ─────────────────────────────────────────────────── + +export const subRuleApi = { + listProject( + token: string, + orgId: string, + projectId: string, + includeOrg = true + ): Promise { + const qs = includeOrg ? '' : '?include_org=false'; + return request(`/orgs/${orgId}/projects/${projectId}/sub-rules${qs}`, { token }); + }, + + listOrg(token: string, orgId: string): Promise { + return request(`/orgs/${orgId}/sub-rules`, { token }); + }, + + getProject(token: string, orgId: string, projectId: string, name: string): Promise { + return request(`/orgs/${orgId}/projects/${projectId}/sub-rules/${encodeURIComponent(name)}`, { + token, + }); + }, + + getOrg(token: string, orgId: string, name: string): Promise { + return request(`/orgs/${orgId}/sub-rules/${encodeURIComponent(name)}`, { token }); + }, + + saveProject( + token: string, + orgId: string, + projectId: string, + name: string, + req: SaveSubRuleAssetRequest + ): Promise { + return request(`/orgs/${orgId}/projects/${projectId}/sub-rules/${encodeURIComponent(name)}`, { + method: 'PUT', + token, + body: JSON.stringify(req), + }); + }, + + saveOrg( + token: string, + orgId: string, + name: string, + req: SaveSubRuleAssetRequest + ): Promise { + return request(`/orgs/${orgId}/sub-rules/${encodeURIComponent(name)}`, { + method: 'PUT', + token, + body: JSON.stringify(req), + }); + }, + + deleteProject(token: string, orgId: string, projectId: string, name: string): Promise { + return request(`/orgs/${orgId}/projects/${projectId}/sub-rules/${encodeURIComponent(name)}`, { + method: 'DELETE', + token, + }); + }, + + deleteOrg(token: string, orgId: string, name: string): Promise { + return request(`/orgs/${orgId}/sub-rules/${encodeURIComponent(name)}`, { + method: 'DELETE', + token, + }); + }, +}; + // ── RBAC ────────────────────────────────────────────────────────────────────── export const roleApi = { diff --git a/ordo-editor/apps/studio/src/api/types.ts b/ordo-editor/apps/studio/src/api/types.ts index 9430de39..9a6c6fb9 100644 --- a/ordo-editor/apps/studio/src/api/types.ts +++ b/ordo-editor/apps/studio/src/api/types.ts @@ -297,6 +297,7 @@ export interface TestRunResult { test_name: string; passed: boolean; failures: string[]; + failure_details?: TestFailureDetail[]; duration_us: number; actual_code?: string; actual_message?: string; @@ -304,14 +305,43 @@ export interface TestRunResult { trace?: TestExecutionTrace; } +export type TestFailureKind = + | 'reference' + | 'contract' + | 'binding' + | 'sub_rule' + | 'output' + | 'assertion' + | 'execution'; + +export interface TestFailureDetail { + message: string; + kind: TestFailureKind; + step_id?: string | null; + sub_rule_ref?: string | null; + trace_path?: string[]; +} + export interface TestExecutionTraceStep { id: string; name: string; duration_us: number; + result?: string | null; next_step?: string | null; is_terminal?: boolean; input_snapshot?: Record | null; variables_snapshot?: Record | null; + sub_rule_ref?: string | null; + sub_rule_input?: Record | null; + sub_rule_outputs?: TestSubRuleOutputTrace[]; + sub_rule_frames?: TestExecutionTraceStep[]; +} + +export interface TestSubRuleOutputTrace { + parent_var: string; + child_var: string; + value?: unknown; + missing?: boolean; } export interface TestExecutionTrace { @@ -334,6 +364,14 @@ export interface ProjectTestRunRequest { include_trace?: boolean; } +export interface AdHocTestRunRequest { + ruleset: RuleSet; + name?: string; + input: Record; + expect?: TestExpectation; + include_trace?: boolean; +} + export interface RulesetTestSummary { ruleset_name: string; total: number; @@ -467,6 +505,41 @@ export interface DraftConflictResponse { server_seq: number; } +// ── Managed SubRule Assets ─────────────────────────────────────────────────── + +export type SubRuleScope = 'org' | 'project'; + +export interface SubRuleAssetMeta { + id: string; + org_id: string; + project_id: string | null; + scope: SubRuleScope; + name: string; + display_name: string | null; + description: string | null; + draft_seq: number; + draft_updated_at: string; + draft_updated_by: string | null; + created_at: string; + created_by: string | null; +} + +export interface SubRuleAsset extends SubRuleAssetMeta { + draft: RuleSet; + input_schema: unknown[]; + output_schema: unknown[]; +} + +export interface SaveSubRuleAssetRequest { + name: string; + display_name?: string | null; + description?: string | null; + draft: RuleSet; + input_schema?: unknown[]; + output_schema?: unknown[]; + expected_seq?: number; +} + // ── Deployments ─────────────────────────────────────────────────────────────── export type DeploymentStatus = 'queued' | 'success' | 'failed'; @@ -569,12 +642,30 @@ export interface ReleaseContentDiffSummary { added_groups: string[]; removed_groups: string[]; modified_groups: string[]; + added_sub_rules: ReleaseSubRuleDiffItem[]; + removed_sub_rules: ReleaseSubRuleDiffItem[]; + modified_sub_rules: ReleaseSubRuleDiffItem[]; input_schema_changed: boolean; output_schema_changed: boolean; tags_changed: boolean; description_changed: boolean; } +export interface ReleaseSubRuleDependency { + name: string; + display_name?: string | null; + scope: SubRuleScope; + asset_id: string; + draft_seq: number; + content_hash: string; +} + +export interface ReleaseSubRuleDiffItem { + name: string; + content_hash?: string | null; + step_count?: number | null; +} + export interface ReleaseRequestSnapshot { requester_id: string; requester_name?: string | null; @@ -592,6 +683,8 @@ export interface ReleaseRequestSnapshot { rollout_strategy: RolloutStrategy; rollback_policy: RollbackPolicy; affected_instance_count: number; + target_ruleset_snapshot?: RuleSet | null; + sub_rule_dependencies: ReleaseSubRuleDependency[]; } export interface ReleaseRequest { diff --git a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue index 9398f9df..68419188 100644 --- a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue +++ b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue @@ -152,6 +152,8 @@ const pageInfo = computed(() => { return { title: t('projectNav.concepts'), subtitle: t('concepts.desc') }; case 'contracts': return { title: t('projectNav.contracts'), subtitle: t('contracts.desc') }; + case 'project-sub-rules': + return { title: t('projectNav.subRules'), subtitle: t('subRules.desc') }; case 'tests': return { title: t('projectNav.tests'), subtitle: t('shell.testsSubtitle') }; case 'versions': diff --git a/ordo-editor/apps/studio/src/i18n/locales/en.ts b/ordo-editor/apps/studio/src/i18n/locales/en.ts index fe05c4d6..74cf61c3 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/en.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/en.ts @@ -276,6 +276,7 @@ export default { facts: 'Facts', concepts: 'Concepts', contracts: 'Contracts', + subRules: 'SubRules', tests: 'Tests', versions: 'Versions', trace: 'Trace', @@ -286,6 +287,7 @@ export default { settings: 'Settings', }, editor: { + rulesets: 'Rulesets', noRulesets: 'No rulesets', newRuleset: 'New Ruleset', unsaved: 'Unsaved', @@ -295,7 +297,7 @@ export default { tableMode: 'Decision Table Mode', execPanel: 'Execution Panel (Ctrl+`)', saveTitle: 'Save (Ctrl+S)', - emptyHint: 'Select a ruleset from the left to start editing', + emptyHint: 'Select a ruleset or project SubRule from the left to start editing', newRulesetBtn: 'New Ruleset', createDialog: 'New Ruleset', nameLabel: 'Ruleset Name', @@ -323,6 +325,24 @@ export default { deleteSuccess: 'Deleted', tableUnsupported: 'Current ruleset does not support decision table mode', deleteTitle: 'Delete', + conceptMaterializationError: 'Concept expression cannot be used for execution: {message}', + }, + knowledgeAdvisor: { + title: 'Knowledge Surface', + missingSummary: '{count} unregistered inputs detected', + noContractSummary: 'Fields are wired, but no decision contract exists yet', + readySummary: 'Facts, concepts, and contract are wired into this editor', + detail: + 'This flow references {fields} inputs. Project catalog has {facts} facts and {concepts} concepts.', + fieldsStat: '{count} inputs', + assetsStat: '{facts} facts / {concepts} concepts', + contractReady: 'Contract defined', + contractMissing: 'Missing contract', + createMissingFacts: 'Register {count} facts', + openContract: 'Open contract', + defineContract: 'Define contract', + generatedFactDescription: 'Registered automatically from references in the rule editor', + createFactsSuccess: '{count} facts registered', }, menuBar: { file: 'File', @@ -504,6 +524,69 @@ export default { fieldName: 'Field Name', fieldDesc: 'Description', }, + subRules: { + title: 'SubRule Assets', + desc: 'Manage reusable atomic decision graphs referenced by SubRule nodes in ruleset flows.', + createTitle: 'Create SubRule', + createDesc: + 'A SubRule has its own flow graph, decision table, input contract, and output contract. Rulesets only reference managed SubRule assets.', + create: 'Create', + empty: 'No SubRule assets yet', + placeholder: 'Select a SubRule or create a new managed asset', + searchPlaceholder: 'Search by name, description, or scope', + name: 'Name', + namePlaceholder: 'e.g. risk_score', + nameRequired: 'Enter a SubRule name', + displayName: 'Display Name', + displayNamePlaceholder: 'e.g. Risk Score', + description: 'Description', + descriptionPlaceholder: 'Describe the atomic capability this SubRule provides', + assetScope: 'Asset Scope', + scopeProject: 'Project', + scopeOrg: 'Organization', + published: 'Published', + publishedAt: 'Published', + projectAssets: 'Project SubRules', + orgAssets: 'Organization SubRules', + noProjectAssets: 'No project SubRules yet.', + noOrgAssets: 'No organization SubRules yet.', + noDescription: 'No description', + status: 'Status', + unpublished: 'Unpublished', + updatedAt: 'Updated', + loadFailed: 'Failed to load SubRules', + saveFailed: 'Failed to save SubRule', + createSuccess: 'SubRule created', + usageTitle: 'Flow reference', + usageDesc: + 'SubRule nodes store this asset reference. Releases resolve it into a fixed version snapshot.', + defaultTerminalName: 'Return result', + defaultDescription: 'Reusable SubRule decision graph.', + openInEditor: 'Open in Editor', + focusTitle: 'Editing SubRule: {name}', + focusDesc: 'This is a focused reusable graph. Save it before returning to the parent ruleset.', + returnParent: 'Back to parent', + returnDispatcher: 'SubRule return dispatch', + extractTitle: 'Extract SubRule', + extractDesc: + 'Create a managed SubRule from {count} selected steps and replace the selection with one SubRule node.', + extractConfirm: 'Extract', + extractSuccess: 'Extracted SubRule {name}', + extractedDescription: 'Extracted from {count} selected steps.', + nameExists: 'SubRule "{name}" already exists', + suggestionsTitle: 'SubRule opportunities', + suggestionsDesc: + 'Reusable regions detected in this graph. Extract one to simplify the parent flow.', + suggestionGroupTitle: 'Group: {name}', + suggestionGroupDesc: 'This grouped region already looks like a reusable decision capability.', + suggestionDecisionTitle: 'Branch cluster: {name}', + suggestionDecisionDesc: '{count} connected steps branch from the same decision.', + suggestionChainTitle: 'Flow segment: {name}', + suggestionChainDesc: '{count} steps form a single-entry/single-exit execution segment.', + suggestionCta: 'Select & extract', + suggestionSteps: '{count} steps', + executionHydrating: 'Preparing executable SubRule snapshot...', + }, versions: { title: 'Version History', desc: 'View ruleset change history and roll back to any previous version (admin+ required)', @@ -550,6 +633,7 @@ export default { actionEditRuleset: 'Edit ruleset', actionRestoreSnapshot: 'Restore: {action}', actionSaveCheckpoint: 'Save checkpoint', + actionExtractSubRule: 'Extract SubRule {name}', }, dashboard: { workspaceTitle: 'Project Workspace', @@ -843,6 +927,33 @@ export default { expected: 'Expected', actual: 'Actual', }, + trace: { + terminal: 'Terminal', + subRule: 'SubRule', + openSubRule: 'Open trace', + callInput: 'Call input', + outputMappings: 'Output mappings', + missingOutput: 'Missing child variable', + innerFrames: 'Inner trace', + showInFlow: 'Show in flow', + failureKinds: { + reference: 'Reference', + contract: 'Contract', + binding: 'Binding', + subRule: 'Execution', + output: 'Output', + assertion: 'Assertion', + execution: 'Runtime', + }, + }, + subRuleProbe: { + title: 'SubRule probe', + desc: 'Run the focused SubRule draft directly with a contract-shaped input. This does not create a persisted test case.', + seed: 'Seed input', + run: 'Run SubRule', + emptyResult: 'Run the probe to inspect output and step trace.', + runFinished: 'SubRule probe completed with failures', + }, project: { title: 'Project Tests', runAll: 'Run All Project Tests', @@ -1087,6 +1198,9 @@ export default { diffAddedGroups: 'Added groups', diffModifiedGroups: 'Modified groups', diffRemovedGroups: 'Removed groups', + diffAddedSubRules: 'Added sub-rules', + diffModifiedSubRules: 'Modified sub-rules', + diffRemovedSubRules: 'Removed sub-rules', diffInputSchemaChanged: 'Input schema changed', diffOutputSchemaChanged: 'Output schema changed', diffTagsChanged: 'Tags changed', @@ -1109,6 +1223,10 @@ export default { minApprovals: 'Minimum approvals', approvers: 'Approvers', rollbackPolicy: 'Rollback policy', + subRuleDependencies: 'Sub-rule snapshot dependencies', + subRuleDraftSeq: 'Draft seq', + subRuleAssetId: 'Asset ID', + subRuleSnapshotHash: 'Snapshot hash', metricGuard: 'Metric guard', updatedAt: 'Updated at', statusPendingApproval: 'Pending approval', diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts index 7dc7e311..1a6ce5de 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts @@ -270,6 +270,7 @@ export default { facts: '事实目录', concepts: '概念注册', contracts: '决策契约', + subRules: '子规则', tests: '测试', versions: '版本历史', trace: '执行追踪', @@ -280,6 +281,7 @@ export default { settings: '设置', }, editor: { + rulesets: '规则集', noRulesets: '暂无规则集', newRuleset: '新建规则集', unsaved: '未保存', @@ -289,7 +291,7 @@ export default { tableMode: '决策表模式', execPanel: '执行面板 (Ctrl+`)', saveTitle: '保存 (Ctrl+S)', - emptyHint: '从左侧选择规则集开始编辑', + emptyHint: '从左侧选择规则集或项目级子规则开始编辑', newRulesetBtn: '新建规则集', createDialog: '新建规则集', nameLabel: '规则集名称', @@ -316,6 +318,23 @@ export default { deleteSuccess: '已删除', tableUnsupported: '当前规则集不支持决策表模式', deleteTitle: '删除', + conceptMaterializationError: '概念表达式无法参与执行:{message}', + }, + knowledgeAdvisor: { + title: '知识面', + missingSummary: '检测到 {count} 个未登记输入', + noContractSummary: '字段已接入,尚未定义决策契约', + readySummary: '事实、概念和契约已接入当前编辑器', + detail: '当前流程引用 {fields} 个输入,项目已有 {facts} 个事实、{concepts} 个概念', + fieldsStat: '{count} 个输入', + assetsStat: '{facts} 事实 / {concepts} 概念', + contractReady: '契约已定义', + contractMissing: '缺少契约', + createMissingFacts: '登记 {count} 个事实', + openContract: '查看契约', + defineContract: '定义契约', + generatedFactDescription: '由规则编辑器根据当前流程引用自动登记', + createFactsSuccess: '已登记 {count} 个事实', }, menuBar: { file: '文件', @@ -496,6 +515,66 @@ export default { fieldName: '字段名', fieldDesc: '描述', }, + subRules: { + title: '子规则资产', + desc: '管理可复用的原子决策图,供规则流程中的子规则节点引用。', + createTitle: '创建子规则', + createDesc: + '子规则拥有自己的节点图、决策表、输入契约和输出契约。规则集只引用已管理的子规则资产。', + create: '创建', + empty: '暂无子规则资产', + placeholder: '选择一个子规则,或创建一个新的子规则资产', + searchPlaceholder: '按名称、描述或范围搜索', + name: '名称', + namePlaceholder: '例如 risk_score', + nameRequired: '请输入子规则名称', + displayName: '显示名称', + displayNamePlaceholder: '例如 风险评分', + description: '描述', + descriptionPlaceholder: '说明这个子规则提供的原子能力', + assetScope: '资产范围', + scopeProject: '项目级', + scopeOrg: '组织级', + published: '已发布', + publishedAt: '发布时间', + projectAssets: '项目级子规则', + orgAssets: '组织级子规则', + noProjectAssets: '暂无项目级子规则。', + noOrgAssets: '暂无组织级子规则。', + noDescription: '暂无描述', + status: '状态', + unpublished: '未发布', + updatedAt: '更新时间', + loadFailed: '加载子规则失败', + saveFailed: '保存子规则失败', + createSuccess: '子规则已创建', + usageTitle: '流程引用', + usageDesc: '规则流程中的子规则节点会保存这个资产引用;发布时会解析为确定版本快照。', + defaultTerminalName: '返回结果', + defaultDescription: '可复用的子规则决策图。', + openInEditor: '在编辑器中打开', + focusTitle: '正在编辑子规则:{name}', + focusDesc: '这是一个可复用的聚焦图,返回父规则前请先保存。', + returnParent: '返回父规则', + returnDispatcher: '子规则返回分派', + extractTitle: '提取为子规则', + extractDesc: '将选中的 {count} 个步骤创建为托管子规则,并用一个子规则节点替换当前选区。', + extractConfirm: '确认提取', + extractSuccess: '已提取子规则 {name}', + extractedDescription: '从 {count} 个选中步骤提取。', + nameExists: '子规则「{name}」已存在', + suggestionsTitle: '子规则机会', + suggestionsDesc: '已在当前图中识别出可复用片段,提取后父流程会更清晰。', + suggestionGroupTitle: '分组:{name}', + suggestionGroupDesc: '这个分组已经像一个可复用的决策能力。', + suggestionDecisionTitle: '分支簇:{name}', + suggestionDecisionDesc: '{count} 个连接步骤来自同一个决策分支。', + suggestionChainTitle: '流程片段:{name}', + suggestionChainDesc: '{count} 个步骤构成单入口、单出口执行片段。', + suggestionCta: '选中并提取', + suggestionSteps: '{count} 个步骤', + executionHydrating: '正在装配子规则执行快照...', + }, versions: { title: '版本历史', desc: '查看规则集的变更历史,支持回滚到任意历史版本(需 admin+)', @@ -541,6 +620,7 @@ export default { actionEditRuleset: '编辑规则集', actionRestoreSnapshot: '恢复:{action}', actionSaveCheckpoint: '保存检查点', + actionExtractSubRule: '提取子规则 {name}', }, dashboard: { workspaceTitle: '项目工作台', @@ -829,6 +909,33 @@ export default { expected: '期望', actual: '实际', }, + trace: { + terminal: '终止', + subRule: '子规则', + openSubRule: '打开追踪', + callInput: '调用输入', + outputMappings: '输出回写', + missingOutput: '子变量缺失', + innerFrames: '内部追踪', + showInFlow: '在流程图中显示', + failureKinds: { + reference: '引用', + contract: '契约', + binding: '绑定', + subRule: '执行', + output: '输出', + assertion: '断言', + execution: '运行时', + }, + }, + subRuleProbe: { + title: '子规则试跑', + desc: '直接运行当前子规则草稿,输入可从契约字段生成;不会创建持久化测试用例。', + seed: '生成输入', + run: '运行子规则', + emptyResult: '运行后可查看输出和步骤追踪。', + runFinished: '子规则试跑完成,但存在失败项', + }, project: { title: '项目测试', runAll: '运行全部项目测试', @@ -1062,6 +1169,9 @@ export default { diffAddedGroups: '新增分组', diffModifiedGroups: '修改分组', diffRemovedGroups: '删除分组', + diffAddedSubRules: '新增子规则', + diffModifiedSubRules: '变更子规则', + diffRemovedSubRules: '移除子规则', diffInputSchemaChanged: '输入契约有变化', diffOutputSchemaChanged: '输出契约有变化', diffTagsChanged: '标签有变化', @@ -1082,6 +1192,10 @@ export default { minApprovals: '最少审批数', approvers: '审批人', rollbackPolicy: '回退策略', + subRuleDependencies: '子规则依赖快照', + subRuleDraftSeq: '草稿序号', + subRuleAssetId: '资产 ID', + subRuleSnapshotHash: '快照哈希', metricGuard: '指标守护', updatedAt: '更新时间', statusPendingApproval: '待审批', diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts index bd7ceb67..b13fc6d1 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts @@ -270,6 +270,7 @@ export default { facts: '事實目錄', concepts: '概念註冊', contracts: '決策契約', + subRules: '子規則', tests: '測試', versions: '版本歷史', trace: '執行追蹤', @@ -280,6 +281,7 @@ export default { settings: '設定', }, editor: { + rulesets: '規則集', noRulesets: '暫無規則集', newRuleset: '新建規則集', unsaved: '未儲存', @@ -289,7 +291,7 @@ export default { tableMode: '決策表模式', execPanel: '執行面板 (Ctrl+`)', saveTitle: '儲存 (Ctrl+S)', - emptyHint: '從左側選擇規則集開始編輯', + emptyHint: '從左側選擇規則集或專案級子規則開始編輯', newRulesetBtn: '新建規則集', createDialog: '新建規則集', nameLabel: '規則集名稱', @@ -316,6 +318,23 @@ export default { deleteSuccess: '已刪除', tableUnsupported: '目前規則集不支援決策表模式', deleteTitle: '刪除', + conceptMaterializationError: '概念表達式無法參與執行:{message}', + }, + knowledgeAdvisor: { + title: '知識面', + missingSummary: '偵測到 {count} 個未登記輸入', + noContractSummary: '欄位已接入,尚未定義決策契約', + readySummary: '事實、概念和契約已接入目前編輯器', + detail: '目前流程引用 {fields} 個輸入,專案已有 {facts} 個事實、{concepts} 個概念', + fieldsStat: '{count} 個輸入', + assetsStat: '{facts} 事實 / {concepts} 概念', + contractReady: '契約已定義', + contractMissing: '缺少契約', + createMissingFacts: '登記 {count} 個事實', + openContract: '查看契約', + defineContract: '定義契約', + generatedFactDescription: '由規則編輯器根據目前流程引用自動登記', + createFactsSuccess: '已登記 {count} 個事實', }, menuBar: { file: '檔案', @@ -496,6 +515,66 @@ export default { fieldName: '欄位名', fieldDesc: '描述', }, + subRules: { + title: '子規則資產', + desc: '管理可重用的原子決策圖,供規則流程中的子規則節點引用。', + createTitle: '建立子規則', + createDesc: + '子規則擁有自己的節點圖、決策表、輸入契約與輸出契約。規則集只引用已管理的子規則資產。', + create: '建立', + empty: '暫無子規則資產', + placeholder: '選擇一個子規則,或建立一個新的子規則資產', + searchPlaceholder: '依名稱、描述或範圍搜尋', + name: '名稱', + namePlaceholder: '例如 risk_score', + nameRequired: '請輸入子規則名稱', + displayName: '顯示名稱', + displayNamePlaceholder: '例如 風險評分', + description: '描述', + descriptionPlaceholder: '說明這個子規則提供的原子能力', + assetScope: '資產範圍', + scopeProject: '專案級', + scopeOrg: '組織級', + published: '已發佈', + publishedAt: '發佈時間', + projectAssets: '專案級子規則', + orgAssets: '組織級子規則', + noProjectAssets: '暫無專案級子規則。', + noOrgAssets: '暫無組織級子規則。', + noDescription: '暫無描述', + status: '狀態', + unpublished: '未發佈', + updatedAt: '更新時間', + loadFailed: '載入子規則失敗', + saveFailed: '儲存子規則失敗', + createSuccess: '子規則已建立', + usageTitle: '流程引用', + usageDesc: '規則流程中的子規則節點會保存這個資產引用;發佈時會解析為確定版本快照。', + defaultTerminalName: '返回結果', + defaultDescription: '可重用的子規則決策圖。', + openInEditor: '在編輯器中開啟', + focusTitle: '正在編輯子規則:{name}', + focusDesc: '這是一個可重用的聚焦圖,返回父規則前請先儲存。', + returnParent: '返回父規則', + returnDispatcher: '子規則返回分派', + extractTitle: '提取為子規則', + extractDesc: '將所選的 {count} 個步驟建立為託管子規則,並用一個子規則節點替換目前選區。', + extractConfirm: '確認提取', + extractSuccess: '已提取子規則 {name}', + extractedDescription: '從 {count} 個所選步驟提取。', + nameExists: '子規則「{name}」已存在', + suggestionsTitle: '子規則機會', + suggestionsDesc: '已在目前圖中識別出可重用片段,提取後父流程會更清晰。', + suggestionGroupTitle: '群組:{name}', + suggestionGroupDesc: '這個群組已經像一個可重用的決策能力。', + suggestionDecisionTitle: '分支簇:{name}', + suggestionDecisionDesc: '{count} 個連接步驟來自同一個決策分支。', + suggestionChainTitle: '流程片段:{name}', + suggestionChainDesc: '{count} 個步驟構成單入口、單出口執行片段。', + suggestionCta: '選中並提取', + suggestionSteps: '{count} 個步驟', + executionHydrating: '正在裝配子規則執行快照...', + }, versions: { title: '版本歷史', desc: '查看規則集的變更歷史,支援回滾到任意歷史版本(需 admin+)', @@ -541,6 +620,7 @@ export default { actionEditRuleset: '編輯規則集', actionRestoreSnapshot: '恢復:{action}', actionSaveCheckpoint: '儲存檢查點', + actionExtractSubRule: '提取子規則 {name}', }, dashboard: { workspaceTitle: '專案工作台', @@ -829,6 +909,33 @@ export default { expected: '期望', actual: '實際', }, + trace: { + terminal: '終止', + subRule: '子規則', + openSubRule: '開啟追蹤', + callInput: '呼叫輸入', + outputMappings: '輸出回寫', + missingOutput: '子變數缺失', + innerFrames: '內部追蹤', + showInFlow: '在流程圖中顯示', + failureKinds: { + reference: '引用', + contract: '契約', + binding: '綁定', + subRule: '執行', + output: '輸出', + assertion: '斷言', + execution: '執行期', + }, + }, + subRuleProbe: { + title: '子規則試跑', + desc: '直接執行目前子規則草稿,輸入可從契約欄位產生;不會建立持久化測試案例。', + seed: '產生輸入', + run: '執行子規則', + emptyResult: '執行後可查看輸出和步驟追蹤。', + runFinished: '子規則試跑完成,但存在失敗項', + }, project: { title: '專案測試', runAll: '執行全部專案測試', @@ -1062,6 +1169,9 @@ export default { diffAddedGroups: '新增分組', diffModifiedGroups: '修改分組', diffRemovedGroups: '刪除分組', + diffAddedSubRules: '新增子規則', + diffModifiedSubRules: '變更子規則', + diffRemovedSubRules: '移除子規則', diffInputSchemaChanged: '輸入契約有變更', diffOutputSchemaChanged: '輸出契約有變更', diffTagsChanged: '標籤有變更', @@ -1082,6 +1192,10 @@ export default { minApprovals: '最少審批數', approvers: '審批人', rollbackPolicy: '回退策略', + subRuleDependencies: '子規則依賴快照', + subRuleDraftSeq: '草稿序號', + subRuleAssetId: '資產 ID', + subRuleSnapshotHash: '快照雜湊', metricGuard: '指標守護', updatedAt: '更新時間', statusPendingApproval: '待審批', diff --git a/ordo-editor/apps/studio/src/router/index.ts b/ordo-editor/apps/studio/src/router/index.ts index 31f2fb8e..42727c7d 100644 --- a/ordo-editor/apps/studio/src/router/index.ts +++ b/ordo-editor/apps/studio/src/router/index.ts @@ -140,6 +140,11 @@ const router = createRouter({ name: 'contracts', component: () => import('@/views/project/ContractView.vue'), }, + { + path: 'sub-rules', + name: 'project-sub-rules', + component: () => import('@/views/project/SubRulesView.vue'), + }, { path: 'tests', name: 'tests', diff --git a/ordo-editor/apps/studio/src/stores/project.ts b/ordo-editor/apps/studio/src/stores/project.ts index 39cd3753..10133bb7 100644 --- a/ordo-editor/apps/studio/src/stores/project.ts +++ b/ordo-editor/apps/studio/src/stores/project.ts @@ -1,7 +1,8 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; -import { projectApi, rulesetDraftApi } from '@/api/platform-client'; +import { projectApi, rulesetDraftApi, subRuleApi } from '@/api/platform-client'; import { normalizeRuleset } from '@/utils/ruleset'; +import { stripRuntimeGeneratedArtifacts } from '@/utils/ruleset'; import { useAuthStore } from './auth'; import { useOrgStore } from './org'; import type { @@ -10,6 +11,7 @@ import type { ProjectRuleset, ProjectRulesetMeta, RuleSetInfo, + SubRuleScope, } from '@/api/types'; import type { RuleSet } from '@ordo-engine/editor-core'; @@ -21,6 +23,10 @@ export interface OpenTab { dirty: boolean; /** Platform draft sequence number for optimistic locking */ draft_seq: number; + /** 'sub_rule' when this tab holds a managed SubRule asset draft */ + kind?: 'sub_rule'; + /** SubRule asset scope (only set when kind === 'sub_rule') */ + subRuleScope?: SubRuleScope; } export const useProjectStore = defineStore('project', () => { @@ -133,6 +139,32 @@ export const useProjectStore = defineStore('project', () => { activeTabName.value = name; } + async function openSubRule(name: string, scope: SubRuleScope = 'project') { + if (!auth.token || !currentProject.value) return; + const tabName = `§${name}`; + const existing = openTabs.value.find((t) => t.name === tabName); + if (existing) { + activeTabName.value = tabName; + return; + } + const org = orgStore.currentOrg; + if (!org) throw new Error('No active org'); + const asset = + scope === 'org' + ? await subRuleApi.getOrg(auth.token, org.id, name) + : await subRuleApi.getProject(auth.token, org.id, currentProject.value.id, name); + const ruleset = normalizeRuleset(asset.draft, name); + openTabs.value.push({ + name: tabName, + ruleset, + dirty: false, + draft_seq: asset.draft_seq, + kind: 'sub_rule', + subRuleScope: scope, + }); + activeTabName.value = tabName; + } + function updateActiveRuleset(ruleset: RuleSet) { const tab = openTabs.value.find((t) => t.name === activeTabName.value); if (tab) { @@ -160,8 +192,34 @@ export const useProjectStore = defineStore('project', () => { const org = orgStore.currentOrg; if (!org) throw new Error('No active org'); + if (tab.kind === 'sub_rule') { + const assetName = name.startsWith('§') ? name.slice(1) : name; + const sanitizedRuleset = stripRuntimeGeneratedArtifacts(tab.ruleset); + const asset = + tab.subRuleScope === 'org' + ? await subRuleApi.saveOrg(auth.token, org.id, assetName, { + name: assetName, + draft: sanitizedRuleset as any, + input_schema: [], + output_schema: [], + expected_seq: tab.draft_seq, + }) + : await subRuleApi.saveProject(auth.token, org.id, currentProject.value.id, assetName, { + name: assetName, + draft: sanitizedRuleset as any, + input_schema: [], + output_schema: [], + expected_seq: tab.draft_seq, + }); + tab.ruleset = sanitizedRuleset; + tab.dirty = false; + tab.draft_seq = asset.draft_seq; + return null; + } + + const sanitizedRuleset = stripRuntimeGeneratedArtifacts(tab.ruleset); const result = await rulesetDraftApi.save(auth.token, org.id, currentProject.value.id, name, { - ruleset: tab.ruleset as any, + ruleset: sanitizedRuleset as any, expected_seq: tab.draft_seq, }); @@ -169,6 +227,7 @@ export const useProjectStore = defineStore('project', () => { return result; } + tab.ruleset = sanitizedRuleset; tab.dirty = false; tab.draft_seq = result.draft_seq; upsertDraftMeta(pickDraftMeta(result)); @@ -244,6 +303,7 @@ export const useProjectStore = defineStore('project', () => { selectProject, fetchRulesets, openRuleset, + openSubRule, updateActiveRuleset, setTabRuleset, saveRuleset, diff --git a/ordo-editor/apps/studio/src/styles/ordo-theme.css b/ordo-editor/apps/studio/src/styles/ordo-theme.css index 4c9b6e6c..f7446e94 100644 --- a/ordo-editor/apps/studio/src/styles/ordo-theme.css +++ b/ordo-editor/apps/studio/src/styles/ordo-theme.css @@ -108,6 +108,7 @@ --ordo-node-decision: var(--ordo-warning); --ordo-node-action: var(--ordo-accent); --ordo-node-terminal: var(--ordo-success); + --ordo-node-sub-rule: #5b708a; --ordo-keyword: var(--ordo-accent); --ordo-variable: #d4860e; @@ -201,6 +202,7 @@ --ordo-node-decision: var(--ordo-warning); --ordo-node-action: var(--ordo-accent); --ordo-node-terminal: var(--ordo-success); + --ordo-node-sub-rule: #8fa7c1; --ordo-keyword: var(--ordo-accent); --ordo-variable: #ffd27f; diff --git a/ordo-editor/apps/studio/src/utils/ruleset.ts b/ordo-editor/apps/studio/src/utils/ruleset.ts index 989685a5..47b3f019 100644 --- a/ordo-editor/apps/studio/src/utils/ruleset.ts +++ b/ordo-editor/apps/studio/src/utils/ruleset.ts @@ -25,20 +25,20 @@ export function isEngineRuleset(value: unknown): value is { export function normalizeRuleset(input: unknown, fallbackName = 'untitled'): RuleSet { if (isEditorRuleset(input)) { - return { + return stripRuntimeGeneratedArtifacts({ ...input, steps: input.steps, groups: Array.isArray(input.groups) ? input.groups : [], metadata: isRecord(input.metadata) ? input.metadata : undefined, - }; + }); } if (isEngineRuleset(input)) { const normalized = convertFromEngineFormat(input as any); - return { + return stripRuntimeGeneratedArtifacts({ ...normalized, groups: Array.isArray(normalized.groups) ? normalized.groups : [], - }; + }); } return { @@ -53,3 +53,96 @@ export function normalizeRuleset(input: unknown, fallbackName = 'untitled'): Rul groups: [], }; } + +function cloneRuleset(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +function safeRuntimeName(value: string) { + return ( + value + .trim() + .replace(/[^a-zA-Z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') || 'value' + ); +} + +function runtimeRefSuffixForStep(stepId: string) { + return `__${safeRuntimeName(stepId)}_terminal_return`; +} + +function collectRuntimePatterns(steps: any[]) { + const exactStepIds = new Set(); + const terminalPrefixes: string[] = []; + const subRuleSuffixes: string[] = []; + + for (const step of steps) { + if (!step || step.type !== 'sub_rule' || typeof step.id !== 'string') continue; + exactStepIds.add(`${step.id}__return_dispatch`); + exactStepIds.add(`${step.id}__return_to_parent`); + terminalPrefixes.push(`${step.id}__terminal_`); + subRuleSuffixes.push(runtimeRefSuffixForStep(step.id)); + } + + return { exactStepIds, terminalPrefixes, subRuleSuffixes }; +} + +function isRuntimeGeneratedStep( + step: any, + patterns: ReturnType +): boolean { + if (!step || typeof step !== 'object') return false; + if (step.systemGenerated === 'sub_rule_runtime') return true; + const id = typeof step.id === 'string' ? step.id : ''; + return ( + patterns.exactStepIds.has(id) || + patterns.terminalPrefixes.some((prefix) => id.startsWith(prefix)) + ); +} + +function restoreSubRuleStep(step: any): any { + if (!step || step.type !== 'sub_rule' || typeof step.refName !== 'string') return step; + + const suffix = runtimeRefSuffixForStep(String(step.id ?? '')); + if (!step.refName.endsWith(suffix)) return step; + + return { + ...step, + refName: step.refName.slice(0, -suffix.length), + returnPolicy: 'propagate_terminal', + nextStepId: '', + outputs: undefined, + }; +} + +export function stripRuntimeGeneratedArtifacts(ruleset: RuleSet): RuleSet { + const cloned = cloneRuleset(ruleset); + const patterns = collectRuntimePatterns(cloned.steps ?? []); + + cloned.steps = (cloned.steps ?? []) + .filter((step: any) => !isRuntimeGeneratedStep(step, patterns)) + .map(restoreSubRuleStep); + + if (cloned.subRules) { + cloned.subRules = Object.fromEntries( + Object.entries(cloned.subRules) + .filter(([name]) => !patterns.subRuleSuffixes.some((suffix) => name.endsWith(suffix))) + .map(([name, graph]: [string, any]) => [ + name, + stripRuntimeGeneratedArtifactsFromGraph(graph), + ]) + ); + } + + return cloned; +} + +function stripRuntimeGeneratedArtifactsFromGraph(graph: any) { + const graphPatterns = collectRuntimePatterns(graph.steps ?? []); + return { + ...graph, + steps: (graph.steps ?? []) + .filter((step: any) => !isRuntimeGeneratedStep(step, graphPatterns)) + .map(restoreSubRuleStep), + }; +} diff --git a/ordo-editor/apps/studio/src/views/editor/EditorView.vue b/ordo-editor/apps/studio/src/views/editor/EditorView.vue index e0033098..6c63b9f4 100644 --- a/ordo-editor/apps/studio/src/views/editor/EditorView.vue +++ b/ordo-editor/apps/studio/src/views/editor/EditorView.vue @@ -1,5 +1,5 @@ - + + @@ -1661,6 +1797,65 @@ async function doControlExecution(action: 'pause' | 'resume' | 'rollback') { gap: 12px; } +.sub-rule-dependency-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; +} + +.sub-rule-dependency { + padding: 14px 16px; + border-radius: 14px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: linear-gradient(180deg, rgba(244, 247, 255, 0.96), rgba(255, 255, 255, 0.98)); + display: grid; + gap: 10px; +} + +.sub-rule-dependency__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.sub-rule-dependency__title { + font-size: 14px; + font-weight: 600; + color: var(--ordo-text-primary); +} + +.sub-rule-dependency__name { + margin-top: 3px; + font-size: 12px; + color: var(--ordo-text-secondary); + font-family: 'JetBrains Mono', monospace; +} + +.sub-rule-dependency__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.sub-rule-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.05); + font-size: 11px; + color: var(--ordo-text-secondary); +} + +.sub-rule-pill strong, +.sub-rule-pill code { + color: var(--ordo-text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + /* ── text blocks ───────────────────────────────────────────────────────── */ .text-sections { display: flex; @@ -1982,6 +2177,12 @@ async function doControlExecution(action: 'pause' | 'resume' | 'rollback') { color: var(--ordo-text-secondary); font-family: monospace; } + +.diff-item--subrule span { + display: inline-flex; + align-items: center; + gap: 4px; +} .diff-empty { font-size: 12px; color: var(--ordo-text-tertiary); diff --git a/ordo-editor/apps/studio/src/views/project/SubRulesView.vue b/ordo-editor/apps/studio/src/views/project/SubRulesView.vue new file mode 100644 index 00000000..e5ef9b97 --- /dev/null +++ b/ordo-editor/apps/studio/src/views/project/SubRulesView.vue @@ -0,0 +1,646 @@ + + + + + diff --git a/ordo-editor/apps/studio/src/views/server/ServerRegistryView.vue b/ordo-editor/apps/studio/src/views/server/ServerRegistryView.vue index 2e179e51..10dea469 100644 --- a/ordo-editor/apps/studio/src/views/server/ServerRegistryView.vue +++ b/ordo-editor/apps/studio/src/views/server/ServerRegistryView.vue @@ -53,22 +53,23 @@ const filterOptions = computed(() => [ const columns = computed(() => [ { colKey: 'name', title: t('settings.serverRegistry.serverName'), width: 240 }, - { colKey: 'url', title: t('settings.serverRegistry.endpoint'), minWidth: 240 }, + { colKey: 'url', title: t('settings.serverRegistry.endpoint'), width: 200 }, { colKey: 'status', title: t('settings.serverRegistry.status'), - width: 120, + width: 92, align: 'center' as const, }, - { colKey: 'version', title: t('settings.serverRegistry.version'), width: 120 }, - { colKey: 'last_seen', title: t('settings.serverRegistry.lastSeen'), width: 190 }, - { colKey: 'registered_at', title: t('settings.serverRegistry.registeredAt'), width: 190 }, - { colKey: 'labels', title: t('settings.serverRegistry.labels'), minWidth: 180 }, + { colKey: 'version', title: t('settings.serverRegistry.version'), width: 96 }, + { colKey: 'last_seen', title: t('settings.serverRegistry.lastSeen'), width: 144 }, + { colKey: 'registered_at', title: t('settings.serverRegistry.registeredAt'), width: 144 }, + { colKey: 'labels', title: t('settings.serverRegistry.labels'), width: 160 }, { colKey: 'actions', title: t('settings.serverRegistry.actions'), - width: 220, + width: 148, align: 'right' as const, + fixed: 'right' as const, }, ]); @@ -111,6 +112,25 @@ function formatTimestamp(value: string | null | undefined) { return new Date(value).toLocaleString(); } +function formatTimestampParts(value: string | null | undefined) { + if (!value) { + return { + primary: t('settings.serverRegistry.neverSeen'), + secondary: '', + }; + } + + const date = new Date(value); + return { + primary: date.toLocaleDateString(), + secondary: date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }), + }; +} + function statusLabel(status: ServerInfo['status']) { return t(`settings.serverRegistry.statusMap.${status}`); } @@ -257,6 +277,7 @@ onMounted(loadServers); @@ -480,6 +513,7 @@ onMounted(loadServers); border: 1px solid var(--ordo-border-color); border-radius: 10px; padding: 16px; + overflow: hidden; } .toolbar { @@ -497,6 +531,14 @@ onMounted(loadServers); .server-name-cell { display: grid; gap: 4px; + min-width: 0; +} + +.server-name-cell strong { + display: block; + line-height: 1.35; + white-space: normal; + word-break: break-word; } .server-id, @@ -511,24 +553,94 @@ onMounted(loadServers); font-family: 'JetBrains Mono', monospace; } +.server-id { + display: block; + white-space: normal; + word-break: break-all; + overflow-wrap: anywhere; + line-height: 1.35; +} + +.mono-ellipsis { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.time-cell { + display: grid; + gap: 2px; + line-height: 1.25; +} + +.time-cell strong { + font-size: 12px; + font-weight: 600; + color: var(--ordo-text-primary); +} + +.time-cell span { + font-size: 11px; + color: var(--ordo-text-secondary); +} + .label-list { display: flex; flex-wrap: wrap; gap: 6px; + max-height: 48px; + overflow: hidden; } .label-chip { + max-width: 100%; padding: 3px 8px; border-radius: 999px; background: var(--ordo-bg-item-hover); color: var(--ordo-text-secondary); font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .action-group { display: flex; justify-content: flex-end; gap: 4px; + white-space: nowrap; +} + +:deep(.server-table .t-table__content) { + overflow-x: auto; +} + +:deep(.server-table td), +:deep(.server-table th) { + vertical-align: middle; +} + +:deep(.server-table tbody td:first-child) { + overflow: visible; +} + +:deep(.server-table tbody td:first-child .cell) { + display: block; + overflow: visible; + white-space: normal; + text-overflow: clip; + word-break: break-word; + line-height: 1.4; +} + +:deep(.server-table .t-table__fixed-right), +:deep(.server-table .t-table__fixed-right-last) { + background: var(--ordo-bg-panel); +} + +:deep(.server-table .t-table__fixed-right-first::before) { + box-shadow: -10px 0 16px -14px rgba(15, 23, 42, 0.28); } .dialog-summary { diff --git a/ordo-editor/packages/core/src/engine/__tests__/adapter.test.ts b/ordo-editor/packages/core/src/engine/__tests__/adapter.test.ts index b574671a..fcae3af2 100644 --- a/ordo-editor/packages/core/src/engine/__tests__/adapter.test.ts +++ b/ordo-editor/packages/core/src/engine/__tests__/adapter.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; import { convertToEngineFormat, isEngineCompatible, validateEngineCompatibility } from '../adapter'; import { convertFromEngineFormat } from '../reverse-adapter'; +import { validateRuleSet } from '../../validator'; import { Expr, type ActionStep, type DecisionStep, type RuleSet, + type SubRuleStep, type TerminalStep, } from '../../model'; @@ -199,6 +201,164 @@ describe('Format Adapter', () => { }, ]); }); + + it('converts sub-rule graphs with contract metadata', () => { + const editorRuleset: RuleSet = { + config: { name: 'sub-rule-test' }, + startStepId: 'call', + steps: [ + { + id: 'call', + name: 'Call Tiering', + type: 'sub_rule', + refName: 'tiering', + bindings: [{ field: 'score', expr: Expr.variable('$.score') }], + outputs: [{ parentVar: 'tier', childVar: 'tier' }], + nextStepId: 'done', + } as SubRuleStep, + { + id: 'done', + name: 'Done', + type: 'terminal', + code: 'OK', + } as TerminalStep, + ], + subRules: { + tiering: { + entryStep: 'set_tier', + inputSchema: [{ name: 'score', type: 'number', required: true }], + outputSchema: [{ name: 'tier', type: 'string', required: true }], + steps: [ + { + id: 'set_tier', + name: 'Set Tier', + type: 'action', + assignments: [{ name: 'tier', value: Expr.string('gold') }], + nextStepId: 'done', + } as ActionStep, + { + id: 'done', + name: 'Done', + type: 'terminal', + code: 'OK', + } as TerminalStep, + ], + }, + }, + }; + + const engineRuleset = convertToEngineFormat(editorRuleset); + + expect(engineRuleset.steps['call']).toMatchObject({ + type: 'sub_rule', + ref_name: 'tiering', + bindings: [['score', { Field: 'score' }]], + outputs: [['tier', 'tier']], + next_step: 'done', + }); + expect(engineRuleset.sub_rules?.tiering.input_schema).toEqual([ + { name: 'score', type: 'number', required: true }, + ]); + expect(engineRuleset.sub_rules?.tiering.output_schema).toEqual([ + { name: 'tier', type: 'string', required: true }, + ]); + }); + + it('preserves variable namespace for runtime sub-rule bridge outputs', () => { + const editorRuleset: RuleSet = { + config: { name: 'runtime-sub-rule-bridge' }, + startStepId: 'dispatch', + steps: [ + { + id: 'dispatch', + name: 'Dispatch', + type: 'decision', + branches: [ + { + id: 'b1', + condition: { + type: 'simple', + left: Expr.variable('$__ordo_sub_terminal_id'), + operator: 'eq', + right: Expr.string('vip'), + }, + nextStepId: 'vip-terminal', + }, + ], + defaultNextStepId: 'default-terminal', + } as DecisionStep, + { + id: 'default-terminal', + name: 'Default', + type: 'terminal', + code: 'DEFAULT', + output: [ + { + name: 'coupon_type', + value: Expr.variable('$__ordo_sub_coupon_type'), + }, + ], + } as TerminalStep, + { + id: 'vip-terminal', + name: 'VIP', + type: 'terminal', + code: 'VIP', + } as TerminalStep, + ], + }; + + const engineRuleset = convertToEngineFormat(editorRuleset); + + expect(engineRuleset.steps.dispatch.branches?.[0]?.condition).toBe( + '$__ordo_sub_terminal_id == "vip"' + ); + expect(engineRuleset.steps['default-terminal'].result?.output).toEqual([ + ['coupon_type', { Field: '$__ordo_sub_coupon_type' }], + ]); + }); + + it('preserves runtime variable references while normalizing input fields', () => { + const editorRuleset: RuleSet = { + config: { name: 'variable-reference-test' }, + startStepId: 'set_result', + steps: [ + { + id: 'set_result', + name: 'Set Result', + type: 'action', + assignments: [ + { name: 'message', value: Expr.variable('$.input_message') }, + { name: 'status', value: Expr.string('OK') }, + ], + nextStepId: 'done', + } as ActionStep, + { + id: 'done', + name: 'Done', + type: 'terminal', + code: 'OK', + message: Expr.string('Done'), + output: [{ name: 'status', value: Expr.variable('$status') }], + } as TerminalStep, + ], + }; + + const engineRuleset = convertToEngineFormat(editorRuleset); + + expect(engineRuleset.steps['set_result']).toMatchObject({ + actions: [ + { action: 'set_variable', name: 'message', value: { Field: 'input_message' } }, + { action: 'set_variable', name: 'status', value: { Literal: 'OK' } }, + ], + }); + expect(engineRuleset.steps['done']).toMatchObject({ + result: { + message: 'Done', + output: [['status', { Field: '$status' }]], + }, + }); + }); }); describe('convertFromEngineFormat', () => { @@ -268,6 +428,54 @@ describe('Format Adapter', () => { }, ]); }); + + it('reconstructs sub-rule graphs with contract metadata', () => { + const editorRuleset = convertFromEngineFormat({ + config: { + name: 'reverse-sub-rule', + version: '1.0.0', + description: '', + entry_step: 'call', + }, + steps: { + call: { + id: 'call', + name: 'Call', + type: 'sub_rule', + ref_name: 'tiering', + bindings: [['score', { Field: 'score' }]], + outputs: [['tier', 'tier']], + next_step: 'done', + }, + done: { + id: 'done', + name: 'Done', + type: 'terminal', + result: { code: 'OK', message: '', output: [], data: null }, + }, + }, + sub_rules: { + tiering: { + entry_step: 'finish', + input_schema: [{ name: 'score', type: 'number', required: true }], + output_schema: [{ name: 'tier', type: 'string', required: true }], + steps: { + finish: { + id: 'finish', + name: 'Finish', + type: 'terminal', + result: { code: 'OK', message: '', output: [], data: null }, + }, + }, + }, + }, + }); + + expect(editorRuleset.subRules?.tiering.inputSchema).toEqual([ + { name: 'score', type: 'number', required: true }, + ]); + expect((editorRuleset.steps[0] as SubRuleStep).refName).toBe('tiering'); + }); }); describe('validateEngineCompatibility', () => { @@ -352,4 +560,147 @@ describe('Format Adapter', () => { expect(errors.some((e) => e.includes('non-existent'))).toBe(true); }); }); + + describe('validateRuleSet sub-rules', () => { + it('rejects missing required bindings and output mappings', () => { + const ruleset: RuleSet = { + config: { name: 'invalid-sub-rule-contract' }, + startStepId: 'call', + steps: [ + { + id: 'call', + name: 'Call', + type: 'sub_rule', + refName: 'tiering', + bindings: [], + outputs: [], + nextStepId: 'done', + } as SubRuleStep, + { id: 'done', name: 'Done', type: 'terminal', code: 'OK' } as TerminalStep, + ], + subRules: { + tiering: { + entryStep: 'done', + inputSchema: [{ name: 'score', type: 'number', required: true }], + outputSchema: [{ name: 'tier', type: 'string', required: true }], + steps: [{ id: 'done', name: 'Done', type: 'terminal', code: 'OK' } as TerminalStep], + }, + }, + }; + + const result = validateRuleSet(ruleset); + + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.code === 'MISSING_SUB_RULE_INPUT_BINDING')).toBe( + true + ); + expect(result.errors.some((error) => error.code === 'MISSING_SUB_RULE_OUTPUT_MAPPING')).toBe( + true + ); + }); + + it('rejects sub-rule call cycles', () => { + const ruleset: RuleSet = { + config: { name: 'sub-rule-cycle' }, + startStepId: 'call', + steps: [ + { + id: 'call', + name: 'Call', + type: 'sub_rule', + refName: 'a', + nextStepId: 'done', + } as SubRuleStep, + { id: 'done', name: 'Done', type: 'terminal', code: 'OK' } as TerminalStep, + ], + subRules: { + a: { + entryStep: 'call_b', + steps: [ + { + id: 'call_b', + name: 'Call B', + type: 'sub_rule', + refName: 'b', + nextStepId: 'done', + } as SubRuleStep, + { id: 'done', name: 'Done', type: 'terminal', code: 'OK' } as TerminalStep, + ], + }, + b: { + entryStep: 'call_a', + steps: [ + { + id: 'call_a', + name: 'Call A', + type: 'sub_rule', + refName: 'a', + nextStepId: 'done', + } as SubRuleStep, + { id: 'done', name: 'Done', type: 'terminal', code: 'OK' } as TerminalStep, + ], + }, + }, + }; + + const result = validateRuleSet(ruleset); + + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.code === 'SUB_RULE_CYCLE')).toBe(true); + }); + + it('allows terminal-propagating sub-rules without an authoring next step', () => { + const ruleset: RuleSet = { + config: { name: 'terminal-propagation' }, + startStepId: 'call', + steps: [ + { + id: 'call', + name: 'Call', + type: 'sub_rule', + refName: 'tiering', + returnPolicy: 'propagate_terminal', + nextStepId: '', + } as SubRuleStep, + ], + subRules: { + tiering: { + entryStep: 'done', + steps: [{ id: 'done', name: 'Done', type: 'terminal', code: 'OK' } as TerminalStep], + }, + }, + }; + + const result = validateRuleSet(ruleset); + + expect(result.valid).toBe(true); + }); + + it('allows sub-rule calls to end the parent flow without a stored return policy', () => { + const ruleset: RuleSet = { + config: { name: 'legacy-empty-next-sub-rule' }, + startStepId: 'call', + steps: [ + { + id: 'call', + name: 'Call', + type: 'sub_rule', + refName: 'tiering', + nextStepId: '', + } as SubRuleStep, + ], + subRules: { + tiering: { + entryStep: 'done', + steps: [{ id: 'done', name: 'Done', type: 'terminal', code: 'OK' } as TerminalStep], + }, + }, + }; + + const result = validateRuleSet(ruleset); + + expect(result.valid).toBe(true); + expect(result.errors.some((error) => error.message.includes('has no next step'))).toBe(false); + }); + }); }); diff --git a/ordo-editor/packages/core/src/engine/adapter.ts b/ordo-editor/packages/core/src/engine/adapter.ts index fa3b40ec..a517ebee 100644 --- a/ordo-editor/packages/core/src/engine/adapter.ts +++ b/ordo-editor/packages/core/src/engine/adapter.ts @@ -41,6 +41,8 @@ interface EngineRuleSet { interface EngineSubRuleGraph { entry_step: string; steps: Record; + input_schema?: any; + output_schema?: any; } /** @@ -114,7 +116,12 @@ export function convertToEngineFormat(editorRuleset: RuleSet): EngineRuleSet { for (const step of graph.steps) { graphSteps[step.id] = convertStep(step); } - subRulesMap[name] = { entry_step: graph.entryStep, steps: graphSteps }; + subRulesMap[name] = { + entry_step: graph.entryStep, + steps: graphSteps, + ...(graph.inputSchema && { input_schema: graph.inputSchema }), + ...(graph.outputSchema && { output_schema: graph.outputSchema }), + }; } } @@ -249,15 +256,7 @@ function convertValueToExprString(value: any): string { // Handle variable reference if (value.type === 'variable' || value.type === 'field') { - let path = value.path || value.name || ''; - // Remove $. prefix - engine Context.get() uses bare paths like "order.amount" - // The Context stores input data at root level, not under "input." key - if (path.startsWith('$.')) { - path = path.slice(2); // $.order.amount -> order.amount - } else if (path.startsWith('$')) { - path = path.slice(1); - } - return path; + return normalizeFieldPath(value.path || value.name || ''); } // Handle literal value @@ -523,15 +522,7 @@ function convertToEngineExpr(value: any): any { if (typeof value === 'string') { // Check if it looks like a field reference if (value.startsWith('$') || value.startsWith('input.') || value.startsWith('vars.')) { - let path = value; - if (path.startsWith('$.')) { - path = path.slice(2); // $.order.amount -> order.amount - } else if (path.startsWith('input.')) { - path = path.slice(6); // input.order.amount -> order.amount - } else if (path.startsWith('$')) { - path = path.slice(1); - } - return { Field: path }; + return { Field: normalizeFieldPath(value) }; } // Otherwise treat as literal string return { Literal: value }; @@ -550,13 +541,7 @@ function convertToEngineExpr(value: any): any { // Editor variable format: { type: 'variable', path: '$.xxx' } if (value.type === 'variable' || value.type === 'field') { - let path = value.path || value.name || ''; - if (path.startsWith('$.')) { - path = path.slice(2); // Remove $. prefix - } else if (path.startsWith('$')) { - path = path.slice(1); - } - return { Field: path }; + return { Field: normalizeFieldPath(value.path || value.name || '') }; } // Editor expression format: { type: 'expression', expression: '...' } @@ -684,9 +669,6 @@ function normalizeFieldPath(path: string): string { if (path.startsWith('input.')) { return path.slice(6); } - if (path.startsWith('$')) { - return path.slice(1); - } return path; } @@ -793,7 +775,11 @@ export function validateEngineCompatibility(ruleset: RuleSet): string[] { case 'sub_rule': { const subRuleStep = step as SubRuleStep; - if (subRuleStep.nextStepId && !stepIds.has(subRuleStep.nextStepId)) { + if ( + subRuleStep.returnPolicy !== 'propagate_terminal' && + subRuleStep.nextStepId && + !stepIds.has(subRuleStep.nextStepId) + ) { errors.push( `Step '${step.id}' nextStepId references non-existent step '${subRuleStep.nextStepId}'` ); diff --git a/ordo-editor/packages/core/src/engine/reverse-adapter.ts b/ordo-editor/packages/core/src/engine/reverse-adapter.ts index 44451da2..74f80a55 100644 --- a/ordo-editor/packages/core/src/engine/reverse-adapter.ts +++ b/ordo-editor/packages/core/src/engine/reverse-adapter.ts @@ -41,6 +41,8 @@ interface EngineRuleSet { interface EngineSubRuleGraph { entry_step: string; steps: Record; + input_schema?: any; + output_schema?: any; } interface EngineStep { @@ -100,6 +102,8 @@ export function convertFromEngineFormat(engine: EngineRuleSet): RuleSet { subRules[name] = { entryStep: graph.entry_step, steps: Object.values(graph.steps).map(convertEngineStep), + ...(graph.input_schema && { inputSchema: graph.input_schema }), + ...(graph.output_schema && { outputSchema: graph.output_schema }), }; } } @@ -338,6 +342,7 @@ function convertEngineSubRuleStep(step: EngineStep): SubRuleStep { expr: convertFromEngineExpr(expr), })), outputs: (step.outputs ?? []).map(([parentVar, childVar]) => ({ parentVar, childVar })), + returnPolicy: step.next_step ? 'continue' : 'propagate_terminal', nextStepId: step.next_step ?? '', }; } diff --git a/ordo-editor/packages/core/src/engine/types.ts b/ordo-editor/packages/core/src/engine/types.ts index c455fa6f..843b917a 100644 --- a/ordo-editor/packages/core/src/engine/types.ts +++ b/ordo-editor/packages/core/src/engine/types.ts @@ -148,6 +148,27 @@ export interface StepTrace { duration_us: number; /** Step result (for decision steps) */ result?: string; + /** Next step ID when execution continues */ + next_step?: string | null; + /** Whether this step ended execution */ + is_terminal?: boolean; + /** Input data snapshot for this step */ + input_snapshot?: Record | null; + /** Runtime variable snapshot for this step */ + variables_snapshot?: Record | null; + /** Referenced sub-rule name when this step invokes a sub-rule */ + sub_rule_ref?: string | null; + /** Input object passed into the sub-rule */ + sub_rule_input?: Record | null; + /** Output mappings copied from child context to parent context */ + sub_rule_outputs?: Array<{ + parent_var: string; + child_var: string; + value?: any; + missing?: boolean; + }>; + /** Nested execution frames produced by a sub-rule invocation */ + sub_rule_frames?: StepTrace[]; } /** Engine validation result */ diff --git a/ordo-editor/packages/core/src/model/ruleset.ts b/ordo-editor/packages/core/src/model/ruleset.ts index a3dc9f3d..ea9aac46 100644 --- a/ordo-editor/packages/core/src/model/ruleset.ts +++ b/ordo-editor/packages/core/src/model/ruleset.ts @@ -279,6 +279,10 @@ export interface SubRuleGraph { entryStep: string; /** All steps in the sub-graph */ steps: Step[]; + /** Expected input fields for static binding validation */ + inputSchema?: SchemaField[]; + /** Variables expected to be exported by the sub-rule */ + outputSchema?: SchemaField[]; } export interface RuleSet { @@ -318,6 +322,7 @@ export const RuleSet = { timeout?: number; startStepId: string; steps: Step[]; + subRules?: Record; }): RuleSet { return { config: { @@ -332,6 +337,7 @@ export const RuleSet = { }, startStepId: options.startStepId, steps: options.steps, + subRules: options.subRules, metadata: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/ordo-editor/packages/core/src/model/step.ts b/ordo-editor/packages/core/src/model/step.ts index 0c674f8c..2d4198fc 100644 --- a/ordo-editor/packages/core/src/model/step.ts +++ b/ordo-editor/packages/core/src/model/step.ts @@ -24,6 +24,8 @@ export interface BaseStep { x: number; y: number; }; + /** Internal runtime marker; generated steps should not surface as authoring suggestions. */ + systemGenerated?: 'sub_rule_runtime' | 'concept_runtime'; } /** Branch definition for decision steps */ @@ -129,16 +131,26 @@ export interface SubRuleOutput { childVar: string; } -/** Sub-rule step - executes an inline sub-graph and returns control to the parent */ +/** Managed SubRule asset reference. Sub-rules are snapshotted inline when the parent is published. */ +export interface SubRuleAssetRef { + scope: 'project' | 'org'; + name: string; +} + +/** Sub-rule step - executes a managed SubRule asset and returns control to the parent */ export interface SubRuleStep extends BaseStep { type: 'sub_rule'; - /** Name of the sub-rule graph defined in the ruleset */ + /** Legacy/engine-compatible reference name. Prefer assetRef for Studio assets. */ refName: string; + /** Managed SubRule asset reference used by Studio and platform resolution. */ + assetRef?: SubRuleAssetRef; /** Input bindings: expressions from the parent context injected into the child context */ bindings?: SubRuleBinding[]; /** Output mappings: variables from the child context written back to the parent context */ outputs?: SubRuleOutput[]; - /** Next step ID after the sub-rule completes */ + /** Authoring-level return semantics. Execution lowering materializes runtime control flow. */ + returnPolicy?: 'continue' | 'propagate_terminal'; + /** Next step ID after the sub-rule completes. Empty means the child terminal result ends the parent flow. */ nextStepId: string; } @@ -176,6 +188,7 @@ export const Step = { branches?: Branch[]; defaultNextStepId: string; position?: { x: number; y: number }; + systemGenerated?: BaseStep['systemGenerated']; }): DecisionStep { return { id: options.id || generateStepId(), @@ -185,6 +198,7 @@ export const Step = { branches: options.branches || [], defaultNextStepId: options.defaultNextStepId, position: options.position, + systemGenerated: options.systemGenerated, }; }, @@ -198,6 +212,7 @@ export const Step = { logging?: ActionStep['logging']; nextStepId: string; position?: { x: number; y: number }; + systemGenerated?: BaseStep['systemGenerated']; }): ActionStep { return { id: options.id || generateStepId(), @@ -209,6 +224,7 @@ export const Step = { logging: options.logging, nextStepId: options.nextStepId, position: options.position, + systemGenerated: options.systemGenerated, }; }, @@ -221,6 +237,7 @@ export const Step = { message?: Expr; output?: OutputField[]; position?: { x: number; y: number }; + systemGenerated?: BaseStep['systemGenerated']; }): TerminalStep { return { id: options.id || generateStepId(), @@ -231,6 +248,7 @@ export const Step = { message: options.message, output: options.output, position: options.position, + systemGenerated: options.systemGenerated, }; }, @@ -255,10 +273,13 @@ export const Step = { name: string; description?: string; refName: string; + assetRef?: SubRuleAssetRef; bindings?: SubRuleBinding[]; outputs?: SubRuleOutput[]; + returnPolicy?: SubRuleStep['returnPolicy']; nextStepId: string; position?: { x: number; y: number }; + systemGenerated?: BaseStep['systemGenerated']; }): SubRuleStep { return { id: options.id || generateStepId(), @@ -266,10 +287,13 @@ export const Step = { description: options.description, type: 'sub_rule', refName: options.refName, + assetRef: options.assetRef, bindings: options.bindings, outputs: options.outputs, + returnPolicy: options.nextStepId ? options.returnPolicy ?? 'continue' : 'propagate_terminal', nextStepId: options.nextStepId, position: options.position, + systemGenerated: options.systemGenerated, }; }, @@ -319,6 +343,6 @@ export function getNextStepIds(step: Step): string[] { return []; case 'sub_rule': - return [step.nextStepId]; + return step.nextStepId ? [step.nextStepId] : []; } } diff --git a/ordo-editor/packages/core/src/validator/index.ts b/ordo-editor/packages/core/src/validator/index.ts index 4bdb0f6e..a1817c0c 100644 --- a/ordo-editor/packages/core/src/validator/index.ts +++ b/ordo-editor/packages/core/src/validator/index.ts @@ -6,6 +6,8 @@ import { RuleSet, Step, + SubRuleGraph, + SubRuleStep, getStepById, getAllStepIds, getNextStepIds, @@ -148,6 +150,8 @@ export function validateRuleSet( validateStep(step, i, ruleset, opts, addError, addWarning, addInfo); } + validateSubRules(ruleset, opts, addError, addWarning); + // Check broken references const brokenRefs = getBrokenReferences(ruleset); for (const { stepId, missingId } of brokenRefs) { @@ -247,6 +251,10 @@ function validateStep( validateTerminalStep(step, basePath, addError, addWarning); break; + case 'sub_rule': + validateSubRuleStep(step, basePath, _ruleset, addError); + break; + default: addError({ code: 'INVALID_STEP_TYPE', @@ -257,6 +265,156 @@ function validateStep( } } +/** Validate a sub-rule invocation step */ +function validateSubRuleStep( + step: SubRuleStep, + basePath: string, + ruleset: RuleSet, + addError: (e: Omit) => void +): void { + if (!step.refName) { + addError({ + code: 'MISSING_SUB_RULE_REF', + message: `Sub-rule step "${step.id}" has no referenced sub-rule`, + path: `${basePath}.refName`, + stepId: step.id, + }); + } else if (!ruleset.subRules?.[step.refName]) { + addError({ + code: 'INVALID_SUB_RULE_REF', + message: `Sub-rule step "${step.id}" references non-existent sub-rule "${step.refName}"`, + path: `${basePath}.refName`, + stepId: step.id, + }); + } + + const graph = step.refName ? ruleset.subRules?.[step.refName] : undefined; + if (!graph) return; + + const requiredInputs = (graph.inputSchema ?? []).filter((field) => field.required); + const boundFields = new Set((step.bindings ?? []).map((binding) => binding.field)); + for (const field of requiredInputs) { + if (!boundFields.has(field.name)) { + addError({ + code: 'MISSING_SUB_RULE_INPUT_BINDING', + message: `Sub-rule step "${step.id}" does not bind required input "${field.name}"`, + path: `${basePath}.bindings`, + stepId: step.id, + }); + } + } + + const requiredOutputs = (graph.outputSchema ?? []).filter((field) => field.required); + const mappedChildVars = new Set((step.outputs ?? []).map((output) => output.childVar)); + for (const field of requiredOutputs) { + if (!mappedChildVars.has(field.name)) { + addError({ + code: 'MISSING_SUB_RULE_OUTPUT_MAPPING', + message: `Sub-rule step "${step.id}" does not map required output "${field.name}"`, + path: `${basePath}.outputs`, + stepId: step.id, + }); + } + } + + (step.bindings ?? []).forEach((binding, index) => { + if (!binding.field) { + addError({ + code: 'MISSING_SUB_RULE_BINDING_FIELD', + message: `Binding ${index} of sub-rule step "${step.id}" has no child field`, + path: `${basePath}.bindings[${index}].field`, + stepId: step.id, + }); + } + }); + + (step.outputs ?? []).forEach((output, index) => { + if (!output.parentVar || !output.childVar) { + addError({ + code: 'INVALID_SUB_RULE_OUTPUT_MAPPING', + message: `Output mapping ${index} of sub-rule step "${step.id}" must define both variables`, + path: `${basePath}.outputs[${index}]`, + stepId: step.id, + }); + } + }); +} + +/** Validate embedded sub-rule graphs */ +function validateSubRules( + ruleset: RuleSet, + opts: ValidationOptions, + addError: (e: Omit) => void, + addWarning: (e: Omit) => void +): void { + if (!ruleset.subRules) return; + + for (const [name, graph] of Object.entries(ruleset.subRules)) { + validateSubRuleGraph(name, graph, ruleset, opts, addError, addWarning); + } + + const cycles = detectSubRuleCycles(ruleset); + for (const cycle of cycles) { + addError({ + code: 'SUB_RULE_CYCLE', + message: `Sub-rule call cycle detected: ${cycle.join(' -> ')}`, + path: 'subRules', + }); + } +} + +function validateSubRuleGraph( + name: string, + graph: SubRuleGraph, + ruleset: RuleSet, + opts: ValidationOptions, + addError: (e: Omit) => void, + addWarning: (e: Omit) => void +): void { + const stepIds = new Set(graph.steps.map((step) => step.id)); + + if (!graph.entryStep) { + addError({ + code: 'MISSING_SUB_RULE_ENTRY', + message: `Sub-rule "${name}" has no entry step`, + path: `subRules.${name}.entryStep`, + }); + } else if (!stepIds.has(graph.entryStep)) { + addError({ + code: 'INVALID_SUB_RULE_ENTRY', + message: `Sub-rule "${name}" entry step "${graph.entryStep}" does not exist`, + path: `subRules.${name}.entryStep`, + }); + } + + const duplicateIds = graph.steps + .map((step) => step.id) + .filter((id, index, ids) => ids.indexOf(id) !== index); + for (const id of new Set(duplicateIds)) { + addError({ + code: 'DUPLICATE_SUB_RULE_STEP_ID', + message: `Sub-rule "${name}" has duplicate step ID "${id}"`, + path: `subRules.${name}.steps`, + stepId: id, + }); + } + + graph.steps.forEach((step, index) => { + validateStep(step, index, ruleset, opts, addError, addWarning, () => undefined); + + for (const nextId of getNextStepIds(step)) { + if (nextId && !stepIds.has(nextId)) { + addError({ + code: 'BROKEN_SUB_RULE_REFERENCE', + message: `Sub-rule "${name}" step "${step.id}" references non-existent step "${nextId}"`, + path: `subRules.${name}.steps[${index}]`, + stepId: step.id, + }); + } + } + }); +} + /** Validate a decision step */ function validateDecisionStep( step: { id: string; branches: { id: string; nextStepId: string }[]; defaultNextStepId: string }, @@ -400,6 +558,53 @@ function detectCycles(ruleset: RuleSet): string[][] { return cycles; } +/** Detect cycles in the sub-rule call graph */ +function detectSubRuleCycles(ruleset: RuleSet): string[][] { + const cycles: string[][] = []; + const subRules = ruleset.subRules ?? {}; + const visited = new Set(); + const recursionStack = new Set(); + const path: string[] = []; + + function collectCalls(graph: SubRuleGraph): string[] { + return graph.steps + .filter((step): step is SubRuleStep => step.type === 'sub_rule') + .map((step) => step.refName) + .filter((refName) => !!subRules[refName]); + } + + function dfs(name: string): void { + if (recursionStack.has(name)) { + const cycleStart = path.indexOf(name); + if (cycleStart !== -1) { + cycles.push([...path.slice(cycleStart), name]); + } + return; + } + + if (visited.has(name)) return; + const graph = subRules[name]; + if (!graph) return; + + visited.add(name); + recursionStack.add(name); + path.push(name); + + for (const next of collectCalls(graph)) { + dfs(next); + } + + path.pop(); + recursionStack.delete(name); + } + + for (const name of Object.keys(subRules)) { + dfs(name); + } + + return cycles; +} + /** Quick validation check (returns true/false) */ export function isValidRuleSet(ruleset: RuleSet): boolean { return validateRuleSet(ruleset, { checkUnreachable: false, checkCircular: false }).valid; diff --git a/ordo-editor/packages/vue/src/components/base/OrdoExpressionInput.vue b/ordo-editor/packages/vue/src/components/base/OrdoExpressionInput.vue index dd095624..1e075a91 100644 --- a/ordo-editor/packages/vue/src/components/base/OrdoExpressionInput.vue +++ b/ordo-editor/packages/vue/src/components/base/OrdoExpressionInput.vue @@ -3,19 +3,23 @@ * OrdoExpressionInput - Expression input with syntax highlighting and autocomplete * 表达式输入组件,支持语法高亮和自动补全 */ -import { computed, ref, watch, onMounted, onUnmounted } from 'vue'; +import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'; export interface FieldSuggestion { /** Field path (e.g., "user.name") */ path: string; /** Alias for path — used by action/terminal editors */ value?: string; + /** Exact text inserted into the expression. Defaults to "$." + path. */ + insertText?: string; /** Display label */ label: string; /** Field type */ type?: string; /** Description */ description?: string; + /** Suggestion origin, used for badges and insertion semantics. */ + source?: 'fact' | 'concept' | 'schema' | 'variable'; } /** JIT analysis result for the expression */ @@ -73,11 +77,13 @@ const emit = defineEmits<{ }>(); // State +const wrapperRef = ref(null); const inputRef = ref(null); const showSuggestions = ref(false); const selectedSuggestionIndex = ref(0); const cursorPosition = ref(0); const validationError = ref(null); +const suggestionsStyle = ref>({}); // JIT compatibility state const internalJitAnalysis = ref(null); @@ -159,18 +165,25 @@ const currentWord = computed(() => { const filteredSuggestions = computed(() => { const word = currentWord.value.toLowerCase(); - if (!word || word.length < 1) return []; + if (!word || word.length < 1) return props.suggestions.slice(0, 12); // Include $ prefix suggestions - const prefix = word.startsWith('$') ? word.slice(1) : word; + const prefix = word.startsWith('$.') + ? word.slice(2) + : word.startsWith('$') + ? word.slice(1) + : word; return props.suggestions .filter((s) => { const searchPath = s.path.toLowerCase(); const searchLabel = s.label.toLowerCase(); - return searchPath.includes(prefix) || searchLabel.includes(prefix); + const insertText = getSuggestionInsertText(s).toLowerCase(); + return ( + searchPath.includes(prefix) || searchLabel.includes(prefix) || insertText.includes(prefix) + ); }) - .slice(0, 10); + .slice(0, 12); }); // Simple expression validation @@ -251,9 +264,13 @@ function handleInput(event: Event) { emit('update:modelValue', target.value); // Show suggestions if typing a variable - if (currentWord.value.startsWith('$') || currentWord.value.length > 0) { + if ( + props.suggestions.length > 0 && + (currentWord.value.startsWith('$') || currentWord.value.length > 0) + ) { showSuggestions.value = filteredSuggestions.value.length > 0; selectedSuggestionIndex.value = 0; + void nextTick(updateSuggestionPosition); } else { showSuggestions.value = false; } @@ -268,7 +285,13 @@ function handleBlur() { } function handleKeyDown(event: KeyboardEvent) { - if (!showSuggestions.value) return; + if (!showSuggestions.value) { + if ((event.ctrlKey || event.metaKey) && event.key === ' ') { + event.preventDefault(); + openSuggestions(); + } + return; + } switch (event.key) { case 'ArrowDown': @@ -297,6 +320,63 @@ function handleKeyDown(event: KeyboardEvent) { } } +function getSuggestionInsertText(suggestion: FieldSuggestion): string { + if (suggestion.insertText) return suggestion.insertText; + const candidate = suggestion.value ?? suggestion.path; + return candidate.startsWith('$') ? candidate : `$.${candidate}`; +} + +function getSuggestionDisplay(suggestion: FieldSuggestion): string { + return suggestion.label || suggestion.path; +} + +function getSuggestionTechnicalPath(suggestion: FieldSuggestion): string { + return getSuggestionInsertText(suggestion); +} + +function openSuggestions() { + if (props.disabled || props.suggestions.length === 0) return; + updateCursorPosition(); + showSuggestions.value = true; + selectedSuggestionIndex.value = 0; + inputRef.value?.focus(); + void nextTick(updateSuggestionPosition); +} + +function updateSuggestionPosition() { + const anchor = wrapperRef.value; + if (!anchor || !showSuggestions.value) return; + + const rect = anchor.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const width = Math.min(Math.max(rect.width, 220), viewportWidth - 16); + const left = Math.min(Math.max(8, rect.left), Math.max(8, viewportWidth - width - 8)); + const belowTop = rect.bottom + 4; + const spaceBelow = viewportHeight - belowTop - 12; + const spaceAbove = rect.top - 12; + const preferredMaxHeight = 280; + + let top = belowTop; + let maxHeight = Math.min(preferredMaxHeight, Math.max(160, spaceBelow)); + + if (spaceBelow < 140 && spaceAbove > spaceBelow) { + maxHeight = Math.min(preferredMaxHeight, Math.max(160, spaceAbove)); + top = Math.max(8, rect.top - maxHeight - 4); + } + + suggestionsStyle.value = { + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, + maxHeight: `${maxHeight}px`, + }; +} + +function handleViewportChange() { + updateSuggestionPosition(); +} + function selectSuggestion(suggestion: FieldSuggestion) { const text = props.modelValue; const pos = cursorPosition.value; @@ -310,7 +390,8 @@ function selectSuggestion(suggestion: FieldSuggestion) { // Replace current word with suggestion const before = text.slice(0, start); const after = text.slice(pos); - const newValue = `${before}$.${suggestion.path}${after}`; + const insertText = getSuggestionInsertText(suggestion); + const newValue = `${before}${insertText}${after}`; emit('update:modelValue', newValue); showSuggestions.value = false; @@ -319,7 +400,7 @@ function selectSuggestion(suggestion: FieldSuggestion) { setTimeout(() => { if (inputRef.value) { inputRef.value.focus(); - const newPos = start + suggestion.path.length + 2; // +2 for "$." + const newPos = start + insertText.length; inputRef.value.setSelectionRange(newPos, newPos); } }, 0); @@ -334,25 +415,36 @@ function updateCursorPosition() { // Keyboard shortcut: Ctrl+Space to show suggestions function handleGlobalKeyDown(event: KeyboardEvent) { - if (event.ctrlKey && event.key === ' ' && document.activeElement === inputRef.value) { + if ( + (event.ctrlKey || event.metaKey) && + event.key === ' ' && + document.activeElement === inputRef.value + ) { event.preventDefault(); - showSuggestions.value = props.suggestions.length > 0; + openSuggestions(); } } onMounted(() => { document.addEventListener('keydown', handleGlobalKeyDown); + window.addEventListener('resize', handleViewportChange); + window.addEventListener('scroll', handleViewportChange, true); }); onUnmounted(() => { document.removeEventListener('keydown', handleGlobalKeyDown); + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('scroll', handleViewportChange, true); }); + + diff --git a/ordo-editor/packages/vue/src/components/step/OrdoSubRuleEditor.vue b/ordo-editor/packages/vue/src/components/step/OrdoSubRuleEditor.vue new file mode 100644 index 00000000..7c13e271 --- /dev/null +++ b/ordo-editor/packages/vue/src/components/step/OrdoSubRuleEditor.vue @@ -0,0 +1,603 @@ + + +