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-platformgovernance · drafts · review · release"] + Server["ordo-server clusterHTTP · gRPC · UDS"] + Core["ordo-core engineVM + 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 @@ - - - - + collectExprDataFields(condition, fields); +} - - - - - - {{ t('menuBar.file') }} - - - (showCreate = true))" - > - {{ t('menuBar.newRuleset') }} - - projectStore.activeTab && handleSave(projectStore.activeTab.name) - ) - " - > - {{ t('menuBar.save') }} - Ctrl+S - - - {{ t('releaseCenter.createRequest') }} - - - +function collectStepDataFields(step: RulesetStep, fields: Set) { + if (step.type === 'decision') { + for (const branch of step.branches) collectConditionDataFields(branch.condition, fields); + return; + } - - - {{ t('menuBar.edit') }} - - - - {{ t('menuBar.undo') }} - Ctrl+Z - - - {{ t('menuBar.redo') }} - Ctrl+Shift+Z - - - + if (step.type === 'action') { + for (const assignment of step.assignments ?? []) { + collectExprDataFields(assignment.value, fields); + } + for (const call of step.externalCalls ?? []) { + for (const value of Object.values(call.params ?? {})) collectExprDataFields(value, fields); + if (call.fallbackValue) collectExprDataFields(call.fallbackValue, fields); + } + if (step.logging?.message) collectExprDataFields(step.logging.message, fields); + return; + } - - - {{ t('menuBar.select') }} - - - - {{ t('menuBar.selectionSoon') }} - - - + if (step.type === 'terminal') { + if (step.message) collectExprDataFields(step.message, fields); + for (const output of step.output ?? []) collectExprDataFields(output.value, fields); + return; + } - - (); + for (const step of steps) collectStepDataFields(step, fields); + + return [...fields] + .filter((field) => field && !field.startsWith('$') && !field.startsWith('item.')) + .sort() + .map((field) => ({ field, expr: variableExpr(field) })); +} + +function mergeSubRuleBindings( + existing: SubRuleBinding[] | undefined, + inferred: SubRuleBinding[] +): SubRuleBinding[] | undefined { + const byField = new Map(); + for (const binding of existing ?? []) byField.set(binding.field, binding); + for (const binding of inferred) { + if (!byField.has(binding.field)) byField.set(binding.field, binding); + } + return byField.size > 0 ? [...byField.values()] : undefined; +} + +function mergeSubRuleOutputs( + existing: SubRuleOutput[] | undefined, + generated: SubRuleOutput[] +): SubRuleOutput[] | undefined { + const byKey = new Map(); + for (const output of existing ?? []) byKey.set(`${output.parentVar}:${output.childVar}`, output); + for (const output of generated) byKey.set(`${output.parentVar}:${output.childVar}`, output); + return byKey.size > 0 ? [...byKey.values()] : undefined; +} + +function isRuntimeGeneratedStep(step: RulesetStep): boolean { + if (step.systemGenerated === 'sub_rule_runtime') return true; + return ( + step.id.includes('__return_dispatch') || + step.id.includes('__return_to_parent') || + step.id.includes('__terminal_') + ); +} + +function createTerminalReturnBridge( + subRuleStepId: string, + sourceSteps: RuleSet['steps'], + position: { x: number; y: number } +): TerminalReturnBridge | null { + const terminals = sourceSteps.filter((step): step is TerminalStep => step.type === 'terminal'); + if (terminals.length === 0) return null; + + const prefix = `__ordo_sub_${safeRuntimeName(subRuleStepId)}`; + const terminalIdVar = `${prefix}_terminal_id`; + const messageVar = `${prefix}_message`; + const returnStepId = `${subRuleStepId}__return_to_parent`; + const outputs: SubRuleOutput[] = [ + { parentVar: terminalIdVar, childVar: terminalIdVar }, + { parentVar: messageVar, childVar: messageVar }, + ]; + const outputVarByTerminal = new Map(); + + const childSteps = sourceSteps.map((step) => { + if (step.type !== 'terminal') return cloneStep(step); + + const terminal = step as TerminalStep; + const outputVars: string[] = []; + const assignments = [ + Step.assign(terminalIdVar, literalExpr(terminal.id)), + Step.assign( + messageVar, + terminal.message ? cloneExpression(terminal.message) : literalExpr('') + ), + ]; + + for (const [index, output] of (terminal.output ?? []).entries()) { + const outputVar = `${prefix}_${safeRuntimeName(terminal.id)}_${safeRuntimeName( + output.name + )}_${index}`; + outputVars.push(outputVar); + outputs.push({ parentVar: outputVar, childVar: outputVar }); + assignments.push(Step.assign(outputVar, output.value)); + } + outputVarByTerminal.set(terminal.id, outputVars); + + return Step.action({ + id: terminal.id, + name: terminal.name, + description: terminal.description, + assignments, + nextStepId: returnStepId, + position: terminal.position, + systemGenerated: 'sub_rule_runtime', + }); + }); + + childSteps.push( + Step.terminal({ + id: returnStepId, + name: t('subRules.returnParent'), + code: 'OK', + position: { x: position.x + 260, y: position.y }, + systemGenerated: 'sub_rule_runtime', + }) + ); + + const parentTerminals = terminals.map((terminal, index) => { + const outputVars = outputVarByTerminal.get(terminal.id) ?? []; + return Step.terminal({ + id: `${subRuleStepId}__terminal_${safeRuntimeName(terminal.id)}`, + name: terminal.name, + description: terminal.description, + code: terminal.code, + message: terminal.message ? cloneExpression(terminal.message) : undefined, + output: (terminal.output ?? []).map((output, outputIndex) => ({ + name: output.name, + value: variableExpr(`$${outputVars[outputIndex]}`), + })), + position: { + x: position.x + 260, + y: position.y + index * 120, + }, + systemGenerated: 'sub_rule_runtime', + }); + }); + + if (parentTerminals.length === 1) { + return { + childSteps, + parentSteps: parentTerminals, + nextStepId: parentTerminals[0].id, + bindings: inferSubRuleInputBindings(sourceSteps), + outputs, + }; + } + + const dispatcherId = `${subRuleStepId}__return_dispatch`; + const dispatcher = Step.decision({ + id: dispatcherId, + name: t('subRules.returnDispatcher'), + branches: terminals.slice(1).map((terminal, index) => + Step.branch({ + id: `${dispatcherId}_b_${index}`, + label: terminal.code, + condition: { + type: 'simple', + left: variableExpr(`$${terminalIdVar}`), + operator: 'eq', + right: literalExpr(terminal.id), + }, + nextStepId: `${subRuleStepId}__terminal_${safeRuntimeName(terminal.id)}`, + }) + ), + defaultNextStepId: parentTerminals[0].id, + position: { x: position.x + 220, y: position.y }, + systemGenerated: 'sub_rule_runtime', + }); + + return { + childSteps, + parentSteps: [dispatcher, ...parentTerminals], + nextStepId: dispatcherId, + bindings: inferSubRuleInputBindings(sourceSteps), + outputs, + }; +} + +function subRuleDraftCacheKey(scope: SubRuleExecutionRef['scope'], name: string) { + return `${scope}:${name}`; +} + +function collectStepSubRuleRefs(steps: RuleSet['steps']): SubRuleExecutionRef[] { + const refs = new Map(); + + for (const step of steps) { + if (step.type !== 'sub_rule') continue; + const subRuleStep = step as SubRuleStep; + const refName = subRuleStep.refName?.trim(); + if (!refName) continue; + + const assetName = subRuleStep.assetRef?.name?.trim() || refName; + const scope = subRuleStep.assetRef?.scope ?? 'project'; + const key = `${scope}:${assetName}:${refName}`; + refs.set(key, { refName, assetName, scope }); + } + + return [...refs.values()]; +} + +function getOpenSubRuleDraft(ref: SubRuleExecutionRef): RuleSet | null { + const byAssetName = projectStore.openTabs.find((tab) => tab.name === `§${ref.assetName}`); + if (byAssetName?.kind === 'sub_rule') return byAssetName.ruleset; + + const byRefName = projectStore.openTabs.find((tab) => tab.name === `§${ref.refName}`); + return byRefName?.kind === 'sub_rule' ? byRefName.ruleset : null; +} + +function getSubRuleDraftForExecution(ref: SubRuleExecutionRef): RuleSet | null { + const openDraft = getOpenSubRuleDraft(ref); + if (openDraft) return openDraft; + + if (ref.scope === 'org') { + return ( + subRuleDraftCache.value[subRuleDraftCacheKey('org', ref.assetName)] ?? + subRuleDraftCache.value[subRuleDraftCacheKey('org', ref.refName)] ?? + null + ); + } + + return ( + subRuleDraftCache.value[subRuleDraftCacheKey('project', ref.assetName)] ?? + subRuleDraftCache.value[subRuleDraftCacheKey('project', ref.refName)] ?? + subRuleDraftCache.value[subRuleDraftCacheKey('org', ref.assetName)] ?? + subRuleDraftCache.value[subRuleDraftCacheKey('org', ref.refName)] ?? + null + ); +} + +function subRuleGraphFromRuleset(ruleset: RuleSet, fallback?: SubRuleGraph): SubRuleGraph { + return { + entryStep: ruleset.startStepId, + steps: cloneRuleset(ruleset).steps, + inputSchema: ruleset.config.inputSchema ?? fallback?.inputSchema ?? [], + outputSchema: ruleset.config.outputSchema ?? fallback?.outputSchema ?? [], + }; +} + +function mergeExecutableSubRulesFromSteps( + steps: RuleSet['steps'], + subRules: Record, + depth: number, + stack: Set +) { + if (depth >= 8) return; + + for (const ref of collectStepSubRuleRefs(steps)) { + const stackKey = `${ref.scope}:${ref.assetName}:${ref.refName}`; + if (stack.has(stackKey)) continue; + + const draft = getSubRuleDraftForExecution(ref); + const existingGraph = subRules[ref.refName]; + if (draft) { + stack.add(stackKey); + const executableChild = buildExecutableRuleset(draft, depth + 1, stack); + Object.assign(subRules, executableChild.subRules ?? {}); + subRules[ref.refName] = subRuleGraphFromRuleset(executableChild, existingGraph); + stack.delete(stackKey); + continue; + } + + if (existingGraph) { + stack.add(stackKey); + mergeExecutableSubRulesFromSteps(existingGraph.steps, subRules, depth + 1, stack); + stack.delete(stackKey); + } + } +} + +function buildExecutableRuleset(ruleset: RuleSet, depth = 0, stack = new Set()): RuleSet { + const executable = stripRuntimeGeneratedArtifacts(ruleset); + const subRules: Record = { ...(executable.subRules ?? {}) }; + + mergeExecutableSubRulesFromSteps(executable.steps, subRules, depth, stack); + repairTerminalReturningSubRuleSteps(executable, subRules); + + return { + ...executable, + ...(Object.keys(subRules).length > 0 ? { subRules } : {}), + }; +} + +function repairTerminalReturningSubRuleSteps( + ruleset: RuleSet, + subRules: Record +) { + const stepIds = new Set(ruleset.steps.map((step) => step.id)); + const appendedSteps: RuleSet['steps'] = []; + + ruleset.steps = ruleset.steps.map((step) => { + if (step.type !== 'sub_rule') return step; + + const subRuleStep = step as SubRuleStep; + const shouldPropagateTerminal = + subRuleStep.returnPolicy === 'propagate_terminal' || + !subRuleStep.nextStepId || + !stepIds.has(subRuleStep.nextStepId); + if (!shouldPropagateTerminal) return subRuleStep; + + const graph = subRules[subRuleStep.refName]; + if (!graph?.steps.some((childStep) => childStep.type === 'terminal')) return subRuleStep; + + const bridge = createTerminalReturnBridge(subRuleStep.id, graph.steps, { + x: subRuleStep.position?.x ?? 0, + y: subRuleStep.position?.y ?? 0, + }); + if (!bridge) return subRuleStep; + + const bridgedRefName = `${subRuleStep.refName}__${safeRuntimeName( + subRuleStep.id + )}_terminal_return`; + subRules[bridgedRefName] = { + ...graph, + steps: bridge.childSteps, + }; + appendedSteps.push(...bridge.parentSteps); + for (const parentStep of bridge.parentSteps) stepIds.add(parentStep.id); + + return { + ...subRuleStep, + refName: bridgedRefName, + returnPolicy: 'continue', + nextStepId: bridge.nextStepId, + bindings: mergeSubRuleBindings(subRuleStep.bindings, bridge.bindings), + outputs: mergeSubRuleOutputs(subRuleStep.outputs, bridge.outputs), + }; + }); + + if (appendedSteps.length > 0) { + ruleset.steps.push(...appendedSteps); + } +} + +function collectMissingExecutionSubRules( + ruleset: RuleSet, + missing = new Map(), + visited = new Set(), + depth = 0 +) { + if (depth >= 8) return missing; + + const inlineSubRules = ruleset.subRules ?? {}; + for (const ref of collectStepSubRuleRefs(ruleset.steps)) { + const key = `${ref.scope}:${ref.assetName}:${ref.refName}`; + if (visited.has(key)) continue; + visited.add(key); + + const draft = getSubRuleDraftForExecution(ref); + const inlineGraph = inlineSubRules[ref.refName]; + + if (draft) { + collectMissingExecutionSubRules(draft, missing, visited, depth + 1); + continue; + } + + if (inlineGraph) { + collectMissingExecutionSubRules( + { + config: ruleset.config, + startStepId: inlineGraph.entryStep, + steps: inlineGraph.steps, + subRules: inlineSubRules, + }, + missing, + visited, + depth + 1 + ); + continue; + } + + missing.set(key, ref); + } + + return missing; +} + +const activeExecutableRulesetResult = computed<{ + ruleset: RuleSet | null; + error: string | null; +}>(() => { + const tab = projectStore.activeTab; + if (!tab) return { ruleset: null, error: null }; + + try { + return { + ruleset: materializeConceptsForExecution( + buildExecutableRuleset(tab.ruleset), + catalogStore.concepts + ), + error: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ruleset: null, + error: t('editor.conceptMaterializationError', { message }), + }; + } +}); + +const activeExecutableRuleset = computed(() => activeExecutableRulesetResult.value.ruleset); +const conceptExecutionError = computed(() => activeExecutableRulesetResult.value.error); +const executionPanelRuleset = computed(() => { + return ( + activeExecutableRuleset.value ?? + projectStore.activeTab?.ruleset ?? + documentToRuleSet(createEmptyFlowDocument()) + ); +}); + +async function fetchSubRuleDraftForExecution(ref: SubRuleExecutionRef) { + if (!auth.token || !orgId.value || !projectId.value) return; + if (getSubRuleDraftForExecution(ref)) return; + + let asset; + if (ref.scope === 'org') { + asset = await subRuleApi.getOrg(auth.token, orgId.value, ref.assetName); + } else { + try { + asset = await subRuleApi.getProject(auth.token, orgId.value, projectId.value, ref.assetName); + } catch (error: any) { + if (error?.status !== 404) throw error; + asset = await subRuleApi.getOrg(auth.token, orgId.value, ref.assetName); + } + } + + const draft = normalizeRuleset(asset.draft, asset.name); + subRuleDraftCache.value = { + ...subRuleDraftCache.value, + [subRuleDraftCacheKey(asset.scope, asset.name)]: draft, + [subRuleDraftCacheKey(ref.scope, ref.assetName)]: draft, + }; +} + +async function hydrateActiveExecutionSubRules() { + const tab = projectStore.activeTab; + if (!tab || !auth.token) return; + + const seq = ++subRuleHydrationSeq.value; + subRuleHydrationLoading.value = true; + subRuleHydrationError.value = null; + + try { + for (let depth = 0; depth < 8; depth += 1) { + const missing = [...collectMissingExecutionSubRules(tab.ruleset).values()]; + if (missing.length === 0) break; + + await Promise.all(missing.map((ref) => fetchSubRuleDraftForExecution(ref))); + if (seq !== subRuleHydrationSeq.value) return; + } + + const unresolved = [...collectMissingExecutionSubRules(tab.ruleset).values()]; + if (unresolved.length > 0) { + subRuleHydrationError.value = `Missing SubRules: ${unresolved + .map((ref) => ref.refName) + .join(', ')}`; + } + } catch (error: any) { + subRuleHydrationError.value = error?.message ?? t('subRules.loadFailed'); + } finally { + if (seq === subRuleHydrationSeq.value) { + subRuleHydrationLoading.value = false; + } + } +} + +watch( + () => [ + showExecution.value, + projectStore.activeTabName, + projectStore.activeTab ? serializeRuleset(projectStore.activeTab.ruleset) : '', + ], + ([visible]) => { + if (visible) void hydrateActiveExecutionSubRules(); + } +); + +function getStepOutgoingIds(step: RulesetStep): string[] { + switch (step.type) { + case 'decision': + return [...step.branches.map((branch) => branch.nextStepId), step.defaultNextStepId].filter( + Boolean + ); + case 'action': + case 'sub_rule': + return step.nextStepId ? [step.nextStepId] : []; + case 'terminal': + default: + return []; + } +} + +function buildRulesetGraph(ruleset: RuleSet) { + const stepMap = new Map(ruleset.steps.map((step) => [step.id, step])); + const edges: RulesetGraphEdge[] = []; + const incoming = new Map(); + const outgoing = new Map(); + + for (const step of ruleset.steps) { + incoming.set(step.id, []); + outgoing.set(step.id, []); + } + + for (const step of ruleset.steps) { + for (const target of getStepOutgoingIds(step)) { + if (!stepMap.has(target)) continue; + const edge = { source: step.id, target }; + edges.push(edge); + outgoing.get(step.id)?.push(edge); + incoming.get(target)?.push(edge); + } + } + + return { stepMap, edges, incoming, outgoing }; +} + +function validateSubRuleCandidate( + ruleset: RuleSet, + stepIds: string[] +): SubRuleCandidateValidation | null { + const selectedStepIds = new Set(stepIds); + if (selectedStepIds.size < 2) return null; + + const graph = buildRulesetGraph(ruleset); + const selectedSteps = ruleset.steps.filter((step) => selectedStepIds.has(step.id)); + if (selectedSteps.length !== selectedStepIds.size) return null; + if (selectedSteps.some((step) => step.type === 'sub_rule' || isRuntimeGeneratedStep(step))) { + return null; + } + + const internalEdges = graph.edges.filter( + (edge) => selectedStepIds.has(edge.source) && selectedStepIds.has(edge.target) + ); + const externalIncomingEdges = graph.edges.filter( + (edge) => !selectedStepIds.has(edge.source) && selectedStepIds.has(edge.target) + ); + const externalOutgoingEdges = graph.edges.filter( + (edge) => selectedStepIds.has(edge.source) && !selectedStepIds.has(edge.target) + ); + + const incomingTargets = new Set(externalIncomingEdges.map((edge) => edge.target)); + if (incomingTargets.size > 1) return null; + + const internalIncomingTargets = new Set(internalEdges.map((edge) => edge.target)); + let entryId: string | undefined; + if (incomingTargets.size === 1) { + entryId = [...incomingTargets][0]; + } else if (selectedStepIds.has(ruleset.startStepId)) { + entryId = ruleset.startStepId; + } else { + const rootSteps = selectedSteps.filter((step) => !internalIncomingTargets.has(step.id)); + if (rootSteps.length !== 1) return null; + entryId = rootSteps[0].id; + } + + const reachable = new Set(); + const stack = [entryId]; + while (stack.length > 0) { + const current = stack.pop()!; + if (reachable.has(current)) continue; + reachable.add(current); + for (const edge of internalEdges) { + if (edge.source === current && !reachable.has(edge.target)) { + stack.push(edge.target); + } + } + } + if (reachable.size !== selectedSteps.length) return null; + + const exitTargets = new Set(externalOutgoingEdges.map((edge) => edge.target)); + if (exitTargets.size > 1) return null; + + const hasTerminal = selectedSteps.some((step) => step.type === 'terminal'); + if (hasTerminal && externalOutgoingEdges.length > 0) return null; + if (!hasTerminal && externalOutgoingEdges.length === 0) return null; + + return { + entryId, + exitTargetId: exitTargets.size === 1 ? [...exitTargets][0] : undefined, + }; +} + +function collectDownstreamRegion(ruleset: RuleSet, startStepId: string, limit = 10): string[] { + const graph = buildRulesetGraph(ruleset); + const selected = new Set([startStepId]); + const queue = [...(graph.outgoing.get(startStepId) ?? []).map((edge) => edge.target)]; + + while (queue.length > 0 && selected.size < limit) { + const current = queue.shift()!; + if (selected.has(current) || !graph.stepMap.has(current)) continue; + + const hasOutsideIncoming = (graph.incoming.get(current) ?? []).some( + (edge) => !selected.has(edge.source) + ); + if (hasOutsideIncoming) continue; + + selected.add(current); + const step = graph.stepMap.get(current); + if (!step || step.type === 'terminal') continue; + + for (const edge of graph.outgoing.get(current) ?? []) { + if (!selected.has(edge.target)) queue.push(edge.target); + } + } + + return ruleset.steps.map((step) => step.id).filter((id) => selected.has(id)); +} + +function collectLinearRegion(ruleset: RuleSet, startStepId: string, limit = 8): string[] { + const graph = buildRulesetGraph(ruleset); + const selected = new Set([startStepId]); + let current = startStepId; + + while (selected.size < limit) { + const outgoingEdges = graph.outgoing.get(current) ?? []; + if (outgoingEdges.length !== 1) break; + + const next = outgoingEdges[0].target; + if (selected.has(next) || !graph.stepMap.has(next)) break; + + const incomingEdges = graph.incoming.get(next) ?? []; + if (incomingEdges.length !== 1) break; + + selected.add(next); + const nextStep = graph.stepMap.get(next); + if (!nextStep || nextStep.type === 'terminal') break; + current = next; + } + + return ruleset.steps.map((step) => step.id).filter((id) => selected.has(id)); +} + +function analyzeSubRuleSuggestions(ruleset: RuleSet): SubRuleSuggestion[] { + const graph = buildRulesetGraph(ruleset); + const suggestions: SubRuleSuggestion[] = []; + const seen = new Set(); + + function pushSuggestion( + kind: SubRuleSuggestion['kind'], + title: string, + description: string, + stepIds: string[], + score: number + ) { + const candidateIds = ruleset.steps + .map((step) => step.id) + .filter((id) => stepIds.includes(id) && graph.stepMap.has(id)); + const validation = validateSubRuleCandidate(ruleset, candidateIds); + if (!validation) return; + + const key = candidateIds.slice().sort().join('|'); + if (seen.has(key)) return; + seen.add(key); + + const entryStep = graph.stepMap.get(validation.entryId); + suggestions.push({ + id: `${kind}:${key}`, + kind, + title, + description, + stepIds: candidateIds, + entryStepId: validation.entryId, + entryName: entryStep?.name ?? validation.entryId, + stepCount: candidateIds.length, + score: score + candidateIds.length, + }); + } + + for (const group of ruleset.groups ?? []) { + if (group.stepIds.length < 2) continue; + pushSuggestion( + 'group', + t('subRules.suggestionGroupTitle', { name: group.name }), + group.description || t('subRules.suggestionGroupDesc'), + group.stepIds, + 90 + ); + } + + for (const step of ruleset.steps) { + if (isRuntimeGeneratedStep(step)) continue; + if (step.type === 'decision' && step.branches.length >= 2) { + const region = collectDownstreamRegion(ruleset, step.id); + if (region.length >= 3) { + pushSuggestion( + 'decision', + t('subRules.suggestionDecisionTitle', { name: step.name }), + t('subRules.suggestionDecisionDesc', { count: region.length }), + region, + 75 + ); + } + } + } + + for (const step of ruleset.steps) { + if (step.type === 'terminal' || step.type === 'sub_rule' || isRuntimeGeneratedStep(step)) + continue; + + const incomingEdges = graph.incoming.get(step.id) ?? []; + if (incomingEdges.length === 1) { + const previousOutgoing = graph.outgoing.get(incomingEdges[0].source) ?? []; + if (previousOutgoing.length === 1) continue; + } + + const region = collectLinearRegion(ruleset, step.id); + if (region.length >= 3) { + pushSuggestion( + 'chain', + t('subRules.suggestionChainTitle', { name: step.name }), + t('subRules.suggestionChainDesc', { count: region.length }), + region, + 55 + ); + } + } + + return suggestions.sort((a, b) => b.score - a.score); +} + +function cloneStep(step: RulesetStep): RulesetStep { + return JSON.parse(JSON.stringify(step)); +} + +function retargetStepNext(step: RulesetStep, fromIds: Set, targetId: string): RulesetStep { + const cloned = cloneStep(step); + + switch (cloned.type) { + case 'decision': + return { + ...cloned, + branches: cloned.branches.map((branch) => ({ + ...branch, + nextStepId: fromIds.has(branch.nextStepId) ? targetId : branch.nextStepId, + })), + defaultNextStepId: fromIds.has(cloned.defaultNextStepId) + ? targetId + : cloned.defaultNextStepId, + } as RulesetStep; + case 'action': + case 'sub_rule': + return { + ...cloned, + nextStepId: + cloned.nextStepId && fromIds.has(cloned.nextStepId) ? targetId : cloned.nextStepId, + } as RulesetStep; + default: + return cloned; + } +} + +function createSuggestedSubRuleExtractionPayload( + ruleset: RuleSet, + suggestion: SubRuleSuggestion +): ExtractSubRulePayload | null { + const validation = validateSubRuleCandidate(ruleset, suggestion.stepIds); + if (!validation) return null; + + const selectedIds = new Set(suggestion.stepIds); + const selectedSteps = ruleset.steps.filter((step) => selectedIds.has(step.id)); + if (selectedSteps.length !== selectedIds.size) return null; + + const entryStep = selectedSteps.find((step) => step.id === validation.entryId); + if (!entryStep) return null; + + const suggestedName = sanitizeAssetName( + `${ruleset.config.name}_${entryStep.name || entryStep.id}` + ); + const displayName = entryStep.name || t('step.subRule'); + const subRuleStepId = generateId('step'); + const exitTargetId = validation.exitTargetId; + const returnStepId = exitTargetId ? generateId('return') : undefined; + const outsideTargets = exitTargetId && returnStepId ? new Set([exitTargetId]) : new Set(); + const minX = Math.min(...selectedSteps.map((step) => step.position?.x ?? 0)); + const minY = Math.min(...selectedSteps.map((step) => step.position?.y ?? 0)); + + const childSteps = selectedSteps.map((step) => + returnStepId ? retargetStepNext(step, outsideTargets, returnStepId) : cloneStep(step) + ); + + if (returnStepId) { + childSteps.push( + Step.terminal({ + id: returnStepId, + name: t('subRules.returnParent'), + code: 'OK', + position: { + x: Math.max(...selectedSteps.map((step) => step.position?.x ?? 0)) + 240, + y: + selectedSteps.reduce((sum, step) => sum + (step.position?.y ?? 0), 0) / + Math.max(selectedSteps.length, 1), + }, + systemGenerated: 'sub_rule_runtime', + }) + ); + } + + const draft: RuleSet = { + config: { + ...ruleset.config, + name: suggestedName, + version: '0.1.0', + description: t('subRules.extractedDescription', { count: selectedSteps.length }), + metadata: { + ...(ruleset.config.metadata ?? {}), + extractedFrom: ruleset.config.name, + extractedAt: new Date().toISOString(), + }, + }, + startStepId: validation.entryId, + steps: childSteps, + ...(ruleset.subRules ? { subRules: cloneRuleset(ruleset).subRules } : {}), + groups: ruleset.groups + ?.map((group) => ({ + ...group, + stepIds: group.stepIds.filter((stepId) => selectedIds.has(stepId)), + })) + .filter((group) => group.stepIds.length > 0), + }; + + const subRuleStep = Step.subRule({ + id: subRuleStepId, + name: displayName, + refName: suggestedName, + assetRef: { + scope: 'project', + name: suggestedName, + }, + bindings: mergeSubRuleBindings(undefined, inferSubRuleInputBindings(selectedSteps)), + returnPolicy: exitTargetId ? 'continue' : 'propagate_terminal', + nextStepId: exitTargetId ?? '', + position: { x: minX, y: minY }, + }); + + const firstSelectedIndex = ruleset.steps.findIndex((step) => selectedIds.has(step.id)); + const parentSteps = ruleset.steps + .filter((step) => !selectedIds.has(step.id)) + .map((step) => retargetStepNext(step, selectedIds, subRuleStepId)); + parentSteps.splice(Math.max(firstSelectedIndex, 0), 0, subRuleStep); + + const parentRuleset: RuleSet = { + ...cloneRuleset(ruleset), + startStepId: selectedIds.has(ruleset.startStepId) ? subRuleStepId : ruleset.startStepId, + steps: parentSteps, + groups: ruleset.groups?.map((group) => { + const nextStepIds = group.stepIds.filter((stepId) => !selectedIds.has(stepId)); + if (group.stepIds.some((stepId) => selectedIds.has(stepId))) { + nextStepIds.push(subRuleStepId); + } + return { + ...group, + stepIds: Array.from(new Set(nextStepIds)), + }; + }), + }; + + return { + suggestedName, + displayName, + subRuleStepId, + selectedStepCount: selectedSteps.length, + draft, + parentRuleset, + }; +} + +function requestSuggestedSubRuleExtraction(suggestion: SubRuleSuggestion) { + if (!canEdit.value || activeSubRuleName.value) return; + + pendingSubRuleSuggestionId.value = suggestion.id; + const tab = projectStore.activeTab; + if (!tab) return; + + const payload = createSuggestedSubRuleExtractionPayload(tab.ruleset, suggestion); + if (!payload) { + pendingSubRuleSuggestionId.value = null; + MessagePlugin.warning(t('subRules.suggestionsDesc')); + return; + } + + setEditorMode('flow'); + handleExtractSubRule(payload); +} + +function handleExtractSubRuleInvalid(reason: string) { + pendingSubRuleSuggestionId.value = null; + MessagePlugin.warning(reason); +} + +// ── Table support ────────────────────────────────────────────────────────────── +const decisionTables = ref>({}); + +const activeDecisionTable = computed(() => { + const tab = projectStore.activeTab; + if (!tab) return null; + return decisionTables.value[tab.name] ?? null; +}); + +function handleTableChange(table: DecisionTable) { + const tab = projectStore.activeTab; + if (!tab) return; + decisionTables.value[tab.name] = table; + + const result = compileTableToSteps(table); + + const nextRuleset: RuleSet = { + ...tab.ruleset, + steps: result.steps, + startStepId: result.startStepId, + config: { + ...tab.ruleset.config, + metadata: { + ...(tab.ruleset.config.metadata ?? {}), + _table: JSON.stringify(table), + }, + }, + }; + + updateRulesetState(tab.name, nextRuleset); + scheduleEditHistoryEntry(tab.name, nextRuleset, t('historyPanel.actionEditTable')); +} + +// ── Lifecycle ────────────────────────────────────────────────────────────────── +onMounted(async () => { + if (!projectStore.currentProject || projectStore.currentProject.id !== projectId.value) { + const project = projectStore.projects.find((p) => p.id === projectId.value); + if (project) { + await projectStore.selectProject(project); + } + } + await projectStore.fetchRulesets(); + await rbacStore.fetchRoles(orgId.value); + await rbacStore.fetchMyRoles(orgId.value); + await environmentStore.fetchEnvironments(orgId.value, projectId.value); + await refreshSubRuleAssets(); + + // Open ruleset or sub-rule from URL param + if (rulesetNameParam.value) { + await openTabFromParam(rulesetNameParam.value); + } else if (projectStore.rulesets.length > 0 && projectStore.openTabs.length === 0) { + await openRuleset(projectStore.rulesets[0].name); + } +}); + +async function openTabFromParam(name: string) { + if (name.startsWith('§')) { + const refName = name.slice(1); + try { + await projectStore.openSubRule(refName, 'project'); + tabModes.set(name, 'flow'); + } catch { + await openRuleset(projectStore.rulesets[0]?.name ?? ''); + } + } else { + await openRuleset(name); + } +} + +watch( + () => rulesetNameParam.value, + async (name) => { + if (name) await openTabFromParam(name); + } +); + +function onKeydown(e: KeyboardEvent) { + const key = e.key.toLowerCase(); + const isPrimary = e.ctrlKey || e.metaKey; + + if (!isPrimary) return; + + if (key === 's') { + e.preventDefault(); + if (projectStore.activeTab) handleSave(projectStore.activeTab.name); + return; + } + + if (key === 'z') { + e.preventDefault(); + if (e.shiftKey) { + redoHistory(); + } else { + undoHistory(); + } + return; + } + + if (key === 'y') { + e.preventDefault(); + redoHistory(); + } +} + +function closeMenus() { + openMenu.value = null; +} + +function toggleMenu(menu: 'file' | 'edit' | 'select' | 'view' | 'window') { + openMenu.value = openMenu.value === menu ? null : menu; +} + +function hoverMenu(menu: 'file' | 'edit' | 'select' | 'view' | 'window') { + if (openMenu.value) { + openMenu.value = menu; + } +} + +function onDocumentPointerDown(event: MouseEvent) { + const target = event.target as HTMLElement | null; + if (!target?.closest('.editor-menubar')) { + closeMenus(); + } + if (!target?.closest('.knowledge-dock') && !target?.closest('.knowledge-dock__panel')) { + showKnowledgeAdvisorPanel.value = false; + } +} + +function toggleKnowledgeAdvisorPanel() { + showKnowledgeAdvisorPanel.value = !showKnowledgeAdvisorPanel.value; +} + +function runMenuAction(action: () => void) { + closeMenus(); + action(); +} + +onMounted(() => document.addEventListener('keydown', onKeydown)); +onUnmounted(() => document.removeEventListener('keydown', onKeydown)); +onMounted(() => document.addEventListener('mousedown', onDocumentPointerDown)); +onUnmounted(() => document.removeEventListener('mousedown', onDocumentPointerDown)); + +// ── Actions ─────────────────────────────────────────────────────────────────── +async function openRuleset(name: string) { + try { + await projectStore.openRuleset(name); + const tab = projectStore.openTabs.find((t) => t.name === name); + if (tab) { + syncDecisionTableFromRuleset(name, tab.ruleset); + editorMode.value = canBeTable(tab.ruleset) ? 'table' : 'form'; + await ensureHistoryLoaded(name, tab.ruleset); + } + tabModes.set(name, editorMode.value); + router.replace(`${projectBase.value}/editor/${encodeURIComponent(name)}`); + } catch (e: any) { + MessagePlugin.error(e.message || t('editor.loadFailed')); + } +} + +function canBeTable(rs: RuleSet): boolean { + try { + return !!decompileStepsToTable(rs.steps, rs.startStepId); + } catch { + return false; + } +} + +function handleRulesetChange(ruleset: RuleSet) { + applyRulesetChange(ruleset); +} + +function stripDecisionTableMetadata(ruleset: RuleSet): RuleSet { + if (!ruleset.config.metadata?._table) return ruleset; + + const metadata = { ...ruleset.config.metadata }; + delete metadata._table; + return { + ...ruleset, + config: { + ...ruleset.config, + metadata, + }, + }; +} + +function applyRulesetChange(ruleset: RuleSet, actionOverride?: string) { + const tab = projectStore.activeTab; + if (!tab) return; + + const incomingRuleset = + editorMode.value === 'flow' ? stripDecisionTableMetadata(ruleset) : ruleset; + const { ruleset: normalizedRuleset, refsToCreate } = normalizeSubRuleReferences( + tab.name, + incomingRuleset + ); + const action = actionOverride ?? buildHistoryAction(tab.ruleset, normalizedRuleset); + updateRulesetState(tab.name, normalizedRuleset); + scheduleEditHistoryEntry(tab.name, normalizedRuleset, action); + + for (const refName of refsToCreate) { + void ensureProjectSubRuleAsset(refName); + } +} + +function makeUniqueProjectSubRuleName(baseName: string) { + const base = sanitizeAssetName(baseName); + const names = new Set( + subRuleAssets.value.filter((asset) => asset.scope === 'project').map((asset) => asset.name) + ); + if (!names.has(base)) return base; + + let suffix = 2; + while (names.has(`${base}_${suffix}`)) { + suffix += 1; + } + return `${base}_${suffix}`; +} + +function hasAnySubRuleAsset(name: string) { + return subRuleAssets.value.some((asset) => asset.name === name); +} + +function handleExtractSubRule(payload: ExtractSubRulePayload) { + const tab = projectStore.activeTab; + if (!tab || tab.kind === 'sub_rule') return; + + pendingSubRuleSuggestionId.value = null; + const name = makeUniqueProjectSubRuleName(payload.suggestedName); + extractSubRuleState.value = { + parentTabName: tab.name, + payload, + name, + displayName: payload.displayName, + description: t('subRules.extractedDescription', { count: payload.selectedStepCount }), + }; +} + +function retargetExtractedSubRuleStep( + ruleset: RuleSet, + subRuleStepId: string, + name: string, + displayName: string +): RuleSet { + return { + ...ruleset, + steps: ruleset.steps.map((step) => { + if (step.id !== subRuleStepId || step.type !== 'sub_rule') return step; + const subRuleStep = step as SubRuleStep; + return { + ...subRuleStep, + name: displayName || name, + refName: name, + assetRef: { + scope: 'project' as const, + name, + }, + }; + }), + }; +} + +function inlineExtractedSubRule( + parentRuleset: RuleSet, + name: string, + draft: RuleSet, + previousName?: string +): RuleSet { + const subRules = { ...(parentRuleset.subRules ?? {}) }; + if (previousName && previousName !== name) { + delete subRules[previousName]; + } + + subRules[name] = { + entryStep: draft.startStepId, + steps: cloneRuleset(draft).steps, + inputSchema: draft.config.inputSchema ?? [], + outputSchema: draft.config.outputSchema ?? [], + }; + + return { + ...parentRuleset, + subRules, + }; +} + +async function confirmExtractSubRule() { + const state = extractSubRuleState.value; + if (!state || !auth.token) return; + + const name = sanitizeAssetName(state.name); + if (!name) { + MessagePlugin.warning(t('subRules.nameRequired')); + return; + } + if (!subRuleAssetsLoaded.value) { + await refreshSubRuleAssets(); + } + if (hasAnySubRuleAsset(name)) { + MessagePlugin.warning(t('subRules.nameExists', { name })); + return; + } + + extractingSubRule.value = true; + try { + const displayName = state.displayName.trim() || name; + const description = state.description.trim(); + const draft: RuleSet = { + ...cloneRuleset(state.payload.draft), + config: { + ...state.payload.draft.config, + name, + description, + }, + }; + + await subRuleApi.saveProject(auth.token, orgId.value, projectId.value, name, { + name, + display_name: displayName, + description, + draft, + input_schema: [], + output_schema: [], + expected_seq: 0, + }); + await refreshSubRuleAssets(); + + if (projectStore.activeTabName !== state.parentTabName) { + switchToTab(state.parentTabName); + } + + const parentRuleset = inlineExtractedSubRule( + retargetExtractedSubRuleStep( + cloneRuleset(state.payload.parentRuleset), + state.payload.subRuleStepId, + name, + displayName + ), + name, + draft, + state.payload.suggestedName + ); + applyRulesetChange(parentRuleset, t('historyPanel.actionExtractSubRule', { name })); + + await projectStore.openSubRule(name, 'project'); + const tabName = `§${name}`; + subRuleParentTabs.set(tabName, state.parentTabName); + tabModes.set(tabName, 'flow'); + router.replace(`${projectBase.value}/editor/${encodeURIComponent(tabName)}`); + + MessagePlugin.success(t('subRules.extractSuccess', { name })); + extractSubRuleState.value = null; + } catch (e: any) { + MessagePlugin.error(e.message || t('subRules.saveFailed')); + } finally { + extractingSubRule.value = false; + } +} + +function normalizeSubRuleReferences(parentRulesetName: string, ruleset: RuleSet) { + let changed = false; + const refsToCreate: string[] = []; + + const steps = ruleset.steps.map((step) => { + if (step.type !== 'sub_rule') return step; + + const subRuleStep = step as SubRuleStep; + const generatedName = + subRuleStep.refName.trim() || `${sanitizeAssetName(parentRulesetName)}_${subRuleStep.id}`; + const assetRef: NonNullable = { + ...(subRuleStep.assetRef ?? { scope: 'project' as const }), + scope: subRuleStep.assetRef?.scope ?? ('project' as const), + name: subRuleStep.assetRef?.name?.trim() || generatedName, + }; + + const needsPatch = + !subRuleStep.refName.trim() || + !subRuleStep.assetRef || + !subRuleStep.assetRef.name?.trim() || + subRuleStep.assetRef.name !== assetRef.name; + + if (needsPatch) { + changed = true; + refsToCreate.push(generatedName); + return { + ...subRuleStep, + refName: generatedName, + assetRef, + }; + } + + return subRuleStep; + }); + + return { + ruleset: changed ? { ...ruleset, steps } : ruleset, + refsToCreate, + }; +} + +function sanitizeAssetName(name: string) { + return ( + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') || 'sub_rule' + ); +} + +function createDefaultSubRuleDraft(name: string): RuleSet { + const terminal = Step.terminal({ + id: 'return_result', + name: t('subRules.defaultTerminalName'), + code: 'OK', + message: { + type: 'literal', + value: '', + valueType: 'string', + }, + output: [], + position: { x: 160, y: 120 }, + }); + + return { + config: { + name, + version: '0.1.0', + description: t('subRules.defaultDescription'), + enableTrace: true, + }, + startStepId: terminal.id, + steps: [terminal], + groups: [], + metadata: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; +} + +async function refreshSubRuleAssets() { + if (!auth.token || !orgId.value || !projectId.value) return; + subRuleAssetsLoading.value = true; + try { + subRuleAssets.value = await subRuleApi.listProject( + auth.token, + orgId.value, + projectId.value, + true + ); + subRuleAssetsLoaded.value = true; + } catch (e: any) { + subRuleAssetsLoaded.value = false; + MessagePlugin.warning(e.message || t('subRules.loadFailed')); + } finally { + subRuleAssetsLoading.value = false; + } +} + +function hasProjectSubRuleAsset(name: string) { + return subRuleAssets.value.some((asset) => asset.scope === 'project' && asset.name === name); +} + +async function ensureProjectSubRuleAsset(name: string) { + if (!auth.token || !orgId.value || !projectId.value) return; + const key = `${projectId.value}:${name}`; + if (pendingSubRuleAssets.has(key)) return; + + if (!subRuleAssetsLoaded.value) { + await refreshSubRuleAssets(); + } + if (hasProjectSubRuleAsset(name)) return; + + pendingSubRuleAssets.add(key); + try { + await subRuleApi.saveProject(auth.token, orgId.value, projectId.value, name, { + name, + display_name: name, + description: t('subRules.defaultDescription'), + draft: createDefaultSubRuleDraft(name), + input_schema: [], + output_schema: [], + expected_seq: 0, + }); + await refreshSubRuleAssets(); + } catch (e: any) { + MessagePlugin.warning(e.message || t('subRules.saveFailed')); + } finally { + pendingSubRuleAssets.delete(key); + } +} + +async function handleOpenSubRule(refName: string) { + if (!refName) return; + const parentTabName = projectStore.activeTab?.name ?? null; + const scope = + (projectStore.activeTab?.ruleset.steps as any[])?.find( + (s: any) => s.type === 'sub_rule' && s.refName === refName + )?.assetRef?.scope ?? 'project'; + if (scope === 'project') { + await ensureProjectSubRuleAsset(refName); + } + try { + await projectStore.openSubRule(refName, scope); + const tabName = `§${refName}`; + if (parentTabName && parentTabName !== tabName) { + subRuleParentTabs.set(tabName, parentTabName); + } + tabModes.set(tabName, 'flow'); + router.replace(`${projectBase.value}/editor/${encodeURIComponent(tabName)}`); + } catch (e: any) { + MessagePlugin.error(e.message || t('subRules.loadFailed')); + } +} + +function openCreateProjectSubRule() { + newSubRuleName.value = makeUniqueProjectSubRuleName('sub_rule'); + newSubRuleDisplayName.value = ''; + newSubRuleDescription.value = ''; + showCreateSubRule.value = true; +} + +async function openProjectSubRuleAsset(name: string) { + if (!name) return; + const parentTabName = + projectStore.activeTab?.kind === 'sub_rule' ? null : projectStore.activeTab?.name ?? null; + try { + await projectStore.openSubRule(name, 'project'); + const tabName = `§${name}`; + if (parentTabName && parentTabName !== tabName) { + subRuleParentTabs.set(tabName, parentTabName); + } + tabModes.set(tabName, 'flow'); + router.replace(`${projectBase.value}/editor/${encodeURIComponent(tabName)}`); + } catch (e: any) { + MessagePlugin.error(e.message || t('subRules.loadFailed')); + } +} + +async function handleCreateProjectSubRule() { + if (!canEdit.value) { + MessagePlugin.warning(t('editor.noPermission')); + return; + } + + if (!newSubRuleName.value.trim()) { + MessagePlugin.warning(t('subRules.nameRequired')); + return; + } + const name = sanitizeAssetName(newSubRuleName.value); + + if (!subRuleAssetsLoaded.value) { + await refreshSubRuleAssets(); + } + if (hasAnySubRuleAsset(name)) { + MessagePlugin.warning(t('subRules.nameExists', { name })); + return; + } + if (!auth.token || !orgId.value || !projectId.value) return; + + creatingSubRuleAsset.value = true; + try { + await subRuleApi.saveProject(auth.token, orgId.value, projectId.value, name, { + name, + display_name: newSubRuleDisplayName.value.trim() || name, + description: newSubRuleDescription.value.trim() || t('subRules.defaultDescription'), + draft: createDefaultSubRuleDraft(name), + input_schema: [], + output_schema: [], + expected_seq: 0, + }); + await refreshSubRuleAssets(); + showCreateSubRule.value = false; + MessagePlugin.success(t('subRules.createSuccess')); + await openProjectSubRuleAsset(name); + } catch (e: any) { + MessagePlugin.error(e.message || t('subRules.saveFailed')); + } finally { + creatingSubRuleAsset.value = false; + } +} + +function returnToSubRuleParent() { + const parentName = activeSubRuleParentName.value; + if (!parentName) return; + switchToTab(parentName); + router.replace(`${projectBase.value}/editor/${encodeURIComponent(parentName)}`); +} + +function handleVersionChange(event: Event) { + const tab = projectStore.activeTab; + if (!tab) return; + + const target = event.target as HTMLInputElement; + const nextVersion = stripVersionSuffix(target.value); + const nextRuleset: RuleSet = { + ...tab.ruleset, + config: { + ...tab.ruleset.config, + version: nextVersion, + }, + }; + + const action = buildHistoryAction(tab.ruleset, nextRuleset); + updateRulesetState(tab.name, nextRuleset); + scheduleEditHistoryEntry(tab.name, nextRuleset, action); +} + +async function handleSave(name: string) { + if (!canEdit.value) { + MessagePlugin.warning(t('editor.noPermission')); + return; + } + const tab = projectStore.openTabs.find((item) => item.name === name); + if (!tab) return; + + const nextVersion = stripVersionSuffix(tab.ruleset.config.version); + const meta = projectStore.draftMetas.find((item) => item.name === name) ?? null; + const publishedVersion = stripVersionSuffix(meta?.published_version); + if (!nextVersion) { + MessagePlugin.warning(t('editor.versionRequired')); + return; + } + if (publishedVersion && publishedVersion === nextVersion) { + MessagePlugin.warning(t('editor.versionBumpRequired', { version: publishedVersion })); + return; + } + saving.value = true; + try { + flushPendingEditHistory(name); + const result = await projectStore.saveRuleset(name); + if (result?.conflict) { + const tab = projectStore.openTabs.find((item) => item.name === name); + if (!tab) { + MessagePlugin.error(t('editor.saveFailed')); + return; + } + conflictState.value = { + rulesetName: name, + localDraft: cloneRuleset(tab.ruleset), + serverDraft: cloneRuleset(normalizeRuleset(result.server_draft, name)), + serverSeq: result.server_seq, + }; + return; + } + const tab = projectStore.openTabs.find((item) => item.name === name); + if (tab) { + savedRulesetSnapshots.set(name, serializeRuleset(tab.ruleset)); + projectStore.setTabRuleset(name, cloneRuleset(tab.ruleset), false); + if (tab.kind === 'sub_rule') { + await refreshSubRuleAssets(); + } + pushHistoryEntry(name, tab.ruleset, t('historyPanel.actionSaveCheckpoint'), 'save'); + await flushHistoryQueue(name); + } + MessagePlugin.success(t('editor.saveSuccess')); + } catch (e: any) { + MessagePlugin.error(e.message || t('editor.saveFailed')); + } finally { + saving.value = false; + } +} + +async function resolveConflictUseServer() { + const conflict = conflictState.value; + if (!conflict) return; + const tab = projectStore.openTabs.find((item) => item.name === conflict.rulesetName); + if (!tab) { + conflictState.value = null; + return; + } + + tab.draft_seq = conflict.serverSeq; + savedRulesetSnapshots.set(conflict.rulesetName, serializeRuleset(conflict.serverDraft)); + projectStore.setTabRuleset(conflict.rulesetName, cloneRuleset(conflict.serverDraft), false); + syncDecisionTableFromRuleset(conflict.rulesetName, conflict.serverDraft); + conflictState.value = null; + MessagePlugin.success(t('conflict.useServerSuccess')); +} + +async function resolveConflictUseLocal() { + const conflict = conflictState.value; + if (!conflict) return; + const tab = projectStore.openTabs.find((item) => item.name === conflict.rulesetName); + if (!tab) { + conflictState.value = null; + return; + } + + tab.draft_seq = conflict.serverSeq; + projectStore.setTabRuleset(conflict.rulesetName, cloneRuleset(conflict.localDraft), true); + conflictState.value = null; + await handleSave(conflict.rulesetName); +} + +function openReleaseCenter() { + if (!projectStore.activeTab) return; + if (projectStore.activeTab.kind === 'sub_rule') return; + router.push({ + name: 'project-release-request-create', + params: { + orgId: route.params.orgId, + projectId: route.params.projectId, + }, + query: { ruleset: projectStore.activeTab.name }, + }); +} + +function openDecisionContract() { + const tab = projectStore.activeTab; + if (!tab || tab.kind === 'sub_rule') return; + router.push({ + name: 'contracts', + params: { + orgId: route.params.orgId, + projectId: route.params.projectId, + }, + query: { ruleset: tab.name }, + }); +} + +async function createMissingKnowledgeFacts() { + const fields = activeKnowledgeAnalysis.value?.missingFields ?? []; + if (!fields.length || !canEdit.value || creatingKnowledgeFacts.value) return; + + creatingKnowledgeFacts.value = true; + try { + for (const field of fields) { + await catalogStore.upsertFact({ + name: field.name, + data_type: field.dataType, + source: `request.body.${field.name}`, + null_policy: 'default', + description: t('knowledgeAdvisor.generatedFactDescription'), + owner: '', + }); + } + MessagePlugin.success(t('knowledgeAdvisor.createFactsSuccess', { count: fields.length })); + } catch (error: any) { + MessagePlugin.error(error?.message ?? t('facts.saveFailed')); + } finally { + creatingKnowledgeFacts.value = false; + } +} + +function handleCloseTab(name: string) { + const tab = projectStore.openTabs.find((t) => t.name === name); + if (tab?.dirty) { + const dlg = DialogPlugin.confirm({ + header: t('editor.closeConfirm'), + body: t('editor.closeConfirmBody', { name }), + confirmBtn: { content: t('editor.closeConfirmBtn'), theme: 'danger' }, + cancelBtn: t('common.cancel'), + onConfirm: async () => { + projectStore.closeTab(name); + await disposeTabHistory(name); + dlg.hide(); + if (!projectStore.activeTabName) { + router.replace(`${projectBase.value}/editor`); + } + }, + }); + } else { + projectStore.closeTab(name); + void disposeTabHistory(name); + if (!projectStore.activeTabName) { + router.replace(`${projectBase.value}/editor`); + } + } +} + +async function handleCreateRuleset() { + if (!newName.value.trim()) { + MessagePlugin.warning(t('editor.nameRequired')); + return; + } + creating.value = true; + try { + let rs: RuleSet; + const name = newName.value.trim(); + + if (newType.value === 'table') { + const doc = createEmptyTableDocument(name); + rs = documentToRuleSet(doc); + } else { + // Flow: Decision → Terminal + const decisionId = generateId(); + const terminalId = generateId(); + rs = { + config: { name, version: '1.0.0' }, + startStepId: decisionId, + steps: [ + Step.decision({ + id: decisionId, + name: 'start', + branches: [], + defaultNextStepId: terminalId, + }), + Step.terminal({ + id: terminalId, + name: 'result', + code: 'DEFAULT', + }), + ], + }; + } + + await projectStore.createRuleset(rs); + showCreate.value = false; + newName.value = ''; + MessagePlugin.success(t('editor.createSuccess')); + await openRuleset(name); + pushHistoryEntry(name, rs, t('historyPanel.actionCreateRuleset'), 'create'); + showHistoryPanel.value = true; + } catch (e: any) { + MessagePlugin.error(e.message || t('editor.createFailed')); + } finally { + creating.value = false; + } +} + +function handleDeleteRuleset(name: string) { + const dlg = DialogPlugin.confirm({ + header: t('editor.deleteDialog'), + body: t('editor.deleteConfirm', { name }), + confirmBtn: { content: t('editor.deleteConfirmBtn'), theme: 'danger' }, + cancelBtn: t('common.cancel'), + onConfirm: async () => { + try { + await projectStore.deleteRuleset(name); + await disposeTabHistory(name); + dlg.hide(); + MessagePlugin.success(t('editor.deleteSuccess')); + if (!projectStore.activeTabName && projectStore.rulesets.length > 0) { + await openRuleset(projectStore.rulesets[0].name); + } + } catch (e: any) { + MessagePlugin.error(e.message); + } + }, + }); +} + +function setEditorMode(mode: 'form' | 'flow' | 'table') { + const tab = projectStore.activeTab; + if (!tab) return; + if (mode === 'table' && !canBeTable(tab.ruleset)) { + MessagePlugin.warning(t('editor.tableUnsupported')); + return; + } + editorMode.value = mode; + tabModes.set(tab.name, mode); +} + +onUnmounted(() => { + for (const name of Array.from(editHistoryTimers.keys())) { + flushPendingEditHistory(name); + } + for (const name of Array.from(historyFlushTimers.keys())) { + void flushHistoryQueue(name); + } +}); + + + + + +