From e08cdc1d473cf28a8afa03470ce148cbd948df7b Mon Sep 17 00:00:00 2001 From: Pama-Lee Date: Sat, 25 Apr 2026 13:20:41 +0800 Subject: [PATCH 1/4] Add protocol conversion runtime --- Cargo.lock | 16 + Cargo.toml | 1 + Dockerfile.platform | 6 +- compose.yml | 25 + crates/ordo-cli/Cargo.toml | 2 + crates/ordo-cli/src/exec.rs | 29 +- crates/ordo-cli/src/main.rs | 1 + crates/ordo-cli/src/runtime.rs | 307 ++ crates/ordo-cli/src/test_runner.rs | 22 +- crates/ordo-core/src/capability/provider.rs | 14 + crates/ordo-core/src/capability/registry.rs | 4 + crates/ordo-core/src/expr/compiler.rs | 74 +- crates/ordo-core/src/expr/vm.rs | 49 +- crates/ordo-core/src/filter/path_collector.rs | 15 + crates/ordo-core/src/lib.rs | 7 +- crates/ordo-core/src/rule/compiled.rs | 162 +- .../ordo-core/src/rule/compiled_executor.rs | 290 +- crates/ordo-core/src/rule/compiler.rs | 40 +- crates/ordo-core/src/rule/executor.rs | 275 +- crates/ordo-core/src/rule/mod.rs | 6 +- crates/ordo-core/src/rule/model.rs | 96 +- crates/ordo-core/src/rule/step.rs | 42 +- crates/ordo-core/src/trace/mod.rs | 7 + crates/ordo-platform/Cargo.toml | 5 + crates/ordo-platform/src/main.rs | 221 +- .../ordo-platform/src/models/environments.rs | 2 +- crates/ordo-platform/src/models/release.rs | 118 + crates/ordo-platform/src/models/servers.rs | 3 + crates/ordo-platform/src/release.rs | 490 +++- .../ordo-platform/src/release/executions.rs | 2505 ++++++++++++++--- crates/ordo-platform/src/release/requests.rs | 142 +- crates/ordo-platform/src/release/reviews.rs | 53 +- crates/ordo-platform/src/ruleset_draft.rs | 80 +- crates/ordo-platform/src/server_registry.rs | 3 + crates/ordo-platform/src/store.rs | 13 +- crates/ordo-platform/src/store/releases.rs | 319 ++- crates/ordo-platform/src/store/rows.rs | 55 +- crates/ordo-platform/src/store/servers.rs | 16 +- crates/ordo-platform/src/sync.rs | 208 +- crates/ordo-platform/src/testing.rs | 46 +- crates/ordo-protocol/Cargo.toml | 16 + crates/ordo-protocol/src/convert.rs | 645 +++++ crates/ordo-protocol/src/lib.rs | 15 + crates/ordo-protocol/src/types/condition.rs | 86 + crates/ordo-protocol/src/types/expr.rs | 135 + crates/ordo-protocol/src/types/mod.rs | 12 + crates/ordo-protocol/src/types/ruleset.rs | 56 + crates/ordo-protocol/src/types/step.rs | 134 + crates/ordo-server/src/capability_registry.rs | 7 + crates/ordo-server/src/debug/api.rs | 1 + crates/ordo-server/src/main.rs | 7 + crates/ordo-server/src/sync/event.rs | 3 + crates/ordo-wasm/src/lib.rs | 289 +- deploy/nomad/devcontainer-entrypoint.sh | 18 +- deploy/nomad/ordo-devcontainer.nomad | 2 +- ordo-editor/apps/docs/.vitepress/config.mts | 2 + .../apps/docs/en/guide/execution-model.md | 137 + .../apps/docs/zh/guide/execution-model.md | 135 + .../apps/studio/src/api/platform-client.ts | 32 +- ordo-editor/apps/studio/src/api/types.ts | 48 + .../src/components/trace/RuleTraceRunner.vue | 14 +- .../apps/studio/src/i18n/locales/en.ts | 79 + .../apps/studio/src/i18n/locales/zh-CN.ts | 76 + .../apps/studio/src/i18n/locales/zh-TW.ts | 76 + ordo-editor/apps/studio/src/stores/test.ts | 22 +- .../src/views/project/ReleaseCenterView.vue | 15 +- .../project/ReleaseRequestDetailView.vue | 421 ++- .../packages/core/src/engine/adapter.ts | 66 +- .../core/src/engine/reverse-adapter.ts | 51 +- .../packages/core/src/model/ruleset.ts | 10 + ordo-editor/packages/core/src/model/step.ts | 65 +- .../src/components/flow/utils/converter.ts | 2 +- 72 files changed, 7537 insertions(+), 909 deletions(-) create mode 100644 crates/ordo-cli/src/runtime.rs create mode 100644 crates/ordo-protocol/Cargo.toml create mode 100644 crates/ordo-protocol/src/convert.rs create mode 100644 crates/ordo-protocol/src/lib.rs create mode 100644 crates/ordo-protocol/src/types/condition.rs create mode 100644 crates/ordo-protocol/src/types/expr.rs create mode 100644 crates/ordo-protocol/src/types/mod.rs create mode 100644 crates/ordo-protocol/src/types/ruleset.rs create mode 100644 crates/ordo-protocol/src/types/step.rs create mode 100644 ordo-editor/apps/docs/en/guide/execution-model.md create mode 100644 ordo-editor/apps/docs/zh/guide/execution-model.md diff --git a/Cargo.lock b/Cargo.lock index c54e03ce..395668e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2275,7 +2275,9 @@ dependencies = [ "anyhow", "clap", "colored", + "hashbrown 0.14.5", "ordo-core", + "reqwest 0.12.28", "serde", "serde_json", "serde_yaml", @@ -2349,6 +2351,7 @@ dependencies = [ "hostname", "jsonwebtoken", "ordo-core", + "ordo-protocol", "rand 0.8.5", "reqwest 0.12.28", "serde", @@ -2376,6 +2379,17 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "ordo-protocol" +version = "0.1.0" +dependencies = [ + "hashbrown 0.14.5", + "ordo-core", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "ordo-server" version = "0.4.2" @@ -3016,7 +3030,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http 1.4.0", "http-body 1.0.1", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index 9adc4530..8846f7b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/ordo-proto", "crates/ordo-server", "crates/ordo-platform", + "crates/ordo-protocol", "crates/ordo-wasm", "examples/capability-demo", ] diff --git a/Dockerfile.platform b/Dockerfile.platform index e9174f3b..1f08118a 100644 --- a/Dockerfile.platform +++ b/Dockerfile.platform @@ -15,12 +15,13 @@ RUN apt-get update && apt-get install -y \ COPY Cargo.toml Cargo.lock ./ COPY crates ./crates -# Build release binary (only ordo-platform; other crates build as deps) +# Build release binaries (HTTP API + background worker; other crates build as deps) RUN --mount=type=cache,id=ordo-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,id=ordo-cargo-git,target=/usr/local/cargo/git,sharing=locked \ --mount=type=cache,id=ordo-rust-target-platform,target=/app/target,sharing=locked \ cargo build --release --package ordo-platform \ - && cp /app/target/release/ordo-platform /tmp/ordo-platform + && cp /app/target/release/ordo-platform /tmp/ordo-platform \ + && cp /app/target/release/ordo-platform-worker /tmp/ordo-platform-worker # ── Runtime stage ───────────────────────────────────────────────────────────── FROM debian:bookworm-slim @@ -34,6 +35,7 @@ RUN apt-get update && apt-get install -y \ && useradd -r -s /bin/false ordo COPY --from=builder /tmp/ordo-platform /app/ordo-platform +COPY --from=builder /tmp/ordo-platform-worker /app/ordo-platform-worker COPY crates/ordo-platform/templates /app/templates USER ordo diff --git a/compose.yml b/compose.yml index b88922ae..18e3deeb 100644 --- a/compose.yml +++ b/compose.yml @@ -117,6 +117,31 @@ services: retries: 5 start_period: 15s + ordo-platform-worker: + build: + context: . + dockerfile: Dockerfile.platform + restart: unless-stopped + entrypoint: ["/app/ordo-platform-worker"] + environment: + ORDO_DATABASE_URL: "postgresql://ordo:${ORDO_DB_PASSWORD:-ordo_dev_pass}@postgres:5432/ordo_platform" + ORDO_ENGINE_URL: "http://ordo-server:8080" + ORDO_NATS_URL: "nats://nats:4222" + ORDO_NATS_SUBJECT_PREFIX: "ordo.rules" + ORDO_PLATFORM_TEMPLATES_DIR: "/app/templates" + ORDO_JWT_SECRET: "${ORDO_JWT_SECRET:-dev-secret-change-me-in-production-32c}" + ORDO_JWT_EXPIRY_HOURS: "24" + ORDO_LOG_LEVEL: info + depends_on: + nats: + condition: service_started + postgres: + condition: service_healthy + ordo-server: + condition: service_healthy + ordo-platform: + condition: service_healthy + # ── Studio frontend (hot-reload dev server) ─────────────────────────────── ordo-studio: image: node:22-alpine diff --git a/crates/ordo-cli/Cargo.toml b/crates/ordo-cli/Cargo.toml index daac1434..daf6d119 100644 --- a/crates/ordo-cli/Cargo.toml +++ b/crates/ordo-cli/Cargo.toml @@ -17,3 +17,5 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } colored = { workspace = true } anyhow = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +hashbrown = { workspace = true } diff --git a/crates/ordo-cli/src/exec.rs b/crates/ordo-cli/src/exec.rs index 41eeb147..499b023e 100644 --- a/crates/ordo-cli/src/exec.rs +++ b/crates/ordo-cli/src/exec.rs @@ -2,9 +2,11 @@ use anyhow::{Context, Result}; use clap::Args; use ordo_core::prelude::*; +use crate::runtime::{execute_loaded_rule, load_rule}; + #[derive(Args)] pub struct ExecArgs { - /// Rule file (JSON or YAML) + /// Rule file (JSON, YAML, or .ordo) #[arg(long, value_name = "FILE")] rule: String, @@ -26,7 +28,7 @@ pub struct ExecArgs { } pub fn run(args: ExecArgs) -> Result<()> { - let ruleset = load_ruleset(&args.rule)?; + let rule = load_rule(&args.rule)?; let mut input = load_input(args.input.as_deref(), args.input_file.as_deref())?; // Load external data files and inject as $data @@ -34,16 +36,7 @@ pub fn run(args: ExecArgs) -> Result<()> { inject_external_data(&mut input, &args.data_files)?; } - let executor = RuleExecutor::new(); - let options = if args.trace { - Some(ExecutionOptions::default().trace(true)) - } else { - None - }; - - let result = executor - .execute_with_options(&ruleset, input, options.as_ref()) - .map_err(|e| anyhow::anyhow!("Execution error: {}", e))?; + let result = execute_loaded_rule(&rule, input, args.trace)?; // Output result let output = serde_json::json!({ @@ -69,18 +62,6 @@ pub fn run(args: ExecArgs) -> Result<()> { Ok(()) } -fn load_ruleset(path: &str) -> Result { - let content = - std::fs::read_to_string(path).with_context(|| format!("Failed to read rule: {}", path))?; - if path.ends_with(".yaml") || path.ends_with(".yml") { - RuleSet::from_yaml_compiled(&content) - .map_err(|e| anyhow::anyhow!("Failed to parse YAML rule: {}", e)) - } else { - RuleSet::from_json_compiled(&content) - .map_err(|e| anyhow::anyhow!("Failed to parse JSON rule: {}", e)) - } -} - fn load_input(inline: Option<&str>, file: Option<&str>) -> Result { if let Some(json) = inline { let val: Value = serde_json::from_str(json).context("Failed to parse --input JSON")?; diff --git a/crates/ordo-cli/src/main.rs b/crates/ordo-cli/src/main.rs index 1b44cd98..c5c728b7 100644 --- a/crates/ordo-cli/src/main.rs +++ b/crates/ordo-cli/src/main.rs @@ -3,6 +3,7 @@ use clap::{Parser, Subcommand}; mod eval; mod exec; +mod runtime; mod test_runner; #[derive(Parser)] diff --git a/crates/ordo-cli/src/runtime.rs b/crates/ordo-cli/src/runtime.rs new file mode 100644 index 00000000..e2814760 --- /dev/null +++ b/crates/ordo-cli/src/runtime.rs @@ -0,0 +1,307 @@ +use anyhow::{Context, Result}; +use ordo_core::capability::{ + CapabilityCategory, CapabilityDescriptor, CapabilityProvider, CapabilityRegistry, + CapabilityRequest, CapabilityResponse, +}; +use ordo_core::error::{OrdoError, Result as OrdoResult}; +use ordo_core::prelude::{ + CompiledRuleExecutor, CompiledRuleSet, ExecutionOptions, ExecutionResult, RuleExecutor, + RuleSet, Value, +}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +pub enum LoadedRule { + Source(RuleSet), + Compiled(CompiledRuleSet), +} + +pub fn load_rule(path: &str) -> Result { + if path.ends_with(".ordo") { + let compiled = CompiledRuleSet::load_from_file(path) + .with_context(|| format!("Failed to load compiled rule: {}", path))?; + return Ok(LoadedRule::Compiled(compiled)); + } + + let content = + std::fs::read_to_string(path).with_context(|| format!("Failed to read rule: {}", path))?; + if path.ends_with(".yaml") || path.ends_with(".yml") { + RuleSet::from_yaml_compiled(&content) + .map(LoadedRule::Source) + .map_err(|e| anyhow::anyhow!("Failed to parse YAML rule: {}", e)) + } else { + RuleSet::from_json_compiled(&content) + .map(LoadedRule::Source) + .map_err(|e| anyhow::anyhow!("Failed to parse JSON rule: {}", e)) + } +} + +pub fn execute_loaded_rule( + rule: &LoadedRule, + input: Value, + trace: bool, +) -> Result { + let capability_invoker = build_cli_capability_invoker(); + + match rule { + LoadedRule::Source(ruleset) => { + let mut executor = RuleExecutor::new(); + executor.set_capability_invoker(capability_invoker); + let options = if trace { + Some(ExecutionOptions::default().trace(true)) + } else { + None + }; + executor + .execute_with_options(ruleset, input, options.as_ref()) + .map_err(|e| anyhow::anyhow!("Execution error: {}", e)) + } + LoadedRule::Compiled(compiled) => { + if trace { + eprintln!( + "warning: --trace is not supported for compiled .ordo execution; continuing without trace" + ); + } + let mut executor = CompiledRuleExecutor::new(); + executor.set_capability_invoker(capability_invoker); + executor + .execute(compiled, input) + .map_err(|e| anyhow::anyhow!("Execution error: {}", e)) + } + } +} + +fn build_cli_capability_invoker() -> Arc { + let registry = Arc::new(CapabilityRegistry::new()); + registry.register(Arc::new(HttpCapability::new())); + registry.register(Arc::new(MetricCapability)); + registry.register(Arc::new(AuditLoggerCapability)); + registry +} + +struct HttpCapability { + client: reqwest::blocking::Client, +} + +impl HttpCapability { + fn new() -> Self { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| reqwest::blocking::Client::new()); + Self { client } + } +} + +impl CapabilityProvider for HttpCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("network.http", CapabilityCategory::Network) + } + + fn invoke(&self, request: &CapabilityRequest) -> OrdoResult { + let payload = expect_object(&request.payload, "network.http")?; + let url = required_string(payload, "url", "network.http")?; + let method = request + .operation + .parse::() + .map_err(|error| { + OrdoError::capability_invocation( + "network.http", + format!("invalid method '{}': {}", request.operation, error), + ) + })?; + let headers = optional_tags(payload, "headers", "network.http")?; + let json_body = payload + .get("json_body") + .map(|body| { + serde_json::to_value(body).map_err(|error| { + OrdoError::capability_invocation( + "network.http", + format!("failed to serialize request body: {}", error), + ) + }) + }) + .transpose()?; + + let mut builder = self.client.request(method, url); + if let Some(timeout_ms) = request.timeout_ms { + builder = builder.timeout(Duration::from_millis(timeout_ms)); + } + for (name, value) in headers { + builder = builder.header(name, value); + } + if let Some(json_body) = json_body { + builder = builder.json(&json_body); + } + + let response = builder + .send() + .map_err(|error| OrdoError::capability_invocation("network.http", error.to_string()))?; + let status = response.status().as_u16(); + let body_text = response.text().unwrap_or_default(); + + let mut response_payload = HashMap::new(); + response_payload.insert("status".to_string(), Value::int(status as i64)); + response_payload.insert("body".to_string(), Value::string(&body_text)); + if let Ok(json_body) = serde_json::from_str::(&body_text) { + let json_body = serde_json::from_value(json_body).map_err(|error| { + OrdoError::capability_invocation( + "network.http", + format!("failed to convert response body: {}", error), + ) + })?; + response_payload.insert("json_body".to_string(), json_body); + } + + Ok(CapabilityResponse::new(Value::object(response_payload)) + .with_metadata("status", status.to_string())) + } +} + +struct MetricCapability; + +impl CapabilityProvider for MetricCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("metrics.prometheus", CapabilityCategory::Action) + } + + fn invoke(&self, request: &CapabilityRequest) -> OrdoResult { + Ok(CapabilityResponse::empty().with_metadata("operation", request.operation.clone())) + } +} + +struct AuditLoggerCapability; + +impl CapabilityProvider for AuditLoggerCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("audit.logger", CapabilityCategory::Action) + } + + fn invoke(&self, request: &CapabilityRequest) -> OrdoResult { + Ok(CapabilityResponse::empty() + .with_metadata("event", request.operation.clone()) + .with_metadata("capability", "audit.logger")) + } +} + +fn expect_object<'a>( + value: &'a Value, + capability: &str, +) -> OrdoResult<&'a hashbrown::HashMap> { + value.as_object().ok_or_else(|| { + OrdoError::capability_invocation( + capability, + format!("expected object payload, got {}", value.type_name()), + ) + }) +} + +fn required_string<'a>( + payload: &'a hashbrown::HashMap, + field: &str, + capability: &str, +) -> OrdoResult<&'a str> { + payload + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| OrdoError::capability_invocation(capability, format!("missing {}", field))) +} + +fn optional_tags( + payload: &hashbrown::HashMap, + field: &str, + capability: &str, +) -> OrdoResult> { + let Some(value) = payload.get(field) else { + return Ok(Vec::new()); + }; + + let object = value.as_object().ok_or_else(|| { + OrdoError::capability_invocation( + capability, + format!("expected object for '{}', got {}", field, value.type_name()), + ) + })?; + + object + .iter() + .map(|(key, value)| { + value + .as_str() + .map(|value| (key.to_string(), value.to_string())) + .ok_or_else(|| { + OrdoError::capability_invocation( + capability, + format!("header '{}' must be a string", key), + ) + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use ordo_core::expr::Expr; + use ordo_core::rule::{Action, ActionKind, RuleSetCompiler, Step, TerminalResult}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_ordo_path(prefix: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("{prefix}-{nonce}.ordo")) + } + + #[test] + fn compiled_ordo_rules_execute_through_cli_runtime() { + let mut ruleset = RuleSet::new("cli_compiled_runtime", "invoke"); + ruleset.add_step(Step::action( + "invoke", + "Invoke Audit Capability", + vec![Action { + kind: ActionKind::ExternalCall { + service: "audit.logger".to_string(), + method: "emit".to_string(), + params: vec![("message".to_string(), Expr::literal("hello"))], + result_variable: Some("audit_result".to_string()), + timeout_ms: 100, + }, + description: String::new(), + }], + "done", + )); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK") + .with_output("event", Expr::field("$audit_result.metadata.event")) + .with_output( + "capability", + Expr::field("$audit_result.metadata.capability"), + ), + )); + + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + let path = unique_temp_ordo_path("ordo-cli-runtime"); + compiled.save_to_file(&path).unwrap(); + + let loaded = load_rule(path.to_str().unwrap()).unwrap(); + let result = execute_loaded_rule(&loaded, Value::object(HashMap::new()), false).unwrap(); + + assert_eq!(result.code, "OK"); + assert_eq!( + result.output.get_path("event"), + Some(&Value::string("emit")) + ); + assert_eq!( + result.output.get_path("capability"), + Some(&Value::string("audit.logger")) + ); + + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/ordo-cli/src/test_runner.rs b/crates/ordo-cli/src/test_runner.rs index 346ba42d..5de94802 100644 --- a/crates/ordo-cli/src/test_runner.rs +++ b/crates/ordo-cli/src/test_runner.rs @@ -4,9 +4,11 @@ use colored::Colorize; use ordo_core::prelude::*; use serde::Deserialize; +use crate::runtime::{execute_loaded_rule, load_rule}; + #[derive(Args)] pub struct TestArgs { - /// Rule file (JSON or YAML) + /// Rule file (JSON, YAML, or .ordo) #[arg(long, value_name = "FILE")] rule: String, @@ -38,17 +40,15 @@ struct TestExpectation { } pub fn run(args: TestArgs) -> Result<()> { - let ruleset = load_ruleset(&args.rule)?; + let rule = load_rule(&args.rule)?; let suite = load_tests(&args.tests)?; - - let executor = RuleExecutor::new(); let mut passed = 0; let mut failed = 0; let total = suite.tests.len(); for test in &suite.tests { let start = std::time::Instant::now(); - let result = executor.execute(&ruleset, test.input.clone()); + let result = execute_loaded_rule(&rule, test.input.clone(), false); let elapsed = start.elapsed(); match result { @@ -132,18 +132,6 @@ pub fn run(args: TestArgs) -> Result<()> { Ok(()) } -fn load_ruleset(path: &str) -> Result { - let content = - std::fs::read_to_string(path).with_context(|| format!("Failed to read rule: {}", path))?; - if path.ends_with(".yaml") || path.ends_with(".yml") { - RuleSet::from_yaml_compiled(&content) - .map_err(|e| anyhow::anyhow!("Failed to parse YAML rule: {}", e)) - } else { - RuleSet::from_json_compiled(&content) - .map_err(|e| anyhow::anyhow!("Failed to parse JSON rule: {}", e)) - } -} - fn load_tests(path: &str) -> Result { let content = std::fs::read_to_string(path).with_context(|| format!("Failed to read tests: {}", path))?; diff --git a/crates/ordo-core/src/capability/provider.rs b/crates/ordo-core/src/capability/provider.rs index b8ac130a..fdc1dedc 100644 --- a/crates/ordo-core/src/capability/provider.rs +++ b/crates/ordo-core/src/capability/provider.rs @@ -108,6 +108,9 @@ pub struct CapabilityDescriptor { pub name: String, pub description: String, pub config: CapabilityConfig, + /// Operations this capability accepts (e.g. ["GET","POST"] or ["counter","gauge"]). + #[serde(default)] + pub operations: Vec, } impl CapabilityDescriptor { @@ -117,6 +120,7 @@ impl CapabilityDescriptor { name: name.into(), description: String::new(), config: CapabilityConfig::new(category), + operations: Vec::new(), } } @@ -131,6 +135,12 @@ impl CapabilityDescriptor { self.config = config; self } + + #[inline] + pub fn with_operations(mut self, ops: impl IntoIterator>) -> Self { + self.operations = ops.into_iter().map(Into::into).collect(); + self + } } /// Normalized request shape passed to capability providers. @@ -228,4 +238,8 @@ pub trait CapabilityInvoker: Send + Sync { let _ = capability; None } + + fn list_capabilities(&self) -> Vec { + vec![] + } } diff --git a/crates/ordo-core/src/capability/registry.rs b/crates/ordo-core/src/capability/registry.rs index 4755140f..fc45d245 100644 --- a/crates/ordo-core/src/capability/registry.rs +++ b/crates/ordo-core/src/capability/registry.rs @@ -176,6 +176,10 @@ impl CapabilityInvoker for CapabilityRegistry { self.lookup(capability) .map(|entry| entry.descriptor.clone()) } + + fn list_capabilities(&self) -> Vec { + self.list() + } } #[cfg(test)] diff --git a/crates/ordo-core/src/expr/compiler.rs b/crates/ordo-core/src/expr/compiler.rs index bf91c3a5..50b167e8 100644 --- a/crates/ordo-core/src/expr/compiler.rs +++ b/crates/ordo-core/src/expr/compiler.rs @@ -188,8 +188,6 @@ impl ExprCompiler { } => self.compile_conditional(condition, then_branch, else_branch), Expr::Array(elements) => { - // For now, compile each element and return the last register - // A proper implementation would create an array value if elements.is_empty() { let reg = self.alloc_reg(); let const_idx = self.add_constant(Value::array(vec![])); @@ -215,16 +213,74 @@ impl ExprCompiler { return reg; } - // Fall back to loading the first element for now - self.compile_expr(&elements[0]) + let result_reg = self.alloc_reg(); + let const_idx = self.add_constant(Value::array(vec![])); + self.emit(Instruction::new( + Opcode::LoadConst, + result_reg, + const_idx, + 0, + )); + for element in elements { + let value_reg = self.compile_expr(element); + self.emit(Instruction::new( + Opcode::ArrayPush, + result_reg, + value_reg, + 0, + )); + } + result_reg } - Expr::Object(_pairs) => { - // Simplified: return empty object - let reg = self.alloc_reg(); + Expr::Object(pairs) => { + if pairs.is_empty() { + let reg = self.alloc_reg(); + let const_idx = + self.add_constant(Value::object(std::collections::HashMap::new())); + self.emit(Instruction::new(Opcode::LoadConst, reg, const_idx, 0)); + return reg; + } + + if pairs + .iter() + .all(|(_, value)| matches!(value, Expr::Literal(_))) + { + let object: std::collections::HashMap = pairs + .iter() + .filter_map(|(key, value)| { + if let Expr::Literal(value) = value { + Some((key.clone(), value.clone())) + } else { + None + } + }) + .collect(); + let reg = self.alloc_reg(); + let const_idx = self.add_constant(Value::object(object)); + self.emit(Instruction::new(Opcode::LoadConst, reg, const_idx, 0)); + return reg; + } + + let result_reg = self.alloc_reg(); let const_idx = self.add_constant(Value::object(std::collections::HashMap::new())); - self.emit(Instruction::new(Opcode::LoadConst, reg, const_idx, 0)); - reg + self.emit(Instruction::new( + Opcode::LoadConst, + result_reg, + const_idx, + 0, + )); + for (key, value) in pairs { + let value_reg = self.compile_expr(value); + let key_idx = self.add_field(key); + self.emit(Instruction::new( + Opcode::ObjectSet, + result_reg, + value_reg, + key_idx, + )); + } + result_reg } Expr::Exists(path) => { diff --git a/crates/ordo-core/src/expr/vm.rs b/crates/ordo-core/src/expr/vm.rs index 9b14988c..6c5d114d 100644 --- a/crates/ordo-core/src/expr/vm.rs +++ b/crates/ordo-core/src/expr/vm.rs @@ -60,8 +60,10 @@ pub enum Opcode { Call = 50, // r[A] = func(r[B..B+C]) // Special - Exists = 60, // r[A] = ctx.has(fields[B]) - Return = 70, // return r[A] + Exists = 60, // r[A] = ctx.has(fields[B]) + ArrayPush = 61, // r[A].push(r[B]) + ObjectSet = 62, // r[A][fields[C]] = r[B] + Return = 70, // return r[A] // ========== SUPERINSTRUCTIONS ========== // These combine common patterns into single instructions @@ -406,6 +408,8 @@ fn opcode_from_u8(opcode: u8) -> Result { 42 => Ok(Opcode::Jump), 50 => Ok(Opcode::Call), 60 => Ok(Opcode::Exists), + 61 => Ok(Opcode::ArrayPush), + 62 => Ok(Opcode::ObjectSet), 70 => Ok(Opcode::Return), 100 => Ok(Opcode::FieldGtConst), 101 => Ok(Opcode::FieldLtConst), @@ -812,6 +816,25 @@ impl BytecodeVM { regs[inst.a as usize] = Value::bool(ctx.get(field).is_some()); } + Opcode::ArrayPush => { + let value = regs[inst.b as usize].clone(); + match &mut regs[inst.a as usize] { + Value::Array(values) => values.push(value), + other => return Err(OrdoError::type_error("array", other.type_name())), + } + } + + Opcode::ObjectSet => { + let key = unsafe { fields.get_unchecked(inst.c as usize) }; + let value = regs[inst.b as usize].clone(); + match &mut regs[inst.a as usize] { + Value::Object(map) => { + map.insert(std::sync::Arc::from(key.as_str()), value); + } + other => return Err(OrdoError::type_error("object", other.type_name())), + } + } + Opcode::Return => { return Ok(regs[inst.a as usize].clone()); } @@ -1125,6 +1148,23 @@ impl BytecodeVM { let field = unsafe { fields.get_unchecked(inst.b as usize) }; regs[inst.a as usize] = Value::bool(ctx.get(field).is_some()); } + Opcode::ArrayPush => { + let value = regs[inst.b as usize].clone(); + match &mut regs[inst.a as usize] { + Value::Array(values) => values.push(value), + other => return Err(OrdoError::type_error("array", other.type_name())), + } + } + Opcode::ObjectSet => { + let key = unsafe { fields.get_unchecked(inst.c as usize) }; + let value = regs[inst.b as usize].clone(); + match &mut regs[inst.a as usize] { + Value::Object(map) => { + map.insert(std::sync::Arc::from(key.as_str()), value); + } + other => return Err(OrdoError::type_error("object", other.type_name())), + } + } Opcode::Return => { let inst_duration = inst_start.elapsed().as_nanos() as u64; @@ -1206,6 +1246,11 @@ impl BytecodeVM { let field = compiled.fields.get(inst.b as usize); format!("EXISTS r{} = exists({:?})", inst.a, field) } + Opcode::ArrayPush => format!("ARRAY_PUSH r{} << r{}", inst.a, inst.b), + Opcode::ObjectSet => { + let field = compiled.fields.get(inst.c as usize); + format!("OBJECT_SET r{}[{field:?}] = r{}", inst.a, inst.b) + } Opcode::Return => format!("RETURN r{}", inst.a), Opcode::FieldGtConst => { let field = compiled.fields.get(inst.b as usize); diff --git a/crates/ordo-core/src/filter/path_collector.rs b/crates/ordo-core/src/filter/path_collector.rs index 535e37fd..edce9b5f 100644 --- a/crates/ordo-core/src/filter/path_collector.rs +++ b/crates/ordo-core/src/filter/path_collector.rs @@ -187,6 +187,21 @@ fn collect_recursive( } } } + + StepKind::SubRule { next_step, .. } => { + // Treat sub-rule as opaque — continue from next_step without tracking internals + collect_recursive( + ruleset, + evaluator, + next_step, + conditions, + target_results, + max_paths, + depth + 1, + paths, + truncated, + )?; + } } Ok(()) diff --git a/crates/ordo-core/src/lib.rs b/crates/ordo-core/src/lib.rs index ef59ae15..4d59de33 100644 --- a/crates/ordo-core/src/lib.rs +++ b/crates/ordo-core/src/lib.rs @@ -78,9 +78,10 @@ pub mod prelude { pub use crate::rule::{ Action, ActionKind, BatchExecutionResult, Branch, CompiledAction, CompiledBranch, CompiledCondition, CompiledMetadata, CompiledOutput, CompiledRuleExecutor, CompiledRuleSet, - CompiledStep, Condition, ExecutionOptions, ExecutionResult, LoggingMetricSink, MetricSink, - MetricType, NoOpMetricSink, RuleExecutor, RuleSet, RuleSetCompiler, RuleSetConfig, - RuleSetResolver, SingleExecutionResult, Step, StepKind, TerminalResult, + CompiledStep, Condition, ExecutionOptions, ExecutionResult, FieldMissingBehavior, LogLevel, + LoggingMetricSink, MetricSink, MetricType, NoOpMetricSink, RuleExecutor, RuleSet, + RuleSetCompiler, RuleSetConfig, RuleSetResolver, SingleExecutionResult, Step, StepKind, + SubRuleGraph, TerminalResult, }; #[cfg(feature = "signature")] pub use crate::signature::signer::RuleSigner; diff --git a/crates/ordo-core/src/rule/compiled.rs b/crates/ordo-core/src/rule/compiled.rs index e2339216..c70d9717 100644 --- a/crates/ordo-core/src/rule/compiled.rs +++ b/crates/ordo-core/src/rule/compiled.rs @@ -26,7 +26,7 @@ use std::path::Path; use std::sync::Arc; const MAGIC: &[u8; 4] = b"ORDO"; -const VERSION: u16 = 1; +const VERSION: u16 = 2; const FLAG_HAS_SIGNATURE: u16 = 0b0001; /// Maximum allowed size for collections during deserialization (prevent DoS attacks) @@ -668,6 +668,13 @@ pub enum CompiledAction { value: u32, tags: Vec<(u32, u32)>, }, + ExternalCall { + service: u32, + method: u32, + params: Vec<(u32, u32)>, + result_variable: Option, + timeout_ms: u64, + }, } impl CompiledAction { @@ -693,6 +700,24 @@ impl CompiledAction { write_u32(out, *v); } } + CompiledAction::ExternalCall { + service, + method, + params, + result_variable, + timeout_ms, + } => { + write_u8(out, 3); + write_u32(out, *service); + write_u32(out, *method); + write_u32(out, params.len() as u32); + for (name, expr) in params { + write_u32(out, *name); + write_u32(out, *expr); + } + write_option_u32(out, *result_variable); + write_u64(out, *timeout_ms); + } } } @@ -716,6 +741,24 @@ impl CompiledAction { } Ok(CompiledAction::Metric { name, value, tags }) } + 3 => { + let service = read_u32(cursor)?; + let method = read_u32(cursor)?; + let count = read_u32(cursor)? as usize; + let mut params = Vec::with_capacity(count); + for _ in 0..count { + params.push((read_u32(cursor)?, read_u32(cursor)?)); + } + let result_variable = read_option_u32(cursor)?; + let timeout_ms = read_u64(cursor)?; + Ok(CompiledAction::ExternalCall { + service, + method, + params, + result_variable, + timeout_ms, + }) + } _ => Err(OrdoError::parse_error("Unknown compiled action tag")), } } @@ -1004,6 +1047,11 @@ fn crc32_hash(data: &[u8]) -> u32 { #[cfg(test)] mod tests { use super::*; + use crate::capability::{ + CapabilityCategory, CapabilityDescriptor, CapabilityProvider, CapabilityRegistry, + CapabilityRequest, CapabilityResponse, + }; + use crate::error::OrdoError; use crate::expr::Expr; use crate::rule::{ Action, ActionKind, CompiledRuleExecutor, Condition, RuleSet, RuleSetCompiler, Step, @@ -1015,6 +1063,55 @@ mod tests { use crate::signature::signer::RuleSigner; #[cfg(feature = "signature")] use crate::signature::verifier::RuleVerifier; + use std::collections::HashMap; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct EchoCapability; + + impl CapabilityProvider for EchoCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("network.http", CapabilityCategory::Network) + } + + fn invoke(&self, request: &CapabilityRequest) -> Result { + let payload = match &request.payload { + Value::Object(payload) => payload, + other => { + return Err(OrdoError::capability_invocation( + "network.http", + format!("expected object payload, got {other:?}"), + )); + } + }; + + let url = payload + .get("url") + .cloned() + .ok_or_else(|| OrdoError::capability_invocation("network.http", "missing url"))?; + let amount = payload.get("amount").cloned().ok_or_else(|| { + OrdoError::capability_invocation("network.http", "missing amount") + })?; + + Ok(CapabilityResponse::new(Value::object({ + let mut response = HashMap::new(); + response.insert("status".to_string(), Value::int(200)); + response.insert("method".to_string(), Value::string(&request.operation)); + response.insert("url".to_string(), url); + response.insert("echoed_amount".to_string(), amount); + response + })) + .with_metadata("provider", "echo")) + } + } + + fn unique_temp_ordo_path(prefix: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("{prefix}-{nonce}.ordo")) + } fn build_ruleset() -> RuleSet { let mut ruleset = RuleSet::new("compiled_test", "start"); @@ -1319,6 +1416,69 @@ mod tests { println!("File saved at: {:?}", file_path); } + #[test] + fn test_compiled_external_call_ordo_file_roundtrip() { + let mut ruleset = RuleSet::new("compiled_external_file", "invoke"); + ruleset.add_step(Step::action( + "invoke", + "Invoke HTTP Capability", + vec![Action { + kind: ActionKind::ExternalCall { + service: "network.http".to_string(), + method: "POST".to_string(), + params: vec![ + ( + "url".to_string(), + Expr::literal("https://example.test/file-roundtrip"), + ), + ("amount".to_string(), Expr::field("amount")), + ], + result_variable: Some("http_result".to_string()), + timeout_ms: 250, + }, + description: String::new(), + }], + "done", + )); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK") + .with_output("status", Expr::field("$http_result.payload.status")) + .with_output("method", Expr::field("$http_result.payload.method")) + .with_output("amount", Expr::field("$http_result.payload.echoed_amount")) + .with_output("provider", Expr::field("$http_result.metadata.provider")), + )); + + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + let file_path = unique_temp_ordo_path("compiled-external-call"); + compiled.save_to_file(&file_path).unwrap(); + + let loaded = CompiledRuleSet::load_from_file(&file_path).unwrap(); + let registry = Arc::new(CapabilityRegistry::new()); + registry.register(Arc::new(EchoCapability)); + + let mut executor = CompiledRuleExecutor::new(); + executor.set_capability_invoker(registry); + + let input = serde_json::from_str(r#"{"amount": 42}"#).unwrap(); + let result = executor.execute(&loaded, input).unwrap(); + + assert_eq!(result.code, "OK"); + assert_eq!(result.output.get_path("status"), Some(&Value::int(200))); + assert_eq!( + result.output.get_path("method"), + Some(&Value::string("POST")) + ); + assert_eq!(result.output.get_path("amount"), Some(&Value::int(42))); + assert_eq!( + result.output.get_path("provider"), + Some(&Value::string("echo")) + ); + + std::fs::remove_file(&file_path).unwrap(); + } + #[test] fn test_compiled_ruleset_binary_is_unreadable() { let ruleset = build_ruleset(); diff --git a/crates/ordo-core/src/rule/compiled_executor.rs b/crates/ordo-core/src/rule/compiled_executor.rs index e5afba22..70ea5ec1 100644 --- a/crates/ordo-core/src/rule/compiled_executor.rs +++ b/crates/ordo-core/src/rule/compiled_executor.rs @@ -9,6 +9,7 @@ use crate::capability::{CapabilityInvoker, CapabilityRequest}; use crate::context::{Context, IString, Value}; use crate::error::{OrdoError, Result}; use crate::expr::BytecodeVM; +use std::collections::HashMap; use std::sync::Arc; // Use web_time for WASM, std::time for native @@ -216,11 +217,7 @@ impl CompiledRuleExecutor { } } CompiledAction::Metric { name, value, tags } => { - let expr = ruleset - .expressions - .get(*value as usize) - .ok_or_else(|| OrdoError::parse_error("Expression index out of range"))?; - let val = self.vm.execute(expr, ctx)?; + let val = self.evaluate_expr(ruleset, *value, ctx)?; let metric_value = match &val { Value::Int(i) => *i as f64, Value::Float(f) => *f, @@ -248,10 +245,64 @@ impl CompiledRuleExecutor { .collect::>>()?; self.record_metric(name, metric_value, &tags)?; } + CompiledAction::ExternalCall { + service, + method, + params, + result_variable, + timeout_ms, + } => { + let capability_invoker = self.capability_invoker.as_ref().ok_or_else(|| { + OrdoError::eval_error_static("ExternalCall requires a capability invoker") + })?; + + let service_name = ruleset.get_string(*service)?; + let operation = ruleset.get_string(*method)?; + let mut payload = HashMap::with_capacity(params.len()); + for (name, expr) in params { + payload.insert( + ruleset.get_string(*name)?.to_string(), + self.evaluate_expr(ruleset, *expr, ctx)?, + ); + } + + let mut request = CapabilityRequest::new( + service_name.to_string(), + operation.to_string(), + Value::object(payload), + ); + if *timeout_ms > 0 { + request = request.with_timeout(*timeout_ms); + } + + let response = capability_invoker.invoke(&request)?; + if let Some(result_variable) = result_variable { + let response_obj = build_capability_response_value( + service_name, + operation, + response.payload, + response.metadata, + ); + ctx.set_variable(ruleset.get_string(*result_variable)?, response_obj); + } + } } Ok(()) } + fn evaluate_expr( + &self, + ruleset: &CompiledRuleSet, + expr_idx: u32, + ctx: &Context, + ) -> Result { + let expr = ruleset + .expressions + .get(expr_idx as usize) + .ok_or_else(|| OrdoError::parse_error("Expression index out of range"))?; + self.vm.execute(expr, ctx) + } + fn record_metric(&self, name: &str, value: f64, tags: &[(String, String)]) -> Result<()> { if let Some(capability_invoker) = &self.capability_invoker { let mut tag_values = std::collections::HashMap::with_capacity(tags.len()); @@ -312,12 +363,35 @@ impl CompiledRuleExecutor { } } +fn build_capability_response_value( + service: &str, + operation: &str, + payload: Value, + metadata: HashMap, +) -> Value { + let metadata = Value::object( + metadata + .into_iter() + .map(|(key, value)| (key, Value::string(value))) + .collect(), + ); + + Value::object({ + let mut response = HashMap::with_capacity(4); + response.insert("capability".to_string(), Value::string(service)); + response.insert("operation".to_string(), Value::string(operation)); + response.insert("payload".to_string(), payload); + response.insert("metadata".to_string(), metadata); + response + }) +} + #[cfg(test)] mod tests { use super::*; use crate::capability::{ CapabilityCategory, CapabilityDescriptor, CapabilityProvider, CapabilityRegistry, - CapabilityResponse, + CapabilityRequest, CapabilityResponse, }; use crate::expr::Expr; use crate::rule::metrics::MetricSink; @@ -351,6 +425,48 @@ mod tests { } } + struct EchoCapability; + + impl CapabilityProvider for EchoCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("network.http", CapabilityCategory::Network) + } + + fn invoke(&self, request: &CapabilityRequest) -> Result { + let payload = match &request.payload { + Value::Object(payload) => payload, + other => { + return Err(OrdoError::capability_invocation( + "network.http", + format!("expected object payload, got {other:?}"), + )); + } + }; + + let url = payload + .get("url") + .cloned() + .ok_or_else(|| OrdoError::capability_invocation("network.http", "missing url"))?; + let amount = payload.get("amount").cloned().ok_or_else(|| { + OrdoError::capability_invocation("network.http", "missing amount") + })?; + let json_body = payload.get("json_body").cloned(); + + Ok(CapabilityResponse::new(Value::object({ + let mut response = HashMap::new(); + response.insert("status".to_string(), Value::int(200)); + response.insert("method".to_string(), Value::string(&request.operation)); + response.insert("url".to_string(), url); + response.insert("echoed_amount".to_string(), amount); + if let Some(json_body) = json_body { + response.insert("json_body".to_string(), json_body); + } + response + })) + .with_metadata("provider", "echo")) + } + } + #[test] fn compiled_executor_prefers_capability_metrics_when_available() { let mut ruleset = RuleSet::new("compiled_metric_test", "record_metric"); @@ -389,4 +505,166 @@ mod tests { assert_eq!(capability_ref.calls.load(Ordering::SeqCst), 1); assert_eq!(sink.gauge_calls.load(Ordering::SeqCst), 0); } + + #[test] + fn compiled_executor_supports_external_call_capabilities() { + let mut ruleset = RuleSet::new("compiled_external_call_test", "invoke"); + ruleset.add_step(Step::action( + "invoke", + "Invoke Capability", + vec![Action { + kind: ActionKind::ExternalCall { + service: "network.http".to_string(), + method: "POST".to_string(), + params: vec![ + ( + "url".to_string(), + Expr::literal("https://example.test/score"), + ), + ("amount".to_string(), Expr::field("amount")), + ], + result_variable: Some("http_result".to_string()), + timeout_ms: 250, + }, + description: String::new(), + }], + "done", + )); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK") + .with_output("status", Expr::field("$http_result.payload.status")) + .with_output("method", Expr::field("$http_result.payload.method")) + .with_output("amount", Expr::field("$http_result.payload.echoed_amount")) + .with_output("provider", Expr::field("$http_result.metadata.provider")), + )); + + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + let registry = Arc::new(CapabilityRegistry::new()); + registry.register(Arc::new(EchoCapability)); + + let mut executor = CompiledRuleExecutor::new(); + executor.set_capability_invoker(registry); + + let input = serde_json::from_str(r#"{"amount": 42}"#).unwrap(); + let result = executor.execute(&compiled, input).unwrap(); + + assert_eq!(result.code, "OK"); + assert_eq!(result.output.get_path("status"), Some(&Value::int(200))); + assert_eq!( + result.output.get_path("method"), + Some(&Value::string("POST")) + ); + assert_eq!(result.output.get_path("amount"), Some(&Value::int(42))); + assert_eq!( + result.output.get_path("provider"), + Some(&Value::string("echo")) + ); + } + + #[test] + fn compiled_ruleset_external_call_survives_serialize_roundtrip() { + let mut ruleset = RuleSet::new("compiled_external_roundtrip", "invoke"); + ruleset.add_step(Step::action( + "invoke", + "Invoke Capability", + vec![Action { + kind: ActionKind::ExternalCall { + service: "network.http".to_string(), + method: "POST".to_string(), + params: vec![ + ( + "url".to_string(), + Expr::literal("https://example.test/roundtrip"), + ), + ("amount".to_string(), Expr::field("amount")), + ], + result_variable: Some("http_result".to_string()), + timeout_ms: 100, + }, + description: String::new(), + }], + "done", + )); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK") + .with_output("amount", Expr::field("$http_result.payload.echoed_amount")), + )); + + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + let bytes = compiled.serialize(); + let decoded = CompiledRuleSet::deserialize(&bytes).unwrap(); + let registry = Arc::new(CapabilityRegistry::new()); + registry.register(Arc::new(EchoCapability)); + + let mut executor = CompiledRuleExecutor::new(); + executor.set_capability_invoker(registry); + + let input = serde_json::from_str(r#"{"amount": 17}"#).unwrap(); + let result = executor.execute(&decoded, input).unwrap(); + assert_eq!(result.output.get_path("amount"), Some(&Value::int(17))); + } + + #[test] + fn compiled_executor_preserves_object_payloads_for_external_calls() { + let mut ruleset = RuleSet::new("compiled_external_payload_test", "invoke"); + ruleset.add_step(Step::action( + "invoke", + "Invoke Capability", + vec![Action { + kind: ActionKind::ExternalCall { + service: "network.http".to_string(), + method: "POST".to_string(), + params: vec![ + ( + "url".to_string(), + Expr::literal("https://example.test/object-payload"), + ), + ( + "json_body".to_string(), + Expr::Object(vec![ + ("hello".to_string(), Expr::literal("world")), + ("amount".to_string(), Expr::field("amount")), + ]), + ), + ("amount".to_string(), Expr::field("amount")), + ], + result_variable: Some("http_result".to_string()), + timeout_ms: 250, + }, + description: String::new(), + }], + "done", + )); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK") + .with_output("hello", Expr::field("$http_result.payload.json_body.hello")) + .with_output( + "amount", + Expr::field("$http_result.payload.json_body.amount"), + ), + )); + + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + let registry = Arc::new(CapabilityRegistry::new()); + registry.register(Arc::new(EchoCapability)); + + let mut executor = CompiledRuleExecutor::new(); + executor.set_capability_invoker(registry); + + let input = serde_json::from_str(r#"{"amount": 42}"#).unwrap(); + let result = executor.execute(&compiled, input).unwrap(); + + assert_eq!(result.code, "OK"); + assert_eq!( + result.output.get_path("hello"), + Some(&Value::string("world")) + ); + assert_eq!(result.output.get_path("amount"), Some(&Value::int(42))); + } } diff --git a/crates/ordo-core/src/rule/compiler.rs b/crates/ordo-core/src/rule/compiler.rs index 1520dabf..98fe04cb 100644 --- a/crates/ordo-core/src/rule/compiler.rs +++ b/crates/ordo-core/src/rule/compiler.rs @@ -110,6 +110,21 @@ impl RuleSetCompiler { data: compiled.data, }); } + StepKind::SubRule { next_step, .. } => { + // SubRule steps are not compiled into the binary format. + // They require the interpreted executor to run the embedded sub-graph. + let next_step_hash = *step_hashes.get(next_step.as_str()).ok_or_else(|| { + OrdoError::StepNotFound { + step_id: next_step.clone(), + } + })?; + // Represent as an action step with no actions (transparent pass-through) + steps.push(CompiledStep::Action { + id_hash, + actions: vec![], + next_step: next_step_hash, + }); + } } } @@ -195,10 +210,27 @@ fn compile_actions( "CallRuleSet is not supported in compiled rules", )); } - ActionKind::ExternalCall { .. } => { - return Err(OrdoError::parse_error( - "ExternalCall is not supported in compiled rules", - )); + ActionKind::ExternalCall { + service, + method, + params, + result_variable, + timeout_ms, + } => { + let service_idx = string_pool.intern(service); + let method_idx = string_pool.intern(method); + let mut compiled_params = Vec::with_capacity(params.len()); + for (name, expr) in params { + compiled_params + .push((string_pool.intern(name), compile_expr(expr, expressions))); + } + compiled.push(CompiledAction::ExternalCall { + service: service_idx, + method: method_idx, + params: compiled_params, + result_variable: result_variable.as_ref().map(|v| string_pool.intern(v)), + timeout_ms: *timeout_ms, + }); } } } diff --git a/crates/ordo-core/src/rule/executor.rs b/crates/ordo-core/src/rule/executor.rs index 4815b9c4..9c345007 100644 --- a/crates/ordo-core/src/rule/executor.rs +++ b/crates/ordo-core/src/rule/executor.rs @@ -4,7 +4,7 @@ use super::metrics::{MetricSink, NoOpMetricSink}; use super::model::{FieldMissingBehavior, RuleSet}; -use super::step::{ActionKind, Condition, LogLevel, Step, StepKind, TerminalResult}; +use super::step::{ActionKind, Condition, LogLevel, Step, StepKind, SubRuleGraph, TerminalResult}; use crate::capability::{CapabilityInvoker, CapabilityRequest}; use crate::context::{Context, Value}; use crate::error::{OrdoError, Result}; @@ -279,7 +279,56 @@ 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) = if tracing { + 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( + 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 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, @@ -287,7 +336,7 @@ impl RuleExecutor { &ruleset.config.field_missing, remaining_call_depth, )?; - (result, step_start.elapsed().as_micros() as u64) + (result, step_start.elapsed().as_micros() as u64, None) } else { let result = self.execute_step( step, @@ -295,12 +344,12 @@ impl RuleExecutor { &ruleset.config.field_missing, remaining_call_depth, )?; - (result, 0) + (result, 0, None) }; // Record trace (only when enabled — zero overhead otherwise) if let Some(ref mut trace) = trace { - let step_trace = match &step_result { + let mut step_trace = match &step_result { StepResult::Continue { next_step } => { let mut st = StepTrace::continued(&step.id, &step.name, step_duration, next_step); @@ -323,6 +372,9 @@ impl RuleExecutor { st } }; + if let Some(frames) = sub_frames { + step_trace.sub_rule_frames = Some(frames); + } trace.add_step(step_trace); } @@ -472,6 +524,77 @@ impl RuleExecutor { } StepKind::Terminal { result } => Ok(StepResult::Terminal { result }), + + // Handled at the execute_internal loop level before reaching execute_step + StepKind::SubRule { .. } => { + unreachable!("SubRule steps are dispatched in execute_internal") + } + } + } + + /// Execute a sub-rule graph and return the resulting context and optional trace frames. + fn execute_sub_graph( + &self, + graph: &SubRuleGraph, + input: Value, + field_missing: &FieldMissingBehavior, + tracing: bool, + remaining_call_depth: usize, + ) -> Result<(Context, Vec)> { + let mut ctx = Context::new(input); + let mut frames: Vec = Vec::new(); + let mut current = graph.entry_step.clone(); + let mut depth: usize = 0; + + loop { + if depth >= 1000 { + return Err(OrdoError::MaxDepthExceeded { max_depth: 1000 }); + } + + let step = + graph + .steps + .get(current.as_str()) + .ok_or_else(|| OrdoError::StepNotFound { + step_id: current.clone(), + })?; + + let (result, dur) = 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) + } else { + ( + self.execute_step(step, &mut ctx, field_missing, remaining_call_depth)?, + 0, + ) + }; + + if tracing { + let mut st = match &result { + StepResult::Continue { next_step } => { + StepTrace::continued(&step.id, &step.name, dur, next_step) + } + StepResult::Terminal { .. } => StepTrace::terminal(&step.id, &step.name, dur), + }; + if self.trace_config.capture_input { + st.input_snapshot = Some(ctx.data().clone()); + } + if self.trace_config.capture_variables { + st.variables_snapshot = Some(ctx.variables().clone()); + } + frames.push(st); + } + + match result { + StepResult::Continue { next_step } => { + current = next_step.to_string(); + depth += 1; + } + StepResult::Terminal { .. } => { + return Ok((ctx, frames)); + } + } } } @@ -1264,4 +1387,146 @@ mod tests { let result = executor.execute(&main, input); assert!(result.is_err()); } + + #[test] + fn test_sub_rule_basic() { + use crate::rule::step::{Action, ActionKind, SubRuleGraph}; + + // Sub-rule: checks score and sets a "tier" variable + let mut sub_steps = hashbrown::HashMap::new(); + sub_steps.insert( + "check_score".to_string(), + Step::decision("check_score", "Check Score") + .branch(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(Value::string("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(Value::string("silver")), + }, + description: String::new(), + }], + "done", + ), + ); + sub_steps.insert( + "done".to_string(), + Step::terminal("done", "Done", TerminalResult::new("OK")), + ); + + let graph = SubRuleGraph { + entry_step: "check_score".to_string(), + steps: sub_steps, + }; + + let mut ruleset = RuleSet::new("main", "start"); + ruleset.add_sub_rule("classify", graph); + + // Main: SubRule step → terminal + 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: "end".to_string(), + }, + }); + ruleset.add_step(Step::terminal( + "end", + "End", + TerminalResult::new("DONE").with_output("tier", Expr::field("$result_tier")), + )); + + let executor = RuleExecutor::new(); + + // Test with score >= 90 → gold + let input: Value = serde_json::from_str(r#"{"score": 95}"#).unwrap(); + let result = executor.execute(&ruleset, input).unwrap(); + assert_eq!(result.code, "DONE"); + assert_eq!(result.output.get_path("tier"), Some(&Value::string("gold"))); + + // Test with score < 90 → silver + let input: Value = serde_json::from_str(r#"{"score": 70}"#).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] + fn test_sub_rule_validation_cycle() { + use crate::rule::step::SubRuleGraph; + + // Create a sub-rule that calls itself — should be detected as a cycle + let mut sub_steps = hashbrown::HashMap::new(); + sub_steps.insert( + "a".to_string(), + Step { + id: "a".to_string(), + name: "A".to_string(), + kind: StepKind::SubRule { + ref_name: "loop_sub".to_string(), + bindings: vec![], + outputs: vec![], + next_step: "term".to_string(), + }, + }, + ); + sub_steps.insert( + "term".to_string(), + Step::terminal("term", "Term", TerminalResult::new("OK")), + ); + + let graph = SubRuleGraph { + entry_step: "a".to_string(), + steps: sub_steps, + }; + + let mut ruleset = RuleSet::new("main", "start"); + ruleset.add_sub_rule("loop_sub", graph); + ruleset.add_step(Step { + id: "start".to_string(), + name: "Start".to_string(), + kind: StepKind::SubRule { + ref_name: "loop_sub".to_string(), + bindings: vec![], + outputs: vec![], + next_step: "end".to_string(), + }, + }); + ruleset.add_step(Step::terminal("end", "End", TerminalResult::new("OK"))); + + let errors = ruleset.validate().unwrap_err(); + assert!( + errors.iter().any(|e| e.contains("Cycle")), + "Expected cycle error, got: {:?}", + errors + ); + } } diff --git a/crates/ordo-core/src/rule/mod.rs b/crates/ordo-core/src/rule/mod.rs index 4d25aa63..98e8c8de 100644 --- a/crates/ordo-core/src/rule/mod.rs +++ b/crates/ordo-core/src/rule/mod.rs @@ -37,8 +37,10 @@ pub use executor::{ BatchExecutionResult, ExecutionOptions, ExecutionResult, RuleExecutor, SingleExecutionResult, }; pub use metrics::{LoggingMetricSink, MetricSink, MetricType, NoOpMetricSink}; -pub use model::{RuleSet, RuleSetConfig}; -pub use step::{Action, ActionKind, Branch, Condition, Step, StepKind, TerminalResult}; +pub use model::{FieldMissingBehavior, RuleSet, RuleSetConfig}; +pub use step::{ + Action, ActionKind, Branch, Condition, LogLevel, Step, StepKind, SubRuleGraph, TerminalResult, +}; use std::sync::Arc; diff --git a/crates/ordo-core/src/rule/model.rs b/crates/ordo-core/src/rule/model.rs index 19d52837..dba03c5f 100644 --- a/crates/ordo-core/src/rule/model.rs +++ b/crates/ordo-core/src/rule/model.rs @@ -2,7 +2,7 @@ //! //! Defines the structure of rule sets -use super::step::Step; +use super::step::{Step, SubRuleGraph}; use crate::error::Result; use hashbrown::HashMap as FastMap; use serde::{Deserialize, Serialize}; @@ -88,6 +88,10 @@ pub struct RuleSet { /// Steps by ID (hashbrown for faster lookup in the execution hot loop) pub steps: FastMap, + + /// Inline sub-rule graphs referenced by StepKind::SubRule + #[serde(default)] + pub sub_rules: FastMap, } impl RuleSet { @@ -107,9 +111,16 @@ impl RuleSet { metadata: HashMap::new(), }, steps: FastMap::new(), + sub_rules: FastMap::new(), } } + /// Add a sub-rule graph + pub fn add_sub_rule(&mut self, name: impl Into, graph: SubRuleGraph) -> &mut Self { + self.sub_rules.insert(name.into(), graph); + self + } + /// Add a step pub fn add_step(&mut self, step: Step) -> &mut Self { self.steps.insert(step.id.clone(), step); @@ -128,6 +139,8 @@ impl RuleSet { /// Validate the RuleSet pub fn validate(&self) -> std::result::Result<(), Vec> { + use super::step::StepKind; + let mut errors = Vec::new(); // Check entry step exists @@ -135,7 +148,7 @@ impl RuleSet { errors.push(format!("Entry step '{}' not found", self.config.entry_step)); } - // Check all referenced steps exist + // Check all referenced steps and sub-rules exist for step in self.steps.values() { for next_step in step.referenced_steps() { if !self.steps.contains_key(&next_step) { @@ -145,6 +158,39 @@ impl RuleSet { )); } } + if let StepKind::SubRule { ref_name, .. } = &step.kind { + if !self.sub_rules.contains_key(ref_name) { + errors.push(format!( + "Step '{}' references non-existent sub-rule '{}'", + step.id, ref_name + )); + } + } + } + + // Validate each sub-rule graph's internal integrity + for (name, graph) in &self.sub_rules { + if !graph.steps.contains_key(&graph.entry_step) { + errors.push(format!( + "Sub-rule '{}' entry step '{}' not found", + name, graph.entry_step + )); + } + for step in graph.steps.values() { + for next_step in step.referenced_steps() { + if !graph.steps.contains_key(&next_step) { + errors.push(format!( + "Sub-rule '{}' step '{}' references non-existent step '{}'", + name, step.id, next_step + )); + } + } + } + } + + // Cycle detection on sub-rule call graph + if let Err(cycle) = self.check_sub_rule_cycles() { + errors.push(cycle); } if errors.is_empty() { @@ -154,6 +200,47 @@ impl RuleSet { } } + /// DFS cycle detection on the sub-rule → sub-rule call graph. + fn check_sub_rule_cycles(&self) -> std::result::Result<(), String> { + use super::step::StepKind; + use std::collections::HashSet; + + fn dfs( + name: &str, + sub_rules: &FastMap, + visiting: &mut HashSet, + visited: &mut HashSet, + ) -> std::result::Result<(), String> { + if visiting.contains(name) { + return Err(format!( + "Cycle detected in sub-rule call graph at '{}'", + name + )); + } + if visited.contains(name) { + return Ok(()); + } + visiting.insert(name.to_string()); + if let Some(graph) = sub_rules.get(name) { + for step in graph.steps.values() { + if let StepKind::SubRule { ref_name, .. } = &step.kind { + dfs(ref_name, sub_rules, visiting, visited)?; + } + } + } + visiting.remove(name); + visited.insert(name.to_string()); + Ok(()) + } + + let mut visiting = HashSet::new(); + let mut visited = HashSet::new(); + for name in self.sub_rules.keys() { + dfs(name, &self.sub_rules, &mut visiting, &mut visited)?; + } + Ok(()) + } + /// Load from JSON string (raw, without compilation) /// /// **Note**: For production use, prefer `from_json_compiled()` which pre-compiles @@ -214,6 +301,11 @@ impl RuleSet { for step in self.steps.values_mut() { step.compile()?; } + for graph in self.sub_rules.values_mut() { + for step in graph.steps.values_mut() { + step.compile()?; + } + } Ok(()) } diff --git a/crates/ordo-core/src/rule/step.rs b/crates/ordo-core/src/rule/step.rs index 023a2502..762c224d 100644 --- a/crates/ordo-core/src/rule/step.rs +++ b/crates/ordo-core/src/rule/step.rs @@ -10,6 +10,7 @@ use crate::context::Value; use crate::error::Result; use crate::expr::{Expr, ExprParser}; +use hashbrown::HashMap as FastMap; use serde::{Deserialize, Serialize}; /// A step in the rule flow @@ -67,7 +68,7 @@ impl Step { } } - /// Get all steps referenced by this step + /// Get all steps referenced by this step (only outer/continuation steps, not sub-rule internals) pub fn referenced_steps(&self) -> Vec { match &self.kind { StepKind::Decision { @@ -82,16 +83,26 @@ impl Step { } StepKind::Action { next_step, .. } => vec![next_step.clone()], StepKind::Terminal { .. } => vec![], + StepKind::SubRule { next_step, .. } => vec![next_step.clone()], } } /// Compile all expression strings in this step to expression ASTs. /// This pre-parses conditions for faster evaluation at runtime. pub fn compile(&mut self) -> Result<()> { - if let StepKind::Decision { branches, .. } = &mut self.kind { - for branch in branches { - branch.compile()?; + match &mut self.kind { + StepKind::Decision { branches, .. } => { + for branch in branches { + branch.compile()?; + } + } + StepKind::SubRule { bindings, .. } => { + for (_, expr) in bindings { + // Expr is already compiled; nothing to do unless it wraps a string variant + let _ = expr; + } } + _ => {} } Ok(()) } @@ -175,6 +186,29 @@ pub enum StepKind { /// Result to return result: TerminalResult, }, + + /// Sub-rule step - jumps into an inline sub-graph and returns to next_step + SubRule { + /// Name of the sub-rule defined in RuleSet::sub_rules + ref_name: String, + /// Input bindings: (child_field_name, expr_evaluated_in_parent_context) + #[serde(default)] + bindings: Vec<(String, Expr)>, + /// Output mappings: (parent_variable, child_variable) + #[serde(default)] + outputs: Vec<(String, String)>, + /// Step to continue after the sub-rule completes + next_step: String, + }, +} + +/// Inline sub-rule graph embedded in a RuleSet +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubRuleGraph { + /// Entry step ID within this sub-graph + pub entry_step: String, + /// Steps keyed by ID + pub steps: FastMap, } /// A branch in a decision step diff --git a/crates/ordo-core/src/trace/mod.rs b/crates/ordo-core/src/trace/mod.rs index ee247084..efdb92b1 100644 --- a/crates/ordo-core/src/trace/mod.rs +++ b/crates/ordo-core/src/trace/mod.rs @@ -180,6 +180,10 @@ pub struct StepTrace { /// Whether this was a terminal step #[serde(default)] pub is_terminal: bool, + + /// Inner step traces when this is a sub-rule invocation + #[serde(skip_serializing_if = "Option::is_none")] + pub sub_rule_frames: Option>, } impl StepTrace { @@ -193,6 +197,7 @@ impl StepTrace { variables_snapshot: None, next_step: None, is_terminal: false, + sub_rule_frames: None, } } @@ -206,6 +211,7 @@ impl StepTrace { variables_snapshot: None, next_step: Some(next_step.to_string()), is_terminal: false, + sub_rule_frames: None, } } @@ -219,6 +225,7 @@ impl StepTrace { variables_snapshot: None, next_step: None, is_terminal: true, + sub_rule_frames: None, } } } diff --git a/crates/ordo-platform/Cargo.toml b/crates/ordo-platform/Cargo.toml index 6d424616..497bae66 100644 --- a/crates/ordo-platform/Cargo.toml +++ b/crates/ordo-platform/Cargo.toml @@ -9,6 +9,10 @@ description = "Ordo decision management platform — organization, project, auth name = "ordo-platform" path = "src/main.rs" +[[bin]] +name = "ordo-platform-worker" +path = "src/bin/ordo-platform-worker.rs" + [dependencies] anyhow.workspace = true argon2.workspace = true @@ -37,6 +41,7 @@ url = "2" sha2.workspace = true hex.workspace = true ordo-core = { path = "../ordo-core", default-features = false, features = ["derive"] } +ordo-protocol = { path = "../ordo-protocol" } [dev-dependencies] tempfile = "3" diff --git a/crates/ordo-platform/src/main.rs b/crates/ordo-platform/src/main.rs index d05e9d28..ffc7b3c6 100644 --- a/crates/ordo-platform/src/main.rs +++ b/crates/ordo-platform/src/main.rs @@ -1,7 +1,6 @@ //! Ordo Platform Server. use std::sync::Arc; -use std::time::Duration; use axum::{ routing::{any, get, post, put}, @@ -15,86 +14,19 @@ use tower_http::{ }; use tracing::info; -mod auth; -mod catalog; -mod config; -mod contract; -mod environment; -mod error; -mod github; -mod i18n; -mod member; -mod middleware; -mod models; -mod notification; -mod org; -mod project; -mod proxy; -mod rbac; -mod release; -mod ruleset_draft; -mod ruleset_history; -mod server_registry; -mod store; -mod sub_org_member; -mod sync; -mod template; -mod templates_api; -mod testing; - -use config::PlatformConfig; -use middleware::require_auth; -use store::PlatformStore; -use template::TemplateStore; - -fn resolve_templates_dir(configured: &std::path::Path) -> std::path::PathBuf { - if configured.exists() { - return configured.to_path_buf(); - } - - let fallbacks = [ - std::path::PathBuf::from("./crates/ordo-platform/templates"), - std::path::PathBuf::from("./templates"), - std::path::PathBuf::from("/app/templates"), - ]; - - if let Some(path) = fallbacks.into_iter().find(|path| path.exists()) { - tracing::info!( - configured = %configured.display(), - resolved = %path.display(), - "Using fallback templates directory", - ); - return path; - } - - configured.to_path_buf() -} - -/// Shared application state -#[derive(Clone)] -pub struct AppState { - pub store: Arc, - pub config: Arc, - pub http_client: reqwest::Client, - pub templates: Arc, - pub sync_publisher: Option>, - pub marketplace_cache: Arc, -} +use ordo_platform::{ + auth, bootstrap_platform_store, build_app_state, catalog, config::PlatformConfig, + 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, +}; #[tokio::main] async fn main() -> anyhow::Result<()> { let config = Arc::new(PlatformConfig::parse()); - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { - config - .log_level - .parse() - .unwrap_or_else(|_| "info".parse().unwrap()) - }), - ) - .init(); + init_tracing(&config)?; if let Err(e) = config.validate() { return Err(anyhow::anyhow!("Configuration error: {}", e)); @@ -110,131 +42,12 @@ async fn main() -> anyhow::Result<()> { ); } - let pool = sqlx::PgPool::connect(&config.database_url).await?; - sqlx::migrate!("./migrations").run(&pool).await?; - let store = Arc::new(PlatformStore::new(pool).await?); - - { - let orgs = store.list_all_orgs().await.unwrap_or_default(); - for org in &orgs { - if let Err(e) = store.seed_system_roles(&org.id).await { - tracing::warn!("seed_system_roles failed for org {}: {}", org.id, e); - } - } - - let all_projects = store.list_all_projects().await.unwrap_or_default(); - for project in all_projects { - if let Err(e) = store - .migrate_project_server_to_environment(&project.id, project.server_id.as_deref()) - .await - { - tracing::warn!("migrate env failed for project {}: {}", project.id, e); - } - } + let store = connect_platform_store(&config).await?; + bootstrap_platform_store(&store, false).await?; + start_server_registry_maintenance(store.clone()); - if let Err(e) = store.backfill_project_rulesets_from_history().await { - tracing::warn!("backfill_project_rulesets_from_history failed: {}", e); - } - - match store.fail_stuck_queued_deployments().await { - Ok(n) if n > 0 => tracing::warn!( - count = n, - "Marked stuck queued deployments as failed on startup" - ), - Ok(_) => {} - Err(e) => tracing::warn!("fail_stuck_queued_deployments: {}", e), - } - - match store.fail_stuck_active_executions().await { - Ok(n) if n > 0 => tracing::warn!( - count = n, - "Marked stuck active release executions as failed on startup" - ), - Ok(_) => {} - Err(e) => tracing::warn!("fail_stuck_active_executions: {}", e), - } - } - - { - let store_bg = store.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); - loop { - interval.tick().await; - let now = chrono::Utc::now(); - let degraded_threshold = now - chrono::Duration::seconds(90); - let offline_threshold = now - chrono::Duration::minutes(10); - let prune_threshold = now - chrono::Duration::minutes(30); - - let _ = store_bg - .mark_stale_servers_degraded(degraded_threshold) - .await; - let _ = store_bg.mark_stale_servers_offline(offline_threshold).await; - let _ = store_bg.delete_stale_offline_servers(prune_threshold).await; - } - }); - } - - let http_client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build()?; - - let templates_dir = resolve_templates_dir(&config.templates_dir); - let templates = Arc::new( - TemplateStore::load_from_dir(&templates_dir).unwrap_or_else(|e| { - tracing::warn!("Failed to load templates from {:?}: {:#}", templates_dir, e); - TemplateStore::default() - }), - ); - - let sync_publisher = if let Some(nats_url) = config.nats_url.as_deref() { - let jetstream = sync::connect(nats_url) - .await - .map_err(|e| anyhow::anyhow!("Failed to connect to NATS at {}: {}", nats_url, e))?; - sync::ensure_stream(&jetstream, &config.nats_subject_prefix) - .await - .map_err(|e| anyhow::anyhow!("Failed to ensure NATS stream: {}", e))?; - let registry_consumer = sync::create_control_consumer( - &jetstream, - &config.resolve_instance_id(), - &config.nats_subject_prefix, - ) - .await - .map_err(|e| anyhow::anyhow!("Failed to create NATS registry consumer: {}", e))?; - sync::start_registry_subscriber(registry_consumer, store.clone()); - Some(Arc::new(sync::NatsPublisher::new( - jetstream, - config.nats_subject_prefix.clone(), - config.resolve_instance_id(), - ))) - } else { - None - }; - - let marketplace_cache = github::MarketplaceCache::new(); - - let state = AppState { - store, - config: config.clone(), - http_client, - templates, - sync_publisher, - marketplace_cache, - }; - - if let Some(publisher) = &state.sync_publisher { - if let Ok(projects) = state.store.list_all_projects().await { - for project in projects { - let _ = publisher - .publish(sync::SyncEvent::TenantUpsert { - tenant_id: project.id.clone(), - name: project.name.clone(), - enabled: true, - }) - .await; - } - } - } + let state = build_app_state(config.clone(), store, false).await?; + publish_existing_tenants(&state).await; // CORS let cors = { @@ -444,6 +257,10 @@ async fn main() -> anyhow::Result<()> { "/api/v1/orgs/:oid/projects/:pid/releases/:rid", get(release::get_release_request), ) + .route( + "/api/v1/orgs/:oid/projects/:pid/releases/:rid/history", + get(release::list_release_request_history), + ) .route( "/api/v1/orgs/:oid/projects/:pid/releases/:rid/approve", post(release::approve_release_request), @@ -520,6 +337,10 @@ async fn main() -> anyhow::Result<()> { "/api/v1/orgs/:oid/projects/:pid/rulesets/:name/trace", post(ruleset_draft::trace_draft), ) + .route( + "/api/v1/orgs/:oid/projects/:pid/rulesets/:name/convert", + post(ruleset_draft::convert_draft_ruleset), + ) // Deployment history .route( "/api/v1/orgs/:oid/projects/:pid/deployments", diff --git a/crates/ordo-platform/src/models/environments.rs b/crates/ordo-platform/src/models/environments.rs index 5e0aece0..4e1716cf 100644 --- a/crates/ordo-platform/src/models/environments.rs +++ b/crates/ordo-platform/src/models/environments.rs @@ -60,7 +60,7 @@ pub struct ProjectRuleset { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveDraftRequest { - pub ruleset: JsonValue, + pub ruleset: ordo_protocol::StudioRuleSet, pub expected_seq: i64, } diff --git a/crates/ordo-platform/src/models/release.rs b/crates/ordo-platform/src/models/release.rs index fca4e492..7606178f 100644 --- a/crates/ordo-platform/src/models/release.rs +++ b/crates/ordo-platform/src/models/release.rs @@ -137,6 +137,7 @@ pub enum ReleaseRequestStatus { Executing, Completed, Failed, + RollbackFailed, RolledBack, } @@ -151,6 +152,7 @@ impl std::fmt::Display for ReleaseRequestStatus { Self::Executing => write!(f, "executing"), Self::Completed => write!(f, "completed"), Self::Failed => write!(f, "failed"), + Self::RollbackFailed => write!(f, "rollback_failed"), Self::RolledBack => write!(f, "rolled_back"), } } @@ -168,6 +170,7 @@ impl std::str::FromStr for ReleaseRequestStatus { "executing" => Ok(Self::Executing), "completed" => Ok(Self::Completed), "failed" => Ok(Self::Failed), + "rollback_failed" => Ok(Self::RollbackFailed), "rolled_back" => Ok(Self::RolledBack), other => Err(format!("invalid release request status: {}", other)), } @@ -268,6 +271,8 @@ pub struct ReleaseRequestSnapshot { pub rollout_strategy: RolloutStrategy, pub rollback_policy: RollbackPolicy, pub affected_instance_count: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_ruleset_snapshot: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -295,6 +300,9 @@ pub struct ReleaseRequest { pub version_diff: ReleaseVersionDiff, pub content_diff: ReleaseContentDiffSummary, pub request_snapshot: ReleaseRequestSnapshot, + pub execution_attempts: i32, + pub max_execution_attempts: i32, + pub is_closed: bool, pub approvals: Vec, } @@ -325,6 +333,7 @@ pub enum ReleaseExecutionStatus { Paused, Verifying, RollbackInProgress, + RollbackFailed, Completed, Failed, } @@ -338,6 +347,7 @@ impl std::fmt::Display for ReleaseExecutionStatus { Self::Paused => write!(f, "paused"), Self::Verifying => write!(f, "verifying"), Self::RollbackInProgress => write!(f, "rollback_in_progress"), + Self::RollbackFailed => write!(f, "rollback_failed"), Self::Completed => write!(f, "completed"), Self::Failed => write!(f, "failed"), } @@ -354,6 +364,7 @@ impl std::str::FromStr for ReleaseExecutionStatus { "paused" => Ok(Self::Paused), "verifying" => Ok(Self::Verifying), "rollback_in_progress" => Ok(Self::RollbackInProgress), + "rollback_failed" => Ok(Self::RollbackFailed), "completed" => Ok(Self::Completed), "failed" => Ok(Self::Failed), other => Err(format!("invalid release execution status: {}", other)), @@ -365,6 +376,8 @@ impl std::str::FromStr for ReleaseExecutionStatus { #[serde(rename_all = "snake_case")] pub enum ReleaseInstanceStatus { Pending, + WaitingBatch, + Scheduled, Dispatching, Updating, Verifying, @@ -378,6 +391,8 @@ impl std::fmt::Display for ReleaseInstanceStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Pending => write!(f, "pending"), + Self::WaitingBatch => write!(f, "waiting_batch"), + Self::Scheduled => write!(f, "scheduled"), Self::Dispatching => write!(f, "dispatching"), Self::Updating => write!(f, "updating"), Self::Verifying => write!(f, "verifying"), @@ -394,6 +409,8 @@ impl std::str::FromStr for ReleaseInstanceStatus { fn from_str(s: &str) -> Result { match s { "pending" => Ok(Self::Pending), + "waiting_batch" => Ok(Self::WaitingBatch), + "scheduled" => Ok(Self::Scheduled), "dispatching" => Ok(Self::Dispatching), "updating" => Ok(Self::Updating), "verifying" => Ok(Self::Verifying), @@ -421,9 +438,11 @@ pub struct ReleaseExecutionInstance { pub instance_id: String, pub instance_name: String, pub zone: Option, + pub batch_index: i32, pub current_version: String, pub target_version: String, pub status: ReleaseInstanceStatus, + pub scheduled_at: Option>, pub updated_at: DateTime, pub message: Option, pub metric_summary: Option, @@ -437,6 +456,7 @@ pub struct ReleaseExecution { pub started_at: DateTime, pub current_batch: i32, pub total_batches: i32, + pub next_batch_at: Option>, pub strategy: RolloutStrategy, pub summary: ReleaseExecutionSummary, pub instances: Vec, @@ -451,3 +471,101 @@ pub struct ReleaseExecutionEvent { pub payload: serde_json::Value, pub created_at: DateTime, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReleaseHistoryScope { + Request, + Approval, + Execution, + Batch, + Instance, + Rollback, +} + +impl std::fmt::Display for ReleaseHistoryScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Request => write!(f, "request"), + Self::Approval => write!(f, "approval"), + Self::Execution => write!(f, "execution"), + Self::Batch => write!(f, "batch"), + Self::Instance => write!(f, "instance"), + Self::Rollback => write!(f, "rollback"), + } + } +} + +impl std::str::FromStr for ReleaseHistoryScope { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "request" => Ok(Self::Request), + "approval" => Ok(Self::Approval), + "execution" => Ok(Self::Execution), + "batch" => Ok(Self::Batch), + "instance" => Ok(Self::Instance), + "rollback" => Ok(Self::Rollback), + other => Err(format!("invalid release history scope: {}", other)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ReleaseHistoryActorType { + User, + #[default] + System, + Server, +} + +impl std::fmt::Display for ReleaseHistoryActorType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::User => write!(f, "user"), + Self::System => write!(f, "system"), + Self::Server => write!(f, "server"), + } + } +} + +impl std::str::FromStr for ReleaseHistoryActorType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "user" => Ok(Self::User), + "system" => Ok(Self::System), + "server" => Ok(Self::Server), + other => Err(format!("invalid release history actor type: {}", other)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ReleaseHistoryActor { + pub actor_type: ReleaseHistoryActorType, + pub actor_id: Option, + pub actor_name: Option, + pub actor_email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseRequestHistoryEntry { + pub id: String, + pub release_request_id: String, + pub release_execution_id: Option, + pub instance_id: Option, + pub scope: ReleaseHistoryScope, + pub action: String, + pub actor_type: ReleaseHistoryActorType, + pub actor_id: Option, + pub actor_name: Option, + pub actor_email: Option, + pub from_status: Option, + pub to_status: Option, + pub detail: serde_json::Value, + pub created_at: DateTime, +} diff --git a/crates/ordo-platform/src/models/servers.rs b/crates/ordo-platform/src/models/servers.rs index f952f15b..9efc1818 100644 --- a/crates/ordo-platform/src/models/servers.rs +++ b/crates/ordo-platform/src/models/servers.rs @@ -47,6 +47,7 @@ pub struct ServerNode { pub status: ServerStatus, pub last_seen: Option>, pub registered_at: DateTime, + pub capabilities: JsonValue, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -60,6 +61,7 @@ pub struct ServerInfo { pub status: ServerStatus, pub last_seen: Option>, pub registered_at: DateTime, + pub capabilities: JsonValue, } impl From for ServerInfo { @@ -74,6 +76,7 @@ impl From for ServerInfo { status: s.status, last_seen: s.last_seen, registered_at: s.registered_at, + capabilities: s.capabilities, } } } diff --git a/crates/ordo-platform/src/release.rs b/crates/ordo-platform/src/release.rs index 1de11386..9a8ee9c6 100644 --- a/crates/ordo-platform/src/release.rs +++ b/crates/ordo-platform/src/release.rs @@ -3,11 +3,13 @@ use crate::{ models::{ Claims, CreateReleasePolicyRequest, CreateReleaseRequest, DeploymentStatus, ReleaseApprovalDecision, ReleaseContentDiffSummary, ReleaseExecution, - ReleaseExecutionInstance, ReleaseExecutionStatus, ReleaseInstanceStatus, ReleasePolicy, - ReleasePolicyScope, ReleaseRequest, ReleaseRequestSnapshot, ReleaseRequestStatus, - ReleaseStepDiffItem, ReleaseTargetPreview, ReleaseTargetServerPreview, ReleaseVersionDiff, - ReviewReleaseRequest, RolloutStrategy, RolloutStrategyKind, RulesetDeployment, - RulesetHistoryEntry, RulesetHistorySource, UpdateReleasePolicyRequest, + ReleaseExecutionInstance, ReleaseExecutionStatus, ReleaseHistoryActor, + ReleaseHistoryActorType, ReleaseHistoryScope, ReleaseInstanceStatus, ReleasePolicy, + ReleasePolicyScope, ReleaseRequest, ReleaseRequestHistoryEntry, ReleaseRequestSnapshot, + ReleaseRequestStatus, ReleaseStepDiffItem, ReleaseTargetPreview, + ReleaseTargetServerPreview, ReleaseVersionDiff, ReviewReleaseRequest, RolloutStrategy, + RolloutStrategyKind, RulesetDeployment, RulesetHistoryEntry, RulesetHistorySource, + UpdateReleasePolicyRequest, User, }, rbac::{ require_project_permission, PERM_RELEASE_EXECUTE, PERM_RELEASE_INSTANCE_VIEW, @@ -42,15 +44,489 @@ mod reviews; use diff::*; +pub(crate) fn user_history_actor(claims: &Claims, user: Option<&User>) -> ReleaseHistoryActor { + ReleaseHistoryActor { + actor_type: ReleaseHistoryActorType::User, + actor_id: Some(claims.sub.clone()), + actor_name: user.map(|item| item.display_name.clone()), + actor_email: user.map(|item| item.email.clone()), + } +} + +pub(crate) fn system_history_actor(name: &str) -> ReleaseHistoryActor { + ReleaseHistoryActor { + actor_type: ReleaseHistoryActorType::System, + actor_id: Some(name.to_string()), + actor_name: Some(name.to_string()), + actor_email: None, + } +} + +pub(crate) fn server_history_actor( + server_id: &str, + server_name: Option<&str>, +) -> ReleaseHistoryActor { + ReleaseHistoryActor { + actor_type: ReleaseHistoryActorType::Server, + actor_id: Some(server_id.to_string()), + actor_name: server_name + .map(str::to_string) + .or_else(|| Some(server_id.to_string())), + actor_email: None, + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn append_release_history( + state: &AppState, + release_request_id: &str, + release_execution_id: Option<&str>, + instance_id: Option<&str>, + scope: ReleaseHistoryScope, + action: &str, + actor: &ReleaseHistoryActor, + from_status: Option, + to_status: Option, + detail: JsonValue, +) -> anyhow::Result<()> { + state + .store + .create_release_request_history( + &Uuid::new_v4().to_string(), + release_request_id, + release_execution_id, + instance_id, + scope, + action, + actor, + from_status.as_deref(), + to_status.as_deref(), + detail, + ) + .await +} + +pub(crate) fn merge_history_detail(mut base: JsonValue, extra: JsonValue) -> JsonValue { + match (&mut base, extra) { + (JsonValue::Object(base_map), JsonValue::Object(extra_map)) => { + for (key, value) in extra_map { + base_map.insert(key, value); + } + base + } + (_, other) => other, + } +} + +pub(crate) const MAX_RELEASE_EXECUTION_ATTEMPTS: usize = 3; + +pub(crate) fn can_transition_release_request_status( + from: &ReleaseRequestStatus, + to: &ReleaseRequestStatus, +) -> bool { + if from == to { + return true; + } + + matches!( + (from, to), + ( + ReleaseRequestStatus::Draft, + ReleaseRequestStatus::PendingApproval + ) | ( + ReleaseRequestStatus::PendingApproval, + ReleaseRequestStatus::Approved + ) | ( + ReleaseRequestStatus::PendingApproval, + ReleaseRequestStatus::Rejected + ) | ( + ReleaseRequestStatus::PendingApproval, + ReleaseRequestStatus::Cancelled + ) | ( + ReleaseRequestStatus::Rejected, + ReleaseRequestStatus::PendingApproval + ) | ( + ReleaseRequestStatus::Rejected, + ReleaseRequestStatus::Cancelled + ) | ( + ReleaseRequestStatus::Approved, + ReleaseRequestStatus::Executing + ) | ( + ReleaseRequestStatus::Approved, + ReleaseRequestStatus::Cancelled + ) | ( + ReleaseRequestStatus::Executing, + ReleaseRequestStatus::Completed + ) | ( + ReleaseRequestStatus::Executing, + ReleaseRequestStatus::Failed + ) | ( + ReleaseRequestStatus::Executing, + ReleaseRequestStatus::RollbackFailed + ) | ( + ReleaseRequestStatus::Executing, + ReleaseRequestStatus::RolledBack + ) | ( + ReleaseRequestStatus::Completed, + ReleaseRequestStatus::Executing + ) | ( + ReleaseRequestStatus::Failed, + ReleaseRequestStatus::Executing + ) | ( + ReleaseRequestStatus::Failed, + ReleaseRequestStatus::RolledBack + ) | ( + ReleaseRequestStatus::RollbackFailed, + ReleaseRequestStatus::RolledBack + ) | ( + ReleaseRequestStatus::Completed, + ReleaseRequestStatus::RolledBack + ) + ) +} + +pub(crate) fn can_transition_release_execution_status( + from: &ReleaseExecutionStatus, + to: &ReleaseExecutionStatus, +) -> bool { + if from == to { + return true; + } + + matches!( + (from, to), + ( + ReleaseExecutionStatus::Preparing, + ReleaseExecutionStatus::RollingOut + ) | ( + ReleaseExecutionStatus::Preparing, + ReleaseExecutionStatus::Paused + ) | ( + ReleaseExecutionStatus::Preparing, + ReleaseExecutionStatus::Failed + ) | ( + ReleaseExecutionStatus::RollingOut, + ReleaseExecutionStatus::WaitingStart + ) | ( + ReleaseExecutionStatus::RollingOut, + ReleaseExecutionStatus::Paused + ) | ( + ReleaseExecutionStatus::RollingOut, + ReleaseExecutionStatus::Completed + ) | ( + ReleaseExecutionStatus::RollingOut, + ReleaseExecutionStatus::Failed + ) | ( + ReleaseExecutionStatus::RollingOut, + ReleaseExecutionStatus::RollbackInProgress + ) | ( + ReleaseExecutionStatus::WaitingStart, + ReleaseExecutionStatus::RollingOut + ) | ( + ReleaseExecutionStatus::WaitingStart, + ReleaseExecutionStatus::Paused + ) | ( + ReleaseExecutionStatus::WaitingStart, + ReleaseExecutionStatus::Failed + ) | ( + ReleaseExecutionStatus::Paused, + ReleaseExecutionStatus::RollingOut + ) | ( + ReleaseExecutionStatus::Paused, + ReleaseExecutionStatus::RollbackInProgress + ) | ( + ReleaseExecutionStatus::Paused, + ReleaseExecutionStatus::Failed + ) | ( + ReleaseExecutionStatus::Completed, + ReleaseExecutionStatus::RollbackInProgress + ) | ( + ReleaseExecutionStatus::Failed, + ReleaseExecutionStatus::RollbackInProgress + ) | ( + ReleaseExecutionStatus::Verifying, + ReleaseExecutionStatus::Completed + ) | ( + ReleaseExecutionStatus::Verifying, + ReleaseExecutionStatus::Failed + ) | ( + ReleaseExecutionStatus::Verifying, + ReleaseExecutionStatus::RollbackInProgress + ) | ( + ReleaseExecutionStatus::RollbackInProgress, + ReleaseExecutionStatus::RollbackFailed + ) | ( + ReleaseExecutionStatus::RollbackInProgress, + ReleaseExecutionStatus::Completed + ) | ( + ReleaseExecutionStatus::RollbackInProgress, + ReleaseExecutionStatus::Failed + ) + ) +} + +pub(crate) fn validate_release_request_transition( + from: &ReleaseRequestStatus, + to: &ReleaseRequestStatus, +) -> anyhow::Result<()> { + if can_transition_release_request_status(from, to) { + Ok(()) + } else { + Err(anyhow::anyhow!( + "invalid release request status transition: {} -> {}", + from, + to + )) + } +} + +pub(crate) fn validate_release_execution_transition( + from: &ReleaseExecutionStatus, + to: &ReleaseExecutionStatus, +) -> anyhow::Result<()> { + if can_transition_release_execution_status(from, to) { + Ok(()) + } else { + Err(anyhow::anyhow!( + "invalid release execution status transition: {} -> {}", + from, + to + )) + } +} + +pub(crate) fn can_transition_release_instance_status( + from: &ReleaseInstanceStatus, + to: &ReleaseInstanceStatus, +) -> bool { + if from == to { + return true; + } + + matches!( + (from, to), + ( + ReleaseInstanceStatus::Pending, + ReleaseInstanceStatus::Dispatching + ) | ( + ReleaseInstanceStatus::Pending, + ReleaseInstanceStatus::Updating + ) | ( + ReleaseInstanceStatus::Pending, + ReleaseInstanceStatus::Failed + ) | ( + ReleaseInstanceStatus::Pending, + ReleaseInstanceStatus::Skipped + ) | ( + ReleaseInstanceStatus::WaitingBatch, + ReleaseInstanceStatus::Scheduled + ) | ( + ReleaseInstanceStatus::WaitingBatch, + ReleaseInstanceStatus::Dispatching + ) | ( + ReleaseInstanceStatus::WaitingBatch, + ReleaseInstanceStatus::Pending + ) | ( + ReleaseInstanceStatus::WaitingBatch, + ReleaseInstanceStatus::Failed + ) | ( + ReleaseInstanceStatus::WaitingBatch, + ReleaseInstanceStatus::Skipped + ) | ( + ReleaseInstanceStatus::Scheduled, + ReleaseInstanceStatus::Pending + ) | ( + ReleaseInstanceStatus::Scheduled, + ReleaseInstanceStatus::WaitingBatch + ) | ( + ReleaseInstanceStatus::Scheduled, + ReleaseInstanceStatus::Dispatching + ) | ( + ReleaseInstanceStatus::Scheduled, + ReleaseInstanceStatus::Failed + ) | ( + ReleaseInstanceStatus::Scheduled, + ReleaseInstanceStatus::Skipped + ) | ( + ReleaseInstanceStatus::Dispatching, + ReleaseInstanceStatus::Updating + ) | ( + ReleaseInstanceStatus::Dispatching, + ReleaseInstanceStatus::Failed + ) | ( + ReleaseInstanceStatus::Updating, + ReleaseInstanceStatus::Verifying + ) | ( + ReleaseInstanceStatus::Updating, + ReleaseInstanceStatus::Success + ) | ( + ReleaseInstanceStatus::Updating, + ReleaseInstanceStatus::RolledBack + ) | ( + ReleaseInstanceStatus::Updating, + ReleaseInstanceStatus::Failed + ) | ( + ReleaseInstanceStatus::Verifying, + ReleaseInstanceStatus::Success + ) | ( + ReleaseInstanceStatus::Verifying, + ReleaseInstanceStatus::RolledBack + ) | ( + ReleaseInstanceStatus::Verifying, + ReleaseInstanceStatus::Failed + ) | ( + ReleaseInstanceStatus::Success, + ReleaseInstanceStatus::Pending + ) | ( + ReleaseInstanceStatus::Success, + ReleaseInstanceStatus::WaitingBatch + ) | ( + ReleaseInstanceStatus::Failed, + ReleaseInstanceStatus::Pending + ) | ( + ReleaseInstanceStatus::Failed, + ReleaseInstanceStatus::WaitingBatch + ) + ) +} + +pub(crate) fn validate_release_instance_transition( + from: &ReleaseInstanceStatus, + to: &ReleaseInstanceStatus, +) -> anyhow::Result<()> { + if can_transition_release_instance_status(from, to) { + Ok(()) + } else { + Err(anyhow::anyhow!( + "invalid release instance status transition: {} -> {}", + from, + to + )) + } +} + +pub(crate) fn release_request_can_execute( + status: &ReleaseRequestStatus, + attempts: usize, +) -> anyhow::Result<()> { + match status { + ReleaseRequestStatus::Approved => Ok(()), + ReleaseRequestStatus::Failed if attempts < MAX_RELEASE_EXECUTION_ATTEMPTS => Ok(()), + ReleaseRequestStatus::Failed => Err(anyhow::anyhow!( + "release request exceeded max execution attempts ({})", + MAX_RELEASE_EXECUTION_ATTEMPTS + )), + ReleaseRequestStatus::RollbackFailed => Err(anyhow::anyhow!( + "rollback failed; the release request must finish rollback before it can run again" + )), + ReleaseRequestStatus::RolledBack => Err(anyhow::anyhow!( + "rolled back release requests are closed and cannot be executed again" + )), + ReleaseRequestStatus::Completed => Err(anyhow::anyhow!( + "completed release requests cannot be executed again" + )), + ReleaseRequestStatus::Executing => { + Err(anyhow::anyhow!("release request is already executing")) + } + _ => Err(anyhow::anyhow!( + "release request must be approved before execution" + )), + } +} + pub use executions::{ execute_release_request, get_current_release_execution, get_release_execution_for_request, list_release_execution_events, pause_release_execution, resume_release_execution, - rollback_release_execution, + rollback_release_execution, run_release_worker_loop, run_release_worker_once, }; pub use policies::{ create_release_policy, delete_release_policy, list_release_policies, update_release_policy, }; pub use requests::{ - create_release_request, get_release_request, list_release_requests, preview_release_target, + create_release_request, get_release_request, list_release_request_history, + list_release_requests, preview_release_target, }; pub use reviews::{approve_release_request, reject_release_request}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_state_machine_closes_after_rollback() { + assert!(can_transition_release_request_status( + &ReleaseRequestStatus::Completed, + &ReleaseRequestStatus::Executing, + )); + assert!(can_transition_release_request_status( + &ReleaseRequestStatus::Executing, + &ReleaseRequestStatus::RollbackFailed, + )); + assert!(can_transition_release_request_status( + &ReleaseRequestStatus::RollbackFailed, + &ReleaseRequestStatus::RolledBack, + )); + assert!(!can_transition_release_request_status( + &ReleaseRequestStatus::RolledBack, + &ReleaseRequestStatus::Executing, + )); + } + + #[test] + fn request_execute_rules_only_allow_approved_or_retryable_failed() { + assert!(release_request_can_execute(&ReleaseRequestStatus::Approved, 0).is_ok()); + assert!(release_request_can_execute(&ReleaseRequestStatus::Failed, 2).is_ok()); + assert!(release_request_can_execute(&ReleaseRequestStatus::Failed, 3).is_err()); + assert!(release_request_can_execute(&ReleaseRequestStatus::RollbackFailed, 1).is_err()); + assert!(release_request_can_execute(&ReleaseRequestStatus::Completed, 1).is_err()); + assert!(release_request_can_execute(&ReleaseRequestStatus::RolledBack, 1).is_err()); + } + + #[test] + fn execution_state_machine_allows_manual_rollback_from_terminal_release() { + assert!(can_transition_release_execution_status( + &ReleaseExecutionStatus::Completed, + &ReleaseExecutionStatus::RollbackInProgress, + )); + assert!(can_transition_release_execution_status( + &ReleaseExecutionStatus::Failed, + &ReleaseExecutionStatus::RollbackInProgress, + )); + assert!(can_transition_release_execution_status( + &ReleaseExecutionStatus::RollbackInProgress, + &ReleaseExecutionStatus::RollbackFailed, + )); + assert!(!can_transition_release_execution_status( + &ReleaseExecutionStatus::Completed, + &ReleaseExecutionStatus::RollingOut, + )); + } + + #[test] + fn instance_state_machine_allows_batch_and_rollback_replanning() { + assert!(can_transition_release_instance_status( + &ReleaseInstanceStatus::WaitingBatch, + &ReleaseInstanceStatus::Scheduled, + )); + assert!(can_transition_release_instance_status( + &ReleaseInstanceStatus::WaitingBatch, + &ReleaseInstanceStatus::Dispatching, + )); + assert!(can_transition_release_instance_status( + &ReleaseInstanceStatus::Scheduled, + &ReleaseInstanceStatus::Pending, + )); + assert!(can_transition_release_instance_status( + &ReleaseInstanceStatus::Success, + &ReleaseInstanceStatus::Pending, + )); + assert!(can_transition_release_instance_status( + &ReleaseInstanceStatus::Failed, + &ReleaseInstanceStatus::WaitingBatch, + )); + assert!(!can_transition_release_instance_status( + &ReleaseInstanceStatus::RolledBack, + &ReleaseInstanceStatus::Pending, + )); + } +} diff --git a/crates/ordo-platform/src/release/executions.rs b/crates/ordo-platform/src/release/executions.rs index 240a57f1..a0cc523e 100644 --- a/crates/ordo-platform/src/release/executions.rs +++ b/crates/ordo-platform/src/release/executions.rs @@ -1,14 +1,11 @@ use super::*; +use ordo_core::rule::RuleSet; +use ordo_protocol::StudioRuleSet; use std::time::Duration; use tracing::{error, info, warn}; const RELEASE_ACK_TIMEOUT_SECS: u64 = 30; - -fn execution_has_failed_outcome(execution: &ReleaseExecution) -> bool { - execution.status == ReleaseExecutionStatus::Failed - || (execution.status == ReleaseExecutionStatus::Completed - && (execution.summary.failed_instances > 0 || execution.summary.pending_instances > 0)) -} +const ROLLBACK_BATCH_INTERVAL_SECS: u64 = 0; fn execution_is_active(execution: &ReleaseExecution) -> bool { matches!( @@ -22,6 +19,270 @@ fn execution_is_active(execution: &ReleaseExecution) -> bool { ) } +fn instance_is_terminal(status: &ReleaseInstanceStatus) -> bool { + matches!( + status, + ReleaseInstanceStatus::Success + | ReleaseInstanceStatus::Failed + | ReleaseInstanceStatus::RolledBack + | ReleaseInstanceStatus::Skipped + ) +} + +async fn set_release_request_status_with_history( + state: &AppState, + release_request_id: &str, + from_status: ReleaseRequestStatus, + to_status: ReleaseRequestStatus, + actor: &ReleaseHistoryActor, + detail: JsonValue, +) -> anyhow::Result<()> { + validate_release_request_transition(&from_status, &to_status)?; + state + .store + .set_release_request_status(release_request_id, to_status.clone()) + .await?; + + if from_status == to_status { + return Ok(()); + } + + append_release_history( + state, + release_request_id, + None, + None, + ReleaseHistoryScope::Request, + "request_status_changed", + actor, + Some(from_status.to_string()), + Some(to_status.to_string()), + detail, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn update_release_execution_status_with_history( + state: &AppState, + release_request_id: &str, + execution_id: &str, + from_status: Option, + to_status: ReleaseExecutionStatus, + current_batch: Option, + actor: &ReleaseHistoryActor, + detail: JsonValue, +) -> anyhow::Result<()> { + let previous = match from_status { + Some(status) => status, + None => { + state + .store + .get_release_execution(execution_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Release execution not found"))? + .status + } + }; + + validate_release_execution_transition(&previous, &to_status)?; + + state + .store + .update_release_execution_status(execution_id, to_status.clone(), current_batch) + .await?; + + append_release_history( + state, + release_request_id, + Some(execution_id), + None, + ReleaseHistoryScope::Execution, + "execution_status_changed", + actor, + Some(previous.to_string()), + Some(to_status.to_string()), + detail, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn update_instance_status_with_history( + state: &AppState, + release_request_id: &str, + execution_id: &str, + instance_id: &str, + to_status: ReleaseInstanceStatus, + message: Option<&str>, + metric_summary: Option<&str>, + actor: &ReleaseHistoryActor, + action: &str, + detail: JsonValue, +) -> anyhow::Result<()> { + let previous = state + .store + .get_release_execution_instance(instance_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Release execution instance not found"))?; + + validate_release_instance_transition(&previous.status, &to_status)?; + + state + .store + .update_release_execution_instance(instance_id, to_status.clone(), message, metric_summary) + .await?; + + append_release_history( + state, + release_request_id, + Some(execution_id), + Some(instance_id), + ReleaseHistoryScope::Instance, + action, + actor, + Some(previous.status.to_string()), + Some(to_status.to_string()), + merge_history_detail( + serde_json::json!({ + "instance_name": previous.instance_name, + "target_instance_id": previous.instance_id, + "batch_index": previous.batch_index, + "zone": previous.zone, + "current_version": previous.current_version, + "target_version": previous.target_version, + "message": message, + }), + detail, + ), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn update_instance_schedule_with_history( + state: &AppState, + release_request_id: &str, + execution_id: &str, + instance_id: &str, + to_status: ReleaseInstanceStatus, + scheduled_at: Option>, + message: Option<&str>, + actor: &ReleaseHistoryActor, + action: &str, + detail: JsonValue, +) -> anyhow::Result<()> { + let previous = state + .store + .get_release_execution_instance(instance_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Release execution instance not found"))?; + + validate_release_instance_transition(&previous.status, &to_status)?; + + state + .store + .update_release_execution_instance_schedule( + instance_id, + to_status.clone(), + scheduled_at, + message, + ) + .await?; + + append_release_history( + state, + release_request_id, + Some(execution_id), + Some(instance_id), + ReleaseHistoryScope::Instance, + action, + actor, + Some(previous.status.to_string()), + Some(to_status.to_string()), + merge_history_detail( + serde_json::json!({ + "instance_name": previous.instance_name, + "target_instance_id": previous.instance_id, + "batch_index": previous.batch_index, + "zone": previous.zone, + "scheduled_at": scheduled_at.map(|value| value.to_rfc3339()), + "message": message, + }), + detail, + ), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn update_batch_schedule_with_history( + state: &AppState, + release_request_id: &str, + execution_id: &str, + batch_index: i32, + to_status: ReleaseInstanceStatus, + scheduled_at: Option>, + message: Option<&str>, + actor: &ReleaseHistoryActor, + action: &str, + detail: JsonValue, +) -> anyhow::Result<()> { + let before = state + .store + .list_release_execution_instances(execution_id) + .await?; + let affected: Vec<_> = before + .into_iter() + .filter(|instance| { + instance.batch_index == batch_index && !instance_is_terminal(&instance.status) + }) + .collect(); + + for instance in &affected { + validate_release_instance_transition(&instance.status, &to_status)?; + } + + state + .store + .update_release_execution_batch_schedule( + execution_id, + batch_index, + to_status.clone(), + scheduled_at, + message, + ) + .await?; + + for instance in affected { + append_release_history( + state, + release_request_id, + Some(execution_id), + Some(&instance.id), + ReleaseHistoryScope::Instance, + action, + actor, + Some(instance.status.to_string()), + Some(to_status.to_string()), + merge_history_detail( + serde_json::json!({ + "instance_name": instance.instance_name, + "target_instance_id": instance.instance_id, + "batch_index": batch_index, + "zone": instance.zone, + "scheduled_at": scheduled_at.map(|value| value.to_rfc3339()), + "message": message, + }), + detail.clone(), + ), + ) + .await?; + } + + Ok(()) +} + pub async fn list_release_execution_events( State(state): State, Extension(claims): Extension, @@ -113,26 +374,19 @@ pub async fn execute_release_request( .find_release_execution_by_request_id(&release.id) .await .map_err(PlatformError::Internal)?; + let execution_attempts = state + .store + .count_release_executions_by_request(&release.id) + .await + .map_err(PlatformError::Internal)? as usize; if latest_execution.as_ref().is_some_and(execution_is_active) { return Err(PlatformError::conflict( "Release execution is already in progress", )); } - - let retryable_execution = latest_execution - .as_ref() - .is_some_and(execution_has_failed_outcome); - let can_execute = release.status == ReleaseRequestStatus::Approved - || release.status == ReleaseRequestStatus::Failed - || (release.status == ReleaseRequestStatus::Completed && retryable_execution) - || (release.status == ReleaseRequestStatus::Executing && retryable_execution); - - if !can_execute { - return Err(PlatformError::conflict( - "Release request must be approved before execution", - )); - } + release_request_can_execute(&release.status, execution_attempts) + .map_err(|err| PlatformError::conflict(err.to_string()))?; let env = state .store @@ -155,25 +409,32 @@ pub async fn execute_release_request( .ok_or_else(|| PlatformError::not_found("Bound server not found"))?; bound_servers.push(server); } - let draft = state - .store - .get_draft_ruleset(&project_id, &release.ruleset_name) - .await - .map_err(PlatformError::Internal)? - .ok_or_else(|| PlatformError::not_found("Draft ruleset not found"))?; - let strategy = release.request_snapshot.rollout_strategy.clone(); let total_instances = bound_servers.len(); let batch_size = compute_batch_size(&strategy, total_instances); let total_batches = total_instances.div_ceil(batch_size); - - let execution_id = Uuid::new_v4().to_string(); - - state + let executor = state .store - .set_release_request_status(&release.id, ReleaseRequestStatus::Executing) + .get_user(&claims.sub) .await .map_err(PlatformError::Internal)?; + let actor = user_history_actor(&claims, executor.as_ref()); + + let execution_id = Uuid::new_v4().to_string(); + + set_release_request_status_with_history( + &state, + &release.id, + release.status.clone(), + ReleaseRequestStatus::Executing, + &actor, + serde_json::json!({ + "reason": "execution_requested", + "triggered_by": claims.sub, + }), + ) + .await + .map_err(PlatformError::Internal)?; state .store @@ -189,13 +450,58 @@ pub async fn execute_release_request( .await .map_err(PlatformError::Internal)?; + append_release_history( + &state, + &release.id, + Some(&execution_id), + None, + ReleaseHistoryScope::Execution, + "execution_created", + &actor, + None, + Some(ReleaseExecutionStatus::RollingOut.to_string()), + serde_json::json!({ + "ruleset_name": release.ruleset_name, + "version": release.version, + "environment_id": release.environment_id, + "environment_name": env.name, + "batch_size": batch_size, + "total_batches": total_batches, + "total_instances": total_instances, + "rollout_strategy": strategy, + }), + ) + .await + .map_err(PlatformError::Internal)?; + + append_release_history( + &state, + &release.id, + Some(&execution_id), + None, + ReleaseHistoryScope::Execution, + "execution_queued", + &actor, + None, + None, + serde_json::json!({ + "ruleset_name": release.ruleset_name, + "version": release.version, + "environment_name": env.name, + "worker": "ordo-platform-worker", + }), + ) + .await + .map_err(PlatformError::Internal)?; + let current_version = release .version_diff .from_version .clone() .unwrap_or_else(|| "unreleased".to_string()); let mut instances = Vec::new(); - for server in &bound_servers { + for (idx, server) in bound_servers.iter().enumerate() { + let batch_index = (idx / batch_size) as i32 + 1; let instance = ReleaseExecutionInstance { id: Uuid::new_v4().to_string(), release_execution_id: execution_id.clone(), @@ -206,11 +512,21 @@ pub async fn execute_release_request( .get("zone") .and_then(|value| value.as_str()) .map(str::to_string), + batch_index, current_version: current_version.clone(), target_version: release.version.clone(), - status: ReleaseInstanceStatus::Pending, + status: if batch_index == 1 { + ReleaseInstanceStatus::Pending + } else { + ReleaseInstanceStatus::WaitingBatch + }, + scheduled_at: None, updated_at: Utc::now(), - message: None, + message: if batch_index == 1 { + Some("Queued for immediate rollout".to_string()) + } else { + Some(format!("Waiting for batch {}", batch_index)) + }, metric_summary: None, }; state @@ -218,42 +534,30 @@ pub async fn execute_release_request( .create_release_execution_instance(&instance) .await .map_err(PlatformError::Internal)?; - instances.push(instance); - } - - // Retrieve deployer info for history entry (done before spawning to avoid extra DB call) - let deployer = state - .store - .get_user(&claims.sub) + append_release_history( + &state, + &release.id, + Some(&execution_id), + Some(&instance.id), + ReleaseHistoryScope::Instance, + "instance_initialized", + &actor, + None, + Some(instance.status.to_string()), + serde_json::json!({ + "instance_name": instance.instance_name, + "target_instance_id": instance.instance_id, + "batch_index": batch_index, + "zone": instance.zone, + "current_version": instance.current_version, + "target_version": instance.target_version, + "message": instance.message, + }), + ) .await .map_err(PlatformError::Internal)?; - - let ctx = RollingDeploymentContext { - state: state.clone(), - execution_id: execution_id.clone(), - org_id: org_id.clone(), - project_id: project_id.clone(), - release_id: release.id.clone(), - ruleset_name: release.ruleset_name.clone(), - version: release.version.clone(), - env, - instances, - bound_servers, - draft: draft.draft, - strategy, - deployed_by: claims.sub.clone(), - deployer_email: deployer.as_ref().map(|u| u.email.clone()), - deployer_display_name: deployer.as_ref().map(|u| u.display_name.clone()), - auto_rollback: release.request_snapshot.rollback_policy.auto_rollback, - rollback_version: release - .version_diff - .rollback_version - .clone() - .or_else(|| release.rollback_version.clone()), - release_note: release.release_note.clone(), - }; - - tokio::spawn(run_rolling_deployment(ctx)); + instances.push(instance); + } let execution = state .store @@ -289,51 +593,290 @@ struct RollingDeploymentContext { release_note: Option, } -/// Compute per-batch instance count from strategy. -fn compute_batch_size(strategy: &RolloutStrategy, total: usize) -> usize { - if total == 0 { - return 1; - } - match strategy.kind { - Some(RolloutStrategyKind::FixedBatch) => { - let sz = strategy.batch_size.unwrap_or(1).max(1) as usize; - sz.min(total) +pub async fn run_release_worker_loop( + state: AppState, + poll_interval: Duration, +) -> anyhow::Result<()> { + loop { + if let Err(err) = run_release_worker_once(state.clone()).await { + warn!("release worker poll failed: {err}"); } - Some(RolloutStrategyKind::TimeIntervalBatch) => { - let sz = strategy.batch_size.unwrap_or(1).max(1) as usize; - sz.min(total) + tokio::time::sleep(poll_interval).await; + } +} + +pub async fn run_release_worker_once(state: AppState) -> anyhow::Result { + let executions = state + .store + .list_worker_claimable_release_executions(32) + .await?; + let mut claimed = 0; + + for execution in executions { + let Some(lock) = state + .store + .try_lock_release_execution(&execution.id) + .await? + else { + continue; + }; + + claimed += 1; + let worker_state = state.clone(); + let execution_id = execution.id.clone(); + tokio::spawn(async move { + let _lock = lock; + if let Err(err) = run_claimed_release_execution(worker_state, &execution_id).await { + error!(execution_id, "release worker execution failed: {err}"); + } + }); + } + + Ok(claimed) +} + +async fn run_claimed_release_execution(state: AppState, execution_id: &str) -> anyhow::Result<()> { + let execution = match state.store.get_release_execution(execution_id).await? { + Some(execution) => execution, + None => return Ok(()), + }; + + match execution.status { + ReleaseExecutionStatus::RollbackInProgress => { + let ctx = build_rollback_context(state, execution).await?; + run_rollback_deployment(ctx).await; } - Some(RolloutStrategyKind::PercentageBatch) => { - let pct = strategy.batch_percentage.unwrap_or(25).clamp(1, 100) as usize; - let sz = ((total * pct) as f64 / 100.0).ceil() as usize; - sz.max(1).min(total) + ReleaseExecutionStatus::Preparing + | ReleaseExecutionStatus::WaitingStart + | ReleaseExecutionStatus::RollingOut => { + if execution.status == ReleaseExecutionStatus::WaitingStart + && !wait_until_next_batch_due(&state, execution_id, execution.next_batch_at).await? + { + return Ok(()); + } + let ctx = build_rolling_context(state, execution).await?; + run_rolling_deployment(ctx).await; } - // AllAtOnce or unset: single batch - _ => total, + _ => {} } + + Ok(()) } -/// Interval between batches in seconds (0 = no wait). -fn batch_interval_secs(strategy: &RolloutStrategy) -> u64 { - match strategy.kind { - Some(RolloutStrategyKind::TimeIntervalBatch) - | Some(RolloutStrategyKind::FixedBatch) - | Some(RolloutStrategyKind::PercentageBatch) => { - strategy.batch_interval_seconds.unwrap_or(0).max(0) as u64 +async fn wait_until_next_batch_due( + state: &AppState, + execution_id: &str, + next_batch_at: Option>, +) -> anyhow::Result { + let Some(next_batch_at) = next_batch_at else { + return Ok(true); + }; + + loop { + match state.store.get_release_execution(execution_id).await? { + Some(execution) + if matches!( + execution.status, + ReleaseExecutionStatus::Failed + | ReleaseExecutionStatus::Completed + | ReleaseExecutionStatus::RollbackFailed + ) => + { + return Ok(false); + } + Some(execution) if execution.status == ReleaseExecutionStatus::Paused => { + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + Some(_) => {} + None => return Ok(false), } - _ => 0, + + let now = Utc::now(); + if now >= next_batch_at { + return Ok(true); + } + let sleep_for = (next_batch_at - now) + .to_std() + .unwrap_or_else(|_| Duration::from_secs(0)) + .min(Duration::from_secs(1)); + tokio::time::sleep(sleep_for).await; } } -async fn run_rolling_deployment(ctx: RollingDeploymentContext) { - let RollingDeploymentContext { +async fn build_rolling_context( + state: AppState, + mut execution: ReleaseExecution, +) -> anyhow::Result { + let release = state + .store + .get_release_request_by_id(&execution.request_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Release request not found"))?; + let env = state + .store + .get_environment(&release.project_id, &release.environment_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Environment not found"))?; + + execution.instances.sort_by(|left, right| { + left.batch_index + .cmp(&right.batch_index) + .then_with(|| left.instance_name.cmp(&right.instance_name)) + }); + + let mut bound_servers = Vec::with_capacity(execution.instances.len()); + for instance in &execution.instances { + let server = state + .store + .get_server(&instance.instance_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Bound server {} not found", instance.instance_id))?; + bound_servers.push(server); + } + + let draft = if let Some(snapshot) = release.request_snapshot.target_ruleset_snapshot.clone() { + snapshot + } else { + state + .store + .get_draft_ruleset(&release.project_id, &release.ruleset_name) + .await? + .ok_or_else(|| anyhow::anyhow!("Draft ruleset not found"))? + .draft + }; + + let deployer = state.store.get_user(&release.created_by).await?; + + Ok(RollingDeploymentContext { state, - execution_id, - org_id, - project_id, - release_id, - ruleset_name, - version, + execution_id: execution.id, + org_id: release.org_id, + project_id: release.project_id, + release_id: release.id, + ruleset_name: release.ruleset_name, + version: release.version, + env, + instances: execution.instances, + bound_servers, + draft, + strategy: execution.strategy, + deployed_by: release.created_by, + deployer_email: deployer.as_ref().map(|user| user.email.clone()), + deployer_display_name: deployer.as_ref().map(|user| user.display_name.clone()), + auto_rollback: release.request_snapshot.rollback_policy.auto_rollback, + rollback_version: release + .version_diff + .rollback_version + .clone() + .or_else(|| release.rollback_version.clone()), + release_note: release.release_note, + }) +} + +async fn build_rollback_context( + state: AppState, + mut execution: ReleaseExecution, +) -> anyhow::Result { + let release = state + .store + .get_release_request_by_id(&execution.request_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Release request not found"))?; + let env = state + .store + .get_environment(&release.project_id, &release.environment_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Environment not found"))?; + let rollback_version = execution + .instances + .first() + .map(|instance| instance.target_version.clone()) + .or_else(|| release.version_diff.rollback_version.clone()) + .or_else(|| release.rollback_version.clone()) + .ok_or_else(|| anyhow::anyhow!("Release request has no rollback version"))?; + let rollback_deployment = state + .store + .list_deployments(&release.project_id, Some(&release.ruleset_name), 50) + .await? + .into_iter() + .find(|deployment| { + deployment.environment_id == release.environment_id + && deployment.version == rollback_version + && deployment.status == DeploymentStatus::Success + }) + .ok_or_else(|| anyhow::anyhow!("Rollback deployment snapshot not found"))?; + + execution.instances.sort_by(|left, right| { + left.batch_index + .cmp(&right.batch_index) + .then_with(|| left.instance_name.cmp(&right.instance_name)) + }); + let release_request_id = execution.request_id.clone(); + + Ok(RollbackDeploymentContext { + state, + execution_id: execution.id, + org_id: release.org_id, + project_id: release.project_id, + release_id: release.id, + release_status_before: ReleaseRequestStatus::Executing, + ruleset_name: release.ruleset_name, + rollback_version, + env, + instances: execution.instances, + snapshot: rollback_deployment.snapshot, + strategy: execution.strategy, + actor: system_history_actor("release_worker"), + release_note: Some(format!("Rollback for release {}", release_request_id)), + }) +} + +/// Compute per-batch instance count from strategy. +fn compute_batch_size(strategy: &RolloutStrategy, total: usize) -> usize { + if total == 0 { + return 1; + } + match strategy.kind { + Some(RolloutStrategyKind::FixedBatch) => { + let sz = strategy.batch_size.unwrap_or(1).max(1) as usize; + sz.min(total) + } + Some(RolloutStrategyKind::TimeIntervalBatch) => { + let sz = strategy.batch_size.unwrap_or(1).max(1) as usize; + sz.min(total) + } + Some(RolloutStrategyKind::PercentageBatch) => { + let pct = strategy.batch_percentage.unwrap_or(25).clamp(1, 100) as usize; + let sz = ((total * pct) as f64 / 100.0).ceil() as usize; + sz.max(1).min(total) + } + // AllAtOnce or unset: single batch + _ => total, + } +} + +/// Interval between batches in seconds (0 = no wait). +fn batch_interval_secs(strategy: &RolloutStrategy) -> u64 { + match strategy.kind { + Some(RolloutStrategyKind::TimeIntervalBatch) + | Some(RolloutStrategyKind::FixedBatch) + | Some(RolloutStrategyKind::PercentageBatch) => { + strategy.batch_interval_seconds.unwrap_or(0).max(0) as u64 + } + _ => 0, + } +} + +async fn run_rolling_deployment(ctx: RollingDeploymentContext) { + let RollingDeploymentContext { + state, + execution_id, + org_id, + project_id, + release_id, + ruleset_name, + version, env, instances, bound_servers, @@ -352,14 +895,73 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { // Pair each instance with its server let pairs: Vec<_> = instances.into_iter().zip(bound_servers).collect(); let total_batches = pairs.len().div_ceil(batch_size); + let system_actor = system_history_actor("release_rollout_worker"); let mut failed = false; let mut terminal_batch = 0; + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Execution, + "execution_started", + &system_actor, + None, + None, + serde_json::json!({ + "ruleset_name": ruleset_name, + "version": version, + "environment_name": env.name, + "total_batches": total_batches, + "total_instances": pairs.len(), + }), + ) + .await; + 'batches: for (batch_idx, batch) in pairs.chunks(batch_size).enumerate() { let batch_num = batch_idx + 1; terminal_batch = batch_num as i32; let batch_start = std::time::Instant::now(); + let active_batch = batch + .iter() + .filter(|(inst, _)| !instance_is_terminal(&inst.status)) + .cloned() + .collect::>(); + + if active_batch.is_empty() { + continue; + } + + if let Err(e) = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Batch, + "batch_dispatch_started", + &system_actor, + None, + None, + serde_json::json!({ + "batch_index": batch_num, + "total_batches": total_batches, + "target_server_ids": batch + .iter() + .filter(|(inst, _)| !instance_is_terminal(&inst.status)) + .map(|(_, server)| server.id.clone()) + .collect::>(), + }), + ) + .await + { + error!( + execution_id, + batch = batch_num, + "Failed to append batch history: {e}" + ); + } // Honour pause: spin-wait until resumed or terminal #[allow(clippy::while_let_loop)] @@ -367,6 +969,10 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { match state.store.get_release_execution(&execution_id).await { Ok(Some(exec)) => match exec.status { ReleaseExecutionStatus::Paused => { + let _ = state + .store + .set_release_execution_next_batch_at(&execution_id, None) + .await; tokio::time::sleep(Duration::from_secs(3)).await; continue; } @@ -377,30 +983,62 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { } } - // Mark batch instances as Updating - for (inst, _) in batch { - if let Err(e) = state - .store - .update_release_execution_instance( - &inst.id, - ReleaseInstanceStatus::Updating, - Some("Dispatching ruleset to server"), - None, - ) - .await + // Mark batch instances as being dispatched now. + for (inst, _) in &active_batch { + if let Err(e) = update_instance_schedule_with_history( + &state, + &release_id, + &execution_id, + &inst.id, + ReleaseInstanceStatus::Dispatching, + None, + Some("Dispatching ruleset to server"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "batch_dispatch_started", + "batch_index": batch_num, + }), + ) + .await { - error!(execution_id, instance_id = %inst.id, "Failed to update instance to Updating: {e}"); + error!(execution_id, instance_id = %inst.id, "Failed to update instance to Dispatching: {e}"); } - } - - if let Err(e) = state - .store - .update_release_execution_status( + if let Err(e) = update_instance_status_with_history( + &state, + &release_id, &execution_id, - ReleaseExecutionStatus::RollingOut, - Some(batch_num as i32), + &inst.id, + ReleaseInstanceStatus::Updating, + Some("Dispatching ruleset to server"), + None, + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "batch_dispatch_started", + "batch_index": batch_num, + }), ) .await + { + error!(execution_id, instance_id = %inst.id, "Failed to update instance to Updating: {e}"); + } + } + + if let Err(e) = update_release_execution_status_with_history( + &state, + &release_id, + &execution_id, + None, + ReleaseExecutionStatus::RollingOut, + Some(batch_num as i32), + &system_actor, + serde_json::json!({ + "batch_index": batch_num, + "total_batches": total_batches, + }), + ) + .await { error!( execution_id, @@ -409,8 +1047,12 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { failed = true; break 'batches; } + let _ = state + .store + .set_release_execution_next_batch_at(&execution_id, None) + .await; - let target_server_ids = batch + let target_server_ids = active_batch .iter() .map(|(_, server)| server.id.clone()) .collect::>(); @@ -428,16 +1070,24 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { match push_result { Ok(()) => { - for (inst, server) in batch { - let _ = state - .store - .update_release_execution_instance( - &inst.id, - ReleaseInstanceStatus::Updating, - Some("Ruleset published to NATS; waiting for server ack"), - Some("publish_sent"), - ) - .await; + for (inst, server) in &active_batch { + let _ = update_instance_status_with_history( + &state, + &release_id, + &execution_id, + &inst.id, + ReleaseInstanceStatus::Updating, + Some("Ruleset published to NATS; waiting for server ack"), + Some("publish_sent"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "publish_sent", + "server_name": server.name, + "batch_index": batch_num, + }), + ) + .await; info!( execution_id, server = %server.name, @@ -456,7 +1106,25 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { { Ok(()) => { let duration_ms = batch_start.elapsed().as_millis() as u64; - for (inst, _) in batch { + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Batch, + "batch_feedback_succeeded", + &system_actor, + None, + None, + serde_json::json!({ + "batch_index": batch_num, + "total_batches": total_batches, + "duration_ms": duration_ms, + "target_server_ids": target_server_ids, + }), + ) + .await; + for (inst, _) in &active_batch { let summary = serde_json::json!({ "batch_index": batch_num, "total_batches": total_batches, @@ -472,24 +1140,49 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { Err(err) => { let msg = err.to_string(); error!(execution_id, "Batch feedback failed: {msg}"); + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Batch, + "batch_feedback_failed", + &system_actor, + None, + None, + serde_json::json!({ + "batch_index": batch_num, + "error": msg, + "target_server_ids": target_server_ids, + }), + ) + .await; for server_id in &target_server_ids { if let Ok(Some(instance)) = state .store .find_release_execution_instance_by_target(&execution_id, server_id) .await { - if instance.status == ReleaseInstanceStatus::Updating + if instance.status == ReleaseInstanceStatus::Dispatching + || instance.status == ReleaseInstanceStatus::Updating || instance.status == ReleaseInstanceStatus::Pending { - let _ = state - .store - .update_release_execution_instance( - &instance.id, - ReleaseInstanceStatus::Failed, - Some(&msg), - Some("release_ack_timeout"), - ) - .await; + let _ = update_instance_status_with_history( + &state, + &release_id, + &execution_id, + &instance.id, + ReleaseInstanceStatus::Failed, + Some(&msg), + Some("release_ack_timeout"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "release_ack_timeout", + "batch_index": batch_num, + }), + ) + .await; } } } @@ -501,16 +1194,39 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { Err(err) => { let msg = err.to_string(); error!(execution_id, "NATS publish failed: {msg}"); - for (inst, _) in batch { - let _ = state - .store - .update_release_execution_instance( - &inst.id, - ReleaseInstanceStatus::Failed, - Some(&msg), - Some("publish_failed"), - ) - .await; + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Batch, + "batch_publish_failed", + &system_actor, + None, + None, + serde_json::json!({ + "batch_index": batch_num, + "error": msg, + }), + ) + .await; + for (inst, _) in &active_batch { + let _ = update_instance_status_with_history( + &state, + &release_id, + &execution_id, + &inst.id, + ReleaseInstanceStatus::Failed, + Some(&msg), + Some("publish_failed"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "publish_failed", + "batch_index": batch_num, + }), + ) + .await; } failed = true; break 'batches; @@ -519,6 +1235,9 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { // Wait between batches (skip after last batch) if batch_num < total_batches && interval_secs > 0 { + let next_start = (batch_idx + 1) * batch_size; + let next_end = ((batch_idx + 2) * batch_size).min(pairs.len()); + let next_batch = &pairs[next_start..next_end]; info!( execution_id, "Batch {}/{} complete — waiting {}s before next batch", @@ -526,26 +1245,55 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { total_batches, interval_secs ); - tokio::time::sleep(Duration::from_secs(interval_secs)).await; + if !wait_for_next_batch_window( + &state, + &release_id, + &execution_id, + batch_num as i32, + next_batch, + interval_secs, + ) + .await + { + failed = true; + break 'batches; + } } } if failed { - if let Err(e) = state + let _ = state .store - .update_release_execution_status( - &execution_id, - ReleaseExecutionStatus::Failed, - Some(terminal_batch), - ) - .await + .set_release_execution_next_batch_at(&execution_id, None) + .await; + if let Err(e) = update_release_execution_status_with_history( + &state, + &release_id, + &execution_id, + None, + ReleaseExecutionStatus::Failed, + Some(terminal_batch), + &system_actor, + serde_json::json!({ + "terminal_batch": terminal_batch, + }), + ) + .await { error!(execution_id, "Failed to mark execution Failed: {e}"); } - if let Err(e) = state - .store - .set_release_request_status(&release_id, ReleaseRequestStatus::Failed) - .await + if let Err(e) = set_release_request_status_with_history( + &state, + &release_id, + ReleaseRequestStatus::Executing, + ReleaseRequestStatus::Failed, + &system_actor, + serde_json::json!({ + "reason": "execution_failed", + "terminal_batch": terminal_batch, + }), + ) + .await { error!(execution_id, "Failed to mark release request Failed: {e}"); } @@ -571,21 +1319,34 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { } // All batches succeeded - if let Err(e) = state - .store - .update_release_execution_status( - &execution_id, - ReleaseExecutionStatus::Completed, - Some(total_batches as i32), - ) - .await + if let Err(e) = update_release_execution_status_with_history( + &state, + &release_id, + &execution_id, + None, + ReleaseExecutionStatus::Completed, + Some(total_batches as i32), + &system_actor, + serde_json::json!({ + "total_batches": total_batches, + }), + ) + .await { error!(execution_id, "Failed to mark execution Completed: {e}"); } - if let Err(e) = state - .store - .set_release_request_status(&release_id, ReleaseRequestStatus::Completed) - .await + if let Err(e) = set_release_request_status_with_history( + &state, + &release_id, + ReleaseRequestStatus::Executing, + ReleaseRequestStatus::Completed, + &system_actor, + serde_json::json!({ + "reason": "execution_completed", + "total_batches": total_batches, + }), + ) + .await { error!( execution_id, @@ -610,7 +1371,774 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { release_note: release_note.clone(), snapshot: draft.clone(), deployed_at: Utc::now(), - deployed_by: Some(deployed_by.clone()), + deployed_by: Some(deployed_by.clone()), + status: DeploymentStatus::Success, + }; + let _ = state.store.create_deployment(&deployment).await; + + let entry = RulesetHistoryEntry { + id: Uuid::new_v4().to_string(), + ruleset_name: ruleset_name.clone(), + action: format!("released to {}", env.name), + source: RulesetHistorySource::Publish, + created_at: Utc::now(), + author_id: deployed_by.clone(), + author_email: deployer_email.unwrap_or_default(), + author_display_name: deployer_display_name.unwrap_or_default(), + snapshot: draft, + }; + let _ = state + .store + .append_ruleset_history(&org_id, &project_id, &ruleset_name, &[entry]) + .await; + + info!( + execution_id, + "Rolling deployment completed ({} batch(es))", total_batches + ); +} + +async fn wait_for_next_batch_window( + state: &AppState, + release_id: &str, + execution_id: &str, + current_batch: i32, + next_batch: &[(ReleaseExecutionInstance, crate::models::ServerNode)], + interval_secs: u64, +) -> bool { + if next_batch.is_empty() || interval_secs == 0 { + return true; + } + + let batch_index = next_batch[0].0.batch_index; + let mut remaining = Duration::from_secs(interval_secs); + let mut next_batch_at = Utc::now() + chrono::Duration::seconds(interval_secs as i64); + let system_actor = system_history_actor("release_rollout_worker"); + + if update_release_execution_status_with_history( + state, + release_id, + execution_id, + None, + ReleaseExecutionStatus::WaitingStart, + Some(current_batch), + &system_actor, + serde_json::json!({ + "current_batch": current_batch, + "next_batch_index": batch_index, + "wait_seconds": interval_secs, + }), + ) + .await + .is_err() + { + return false; + } + let _ = state + .store + .set_release_execution_next_batch_at(execution_id, Some(next_batch_at)) + .await; + let _ = append_release_history( + state, + release_id, + Some(execution_id), + None, + ReleaseHistoryScope::Batch, + "batch_wait_started", + &system_actor, + None, + None, + serde_json::json!({ + "current_batch": current_batch, + "next_batch_index": batch_index, + "wait_seconds": interval_secs, + "next_batch_at": next_batch_at.to_rfc3339(), + }), + ) + .await; + let _ = update_batch_schedule_with_history( + state, + release_id, + execution_id, + batch_index, + ReleaseInstanceStatus::Scheduled, + Some(next_batch_at), + Some("Scheduled for next rollout window"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "batch_wait_started", + }), + ) + .await; + + loop { + match state.store.get_release_execution(execution_id).await { + Ok(Some(exec)) => match exec.status { + ReleaseExecutionStatus::Paused => { + let _ = state + .store + .set_release_execution_next_batch_at(execution_id, None) + .await; + let _ = update_batch_schedule_with_history( + state, + release_id, + execution_id, + batch_index, + ReleaseInstanceStatus::WaitingBatch, + None, + Some(&format!("Paused before batch {}", batch_index)), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "execution_paused", + }), + ) + .await; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + match state.store.get_release_execution(execution_id).await { + Ok(Some(paused_exec)) + if paused_exec.status == ReleaseExecutionStatus::Paused => + { + continue; + } + Ok(Some(paused_exec)) + if paused_exec.status == ReleaseExecutionStatus::Failed => + { + return false; + } + Ok(Some(_)) => break, + _ => return false, + } + } + + next_batch_at = Utc::now() + + chrono::Duration::from_std(remaining) + .unwrap_or_else(|_| chrono::Duration::seconds(0)); + let _ = update_release_execution_status_with_history( + state, + release_id, + execution_id, + None, + ReleaseExecutionStatus::WaitingStart, + Some(current_batch), + &system_actor, + serde_json::json!({ + "current_batch": current_batch, + "next_batch_index": batch_index, + "remaining_wait_seconds": remaining.as_secs(), + }), + ) + .await; + let _ = state + .store + .set_release_execution_next_batch_at(execution_id, Some(next_batch_at)) + .await; + let _ = append_release_history( + state, + release_id, + Some(execution_id), + None, + ReleaseHistoryScope::Batch, + "batch_wait_resumed", + &system_actor, + None, + None, + serde_json::json!({ + "current_batch": current_batch, + "next_batch_index": batch_index, + "next_batch_at": next_batch_at.to_rfc3339(), + "remaining_wait_seconds": remaining.as_secs(), + }), + ) + .await; + let _ = update_batch_schedule_with_history( + state, + release_id, + execution_id, + batch_index, + ReleaseInstanceStatus::Scheduled, + Some(next_batch_at), + Some("Scheduled for next rollout window"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "batch_wait_resumed", + }), + ) + .await; + } + ReleaseExecutionStatus::Failed => return false, + _ => {} + }, + _ => return false, + } + + if remaining.is_zero() { + break; + } + + let tick = remaining.min(Duration::from_secs(1)); + tokio::time::sleep(tick).await; + remaining = remaining.saturating_sub(tick); + } + + let _ = state + .store + .set_release_execution_next_batch_at(execution_id, None) + .await; + true +} + +struct RollbackDeploymentContext { + state: AppState, + execution_id: String, + org_id: String, + project_id: String, + release_id: String, + release_status_before: ReleaseRequestStatus, + ruleset_name: String, + rollback_version: String, + env: crate::models::ProjectEnvironment, + instances: Vec, + snapshot: JsonValue, + strategy: RolloutStrategy, + actor: ReleaseHistoryActor, + release_note: Option, +} + +async fn wait_for_rollback_batch_window( + state: &AppState, + release_id: &str, + execution_id: &str, + next_batch: &[ReleaseExecutionInstance], + interval_secs: u64, +) -> bool { + if next_batch.is_empty() || interval_secs == 0 { + return true; + } + + let batch_index = next_batch[0].batch_index; + let next_batch_at = Utc::now() + chrono::Duration::seconds(interval_secs as i64); + let system_actor = system_history_actor("release_rollback_worker"); + + let _ = state + .store + .set_release_execution_next_batch_at(execution_id, Some(next_batch_at)) + .await; + let _ = append_release_history( + state, + release_id, + Some(execution_id), + None, + ReleaseHistoryScope::Batch, + "rollback_batch_wait_started", + &system_actor, + None, + None, + serde_json::json!({ + "next_batch_index": batch_index, + "wait_seconds": interval_secs, + "next_batch_at": next_batch_at.to_rfc3339(), + }), + ) + .await; + let _ = update_batch_schedule_with_history( + state, + release_id, + execution_id, + batch_index, + ReleaseInstanceStatus::Scheduled, + Some(next_batch_at), + Some("Scheduled for next rollback window"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "rollback_batch_wait_started", + }), + ) + .await; + + tokio::time::sleep(Duration::from_secs(interval_secs)).await; + + let _ = state + .store + .set_release_execution_next_batch_at(execution_id, None) + .await; + let _ = update_batch_schedule_with_history( + state, + release_id, + execution_id, + batch_index, + ReleaseInstanceStatus::Pending, + None, + Some("Ready for rollback dispatch"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "rollback_batch_wait_finished", + }), + ) + .await; + let _ = append_release_history( + state, + release_id, + Some(execution_id), + None, + ReleaseHistoryScope::Batch, + "rollback_batch_wait_finished", + &system_actor, + None, + None, + serde_json::json!({ + "next_batch_index": batch_index, + }), + ) + .await; + + true +} + +async fn run_rollback_deployment(ctx: RollbackDeploymentContext) { + let RollbackDeploymentContext { + state, + execution_id, + org_id, + project_id, + release_id, + release_status_before, + ruleset_name, + rollback_version, + env, + mut instances, + snapshot, + strategy: _strategy, + actor, + release_note, + } = ctx; + + instances.sort_by(|left, right| { + left.batch_index + .cmp(&right.batch_index) + .then_with(|| left.instance_name.cmp(&right.instance_name)) + }); + let interval_secs = ROLLBACK_BATCH_INTERVAL_SECS; + let total_batches = instances + .iter() + .map(|item| item.batch_index) + .max() + .unwrap_or(1); + let system_actor = system_history_actor("release_rollback_worker"); + let mut failed = false; + let mut terminal_batch = 0; + + for batch_index in 1..=total_batches { + let batch_instances = instances + .iter() + .filter(|instance| { + instance.batch_index == batch_index && !instance_is_terminal(&instance.status) + }) + .cloned() + .collect::>(); + + if batch_instances.is_empty() { + continue; + } + + terminal_batch = batch_index; + let batch_start = std::time::Instant::now(); + let target_server_ids = batch_instances + .iter() + .map(|instance| instance.instance_id.clone()) + .collect::>(); + + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Batch, + "rollback_batch_dispatch_started", + &system_actor, + None, + None, + serde_json::json!({ + "batch_index": batch_index, + "total_batches": total_batches, + "target_server_ids": target_server_ids, + }), + ) + .await; + + for instance in &batch_instances { + let _ = update_instance_schedule_with_history( + &state, + &release_id, + &execution_id, + &instance.id, + ReleaseInstanceStatus::Dispatching, + None, + Some("Dispatching rollback snapshot to server"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "rollback_batch_dispatch_started", + "batch_index": batch_index, + }), + ) + .await; + let _ = update_instance_status_with_history( + &state, + &release_id, + &execution_id, + &instance.id, + ReleaseInstanceStatus::Updating, + Some("Dispatching rollback snapshot to server"), + None, + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "rollback_batch_dispatch_started", + "batch_index": batch_index, + }), + ) + .await; + } + + let _ = state + .store + .set_release_execution_next_batch_at(&execution_id, None) + .await; + if let Err(err) = update_release_execution_status_with_history( + &state, + &release_id, + &execution_id, + None, + ReleaseExecutionStatus::RollbackInProgress, + Some(batch_index), + &system_actor, + serde_json::json!({ + "reason": "rollback_batch_dispatch_started", + "batch_index": batch_index, + "total_batches": total_batches, + "rollback_version": rollback_version, + }), + ) + .await + { + error!( + execution_id, + batch = batch_index, + "Failed to update rollback status: {err}" + ); + failed = true; + break; + } + + match publish_release_via_nats( + &state, + &env, + &project_id, + &ruleset_name, + &snapshot, + &rollback_version, + Some(&execution_id), + Some(target_server_ids.as_slice()), + ) + .await + { + Ok(()) => { + for instance in &batch_instances { + let _ = update_instance_status_with_history( + &state, + &release_id, + &execution_id, + &instance.id, + ReleaseInstanceStatus::Updating, + Some("Rollback snapshot published to NATS; waiting for server ack"), + Some("rollback_publish_sent"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "rollback_publish_sent", + "batch_index": batch_index, + }), + ) + .await; + } + + match wait_for_batch_feedback( + &state, + &execution_id, + &target_server_ids, + Duration::from_secs(RELEASE_ACK_TIMEOUT_SECS), + ) + .await + { + Ok(()) => { + let duration_ms = batch_start.elapsed().as_millis() as u64; + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Batch, + "rollback_batch_succeeded", + &system_actor, + None, + None, + serde_json::json!({ + "batch_index": batch_index, + "total_batches": total_batches, + "duration_ms": duration_ms, + "target_server_ids": target_server_ids, + }), + ) + .await; + } + Err(err) => { + let msg = err.to_string(); + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Batch, + "rollback_batch_failed", + &system_actor, + None, + None, + serde_json::json!({ + "batch_index": batch_index, + "error": msg, + "target_server_ids": target_server_ids, + }), + ) + .await; + for server_id in &target_server_ids { + if let Ok(Some(instance)) = state + .store + .find_release_execution_instance_by_target(&execution_id, server_id) + .await + { + if matches!( + instance.status, + ReleaseInstanceStatus::Dispatching + | ReleaseInstanceStatus::Updating + | ReleaseInstanceStatus::Pending + ) { + let _ = update_instance_status_with_history( + &state, + &release_id, + &execution_id, + &instance.id, + ReleaseInstanceStatus::Failed, + Some(&msg), + Some("rollback_ack_timeout"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "rollback_ack_timeout", + "batch_index": batch_index, + }), + ) + .await; + } + } + } + failed = true; + break; + } + } + } + Err(err) => { + let msg = err.to_string(); + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Batch, + "rollback_batch_failed", + &system_actor, + None, + None, + serde_json::json!({ + "batch_index": batch_index, + "error": msg, + "target_server_ids": target_server_ids, + }), + ) + .await; + for instance in &batch_instances { + let _ = update_instance_status_with_history( + &state, + &release_id, + &execution_id, + &instance.id, + ReleaseInstanceStatus::Failed, + Some(&msg), + Some("rollback_publish_failed"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "rollback_publish_failed", + "batch_index": batch_index, + }), + ) + .await; + } + failed = true; + break; + } + } + + if batch_index < total_batches + && interval_secs > 0 + && !wait_for_rollback_batch_window( + &state, + &release_id, + &execution_id, + &instances + .iter() + .filter(|instance| instance.batch_index == batch_index + 1) + .cloned() + .collect::>(), + interval_secs, + ) + .await + { + failed = true; + break; + } + } + + let _ = state + .store + .set_release_execution_next_batch_at(&execution_id, None) + .await; + + if failed { + if let Ok(remaining_instances) = state + .store + .list_release_execution_instances(&execution_id) + .await + { + for instance in remaining_instances + .into_iter() + .filter(|instance| !instance_is_terminal(&instance.status)) + { + let _ = update_instance_status_with_history( + &state, + &release_id, + &execution_id, + &instance.id, + ReleaseInstanceStatus::Skipped, + Some("Rollback aborted before this instance could be dispatched"), + Some("rollback_aborted"), + &system_actor, + "instance_status_changed", + serde_json::json!({ + "reason": "rollback_aborted", + "terminal_batch": terminal_batch, + "rollback_version": rollback_version, + }), + ) + .await; + } + } + let _ = update_release_execution_status_with_history( + &state, + &release_id, + &execution_id, + None, + ReleaseExecutionStatus::RollbackFailed, + Some(terminal_batch), + &system_actor, + serde_json::json!({ + "reason": "rollback_failed", + "terminal_batch": terminal_batch, + "rollback_version": rollback_version, + }), + ) + .await; + let _ = set_release_request_status_with_history( + &state, + &release_id, + ReleaseRequestStatus::Executing, + ReleaseRequestStatus::RollbackFailed, + &actor, + serde_json::json!({ + "reason": "rollback_failed", + "terminal_batch": terminal_batch, + "rollback_version": rollback_version, + }), + ) + .await; + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Rollback, + "rollback_failed", + &actor, + None, + None, + serde_json::json!({ + "rollback_version": rollback_version, + "terminal_batch": terminal_batch, + }), + ) + .await; + return; + } + + let _ = update_release_execution_status_with_history( + &state, + &release_id, + &execution_id, + None, + ReleaseExecutionStatus::Completed, + Some(total_batches), + &actor, + serde_json::json!({ + "reason": "rollback_completed", + "rollback_version": rollback_version, + "total_batches": total_batches, + }), + ) + .await; + let _ = set_release_request_status_with_history( + &state, + &release_id, + ReleaseRequestStatus::Executing, + ReleaseRequestStatus::RolledBack, + &actor, + serde_json::json!({ + "reason": "rollback_completed", + "rollback_version": rollback_version, + "previous_request_status": release_status_before.to_string(), + }), + ) + .await; + let _ = state + .store + .mark_ruleset_published(&project_id, &ruleset_name, &rollback_version) + .await; + + let deployment = RulesetDeployment { + id: Uuid::new_v4().to_string(), + project_id: project_id.clone(), + environment_id: env.id.clone(), + environment_name: Some(env.name.clone()), + ruleset_name: ruleset_name.clone(), + version: rollback_version.clone(), + release_note, + snapshot: snapshot.clone(), + deployed_at: Utc::now(), + deployed_by: actor.actor_id.clone(), status: DeploymentStatus::Success, }; let _ = state.store.create_deployment(&deployment).await; @@ -618,23 +2146,35 @@ async fn run_rolling_deployment(ctx: RollingDeploymentContext) { let entry = RulesetHistoryEntry { id: Uuid::new_v4().to_string(), ruleset_name: ruleset_name.clone(), - action: format!("released to {}", env.name), + action: format!("rolled back in {}", env.name), source: RulesetHistorySource::Publish, created_at: Utc::now(), - author_id: deployed_by.clone(), - author_email: deployer_email.unwrap_or_default(), - author_display_name: deployer_display_name.unwrap_or_default(), - snapshot: draft, + author_id: actor.actor_id.clone().unwrap_or_default(), + author_email: actor.actor_email.clone().unwrap_or_default(), + author_display_name: actor.actor_name.clone().unwrap_or_default(), + snapshot, }; let _ = state .store .append_ruleset_history(&org_id, &project_id, &ruleset_name, &[entry]) .await; - info!( - execution_id, - "Rolling deployment completed ({} batch(es))", total_batches - ); + let _ = append_release_history( + &state, + &release_id, + Some(&execution_id), + None, + ReleaseHistoryScope::Rollback, + "rollback_completed", + &actor, + None, + None, + serde_json::json!({ + "rollback_version": rollback_version, + "total_batches": total_batches, + }), + ) + .await; } #[allow(clippy::too_many_arguments)] @@ -648,6 +2188,17 @@ async fn trigger_auto_rollback( rollback_version: &str, _deployed_by: &str, ) -> anyhow::Result<()> { + let actor = system_history_actor("release_auto_rollback"); + let execution = state + .store + .get_release_execution(execution_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Release execution not found"))?; + let release = state + .store + .get_release_request_by_id(release_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Release request not found"))?; let rollback_deployment = state .store .list_deployments(project_id, Some(ruleset_name), 50) @@ -659,7 +2210,7 @@ async fn trigger_auto_rollback( && d.status == DeploymentStatus::Success }); - let Some(rb) = rollback_deployment else { + let Some(_rollback_deployment) = rollback_deployment else { warn!( execution_id, "Auto-rollback: snapshot not found for version {}", rollback_version @@ -667,58 +2218,114 @@ async fn trigger_auto_rollback( return Err(anyhow::anyhow!("Rollback snapshot not found")); }; + let _ = append_release_history( + state, + release_id, + Some(execution_id), + None, + ReleaseHistoryScope::Rollback, + "auto_rollback_started", + &actor, + None, + None, + serde_json::json!({ + "rollback_version": rollback_version, + }), + ) + .await; + let _ = set_release_request_status_with_history( + state, + release_id, + ReleaseRequestStatus::Failed, + ReleaseRequestStatus::Executing, + &actor, + serde_json::json!({ + "reason": "auto_rollback_started", + "rollback_version": rollback_version, + }), + ) + .await; + let _ = update_release_execution_status_with_history( + state, + release_id, + execution_id, + None, + ReleaseExecutionStatus::RollbackInProgress, + Some(0), + &actor, + serde_json::json!({ + "reason": "auto_rollback_started", + "rollback_version": rollback_version, + }), + ) + .await; + + let mut instances = execution.instances.clone(); + instances.sort_by(|left, right| { + left.batch_index + .cmp(&right.batch_index) + .then_with(|| left.instance_name.cmp(&right.instance_name)) + }); + for instance in &instances { + let planned_status = if instance.batch_index == 1 { + ReleaseInstanceStatus::Pending + } else { + ReleaseInstanceStatus::WaitingBatch + }; + validate_release_instance_transition(&instance.status, &planned_status)?; + state + .store + .update_release_execution_instance_plan( + &instance.id, + rollback_version, + planned_status, + None, + Some(if instance.batch_index == 1 { + "Queued for immediate rollback" + } else { + "Waiting for rollback batch" + }), + ) + .await?; + } + let _ = state .store - .update_release_execution_status( + .create_release_execution_event( + &Uuid::new_v4().to_string(), execution_id, - ReleaseExecutionStatus::RollbackInProgress, None, + "rollback_started", + serde_json::json!({ + "release_id": release.id, + "rollback_version": rollback_version, + "requested_by": actor.actor_id.clone(), + "mode": "auto", + }), ) .await; - let push_result = publish_release_via_nats( + let _ = append_release_history( state, - env, - project_id, - ruleset_name, - &rb.snapshot, - rollback_version, + release_id, Some(execution_id), None, + ReleaseHistoryScope::Rollback, + "auto_rollback_scheduled", + &actor, + None, + None, + serde_json::json!({ + "rollback_version": rollback_version, + "total_batches": execution.total_batches, + }), ) .await; - match push_result { - Ok(()) => { - let _ = state - .store - .mark_ruleset_published(project_id, ruleset_name, rollback_version) - .await; - let _ = state - .store - .update_release_execution_status( - execution_id, - ReleaseExecutionStatus::Completed, - None, - ) - .await; - let _ = state - .store - .set_release_request_status(release_id, ReleaseRequestStatus::RolledBack) - .await; - info!( - execution_id, - "Auto-rollback to {} succeeded", rollback_version - ); - } - Err(err) => { - warn!(execution_id, "Auto-rollback failed: {}", err); - let _ = state - .store - .update_release_execution_status(execution_id, ReleaseExecutionStatus::Failed, None) - .await; - } - } + info!( + execution_id, + "Auto-rollback to {} scheduled", rollback_version + ); Ok(()) } @@ -809,6 +2416,11 @@ pub async fn rollback_release_execution( .map_err(PlatformError::Internal)? .ok_or_else(|| PlatformError::not_found("Release execution not found"))?; + if release.status == ReleaseRequestStatus::RolledBack { + return Err(PlatformError::conflict( + "Rolled back release requests are already closed", + )); + } if execution.status == ReleaseExecutionStatus::RollbackInProgress { return Err(PlatformError::conflict( "Release execution is already rolling back", @@ -816,12 +2428,18 @@ pub async fn rollback_release_execution( } if execution.status != ReleaseExecutionStatus::Completed && execution.status != ReleaseExecutionStatus::Failed + && execution.status != ReleaseExecutionStatus::RollbackFailed && execution.status != ReleaseExecutionStatus::Paused { return Err(PlatformError::conflict( "Release execution cannot be rolled back from its current status", )); } + validate_release_execution_transition( + &execution.status, + &ReleaseExecutionStatus::RollbackInProgress, + ) + .map_err(|err| PlatformError::conflict(err.to_string()))?; let rollback_version = release .version_diff @@ -835,14 +2453,14 @@ pub async fn rollback_release_execution( )); } - let env = state + let _env = state .store .get_environment(&project_id, &release.environment_id) .await .map_err(PlatformError::Internal)? .ok_or_else(|| PlatformError::not_found("Environment not found"))?; - let rollback_deployment = state + let _rollback_deployment = state .store .list_deployments(&project_id, Some(&release.ruleset_name), 50) .await @@ -854,16 +2472,59 @@ pub async fn rollback_release_execution( && deployment.status == DeploymentStatus::Success }) .ok_or_else(|| PlatformError::not_found("Rollback deployment snapshot not found"))?; - - state + let executor = state .store - .update_release_execution_status( - &execution.id, - ReleaseExecutionStatus::RollbackInProgress, - Some(execution.current_batch), - ) + .get_user(&claims.sub) .await .map_err(PlatformError::Internal)?; + let actor = user_history_actor(&claims, executor.as_ref()); + + append_release_history( + &state, + &release.id, + Some(&execution.id), + None, + ReleaseHistoryScope::Rollback, + "rollback_requested", + &actor, + None, + None, + serde_json::json!({ + "rollback_version": rollback_version, + "requested_by": claims.sub, + }), + ) + .await + .map_err(PlatformError::Internal)?; + set_release_request_status_with_history( + &state, + &release.id, + release.status.clone(), + ReleaseRequestStatus::Executing, + &actor, + serde_json::json!({ + "reason": "rollback_requested", + "rollback_version": rollback_version, + "requested_by": claims.sub, + }), + ) + .await + .map_err(PlatformError::Internal)?; + update_release_execution_status_with_history( + &state, + &release.id, + &execution.id, + Some(execution.status.clone()), + ReleaseExecutionStatus::RollbackInProgress, + Some(0), + &actor, + serde_json::json!({ + "reason": "rollback_requested", + "rollback_version": rollback_version, + }), + ) + .await + .map_err(PlatformError::Internal)?; let _ = state .store .create_release_execution_event( @@ -878,133 +2539,76 @@ pub async fn rollback_release_execution( }), ) .await; + let _ = state + .store + .set_release_execution_next_batch_at(&execution.id, None) + .await; - for instance in &execution.instances { - let _ = state + let mut instances = execution.instances.clone(); + instances.sort_by(|left, right| { + left.batch_index + .cmp(&right.batch_index) + .then_with(|| left.instance_name.cmp(&right.instance_name)) + }); + for instance in &instances { + let planned_status = if instance.batch_index == 1 { + ReleaseInstanceStatus::Pending + } else { + ReleaseInstanceStatus::WaitingBatch + }; + validate_release_instance_transition(&instance.status, &planned_status) + .map_err(PlatformError::Internal)?; + state .store - .update_release_execution_instance( + .update_release_execution_instance_plan( &instance.id, - ReleaseInstanceStatus::Updating, - Some("Rolling back to previous deployment snapshot"), - Some("rollback_started"), + &rollback_version, + planned_status.clone(), + None, + Some(if instance.batch_index == 1 { + "Queued for immediate rollback" + } else { + "Waiting for rollback batch" + }), ) - .await; + .await + .map_err(PlatformError::Internal)?; + append_release_history( + &state, + &release.id, + Some(&execution.id), + Some(&instance.id), + ReleaseHistoryScope::Instance, + "instance_rollback_queued", + &actor, + Some(instance.status.to_string()), + Some(planned_status.to_string()), + serde_json::json!({ + "instance_name": instance.instance_name, + "target_instance_id": instance.instance_id, + "batch_index": instance.batch_index, + "rollback_version": rollback_version, + }), + ) + .await + .map_err(PlatformError::Internal)?; } - let publish_result = publish_release_via_nats( - &state, - &env, - &project_id, - &release.ruleset_name, - &rollback_deployment.snapshot, - &rollback_version, - Some(&execution.id), - None, - ) - .await; - - match publish_result { - Ok(()) => { - for instance in &execution.instances { - let _ = state - .store - .update_release_execution_instance( - &instance.id, - ReleaseInstanceStatus::RolledBack, - Some("Rollback snapshot pushed and acknowledged"), - Some("rollback_ack"), - ) - .await; - } - state - .store - .update_release_execution_status( - &execution.id, - ReleaseExecutionStatus::Completed, - Some(execution.total_batches), - ) - .await - .map_err(PlatformError::Internal)?; - state - .store - .set_release_request_status(&release.id, ReleaseRequestStatus::RolledBack) - .await - .map_err(PlatformError::Internal)?; - state - .store - .mark_ruleset_published(&project_id, &release.ruleset_name, &rollback_version) - .await - .map_err(PlatformError::Internal)?; - - let deployment = RulesetDeployment { - id: Uuid::new_v4().to_string(), - project_id: project_id.clone(), - environment_id: env.id.clone(), - environment_name: Some(env.name.clone()), - ruleset_name: release.ruleset_name.clone(), - version: rollback_version.clone(), - release_note: Some(format!("Rollback for release {}", release.id)), - snapshot: rollback_deployment.snapshot.clone(), - deployed_at: Utc::now(), - deployed_by: Some(claims.sub.clone()), - status: DeploymentStatus::Success, - }; - state - .store - .create_deployment(&deployment) - .await - .map_err(PlatformError::Internal)?; - let _ = state - .store - .create_release_execution_event( - &Uuid::new_v4().to_string(), - &execution.id, - None, - "rollback_completed", - serde_json::json!({ - "rollback_version": rollback_version, - "deployed_by": claims.sub, - }), - ) - .await; - } - Err(err) => { - for instance in &execution.instances { - let _ = state - .store - .update_release_execution_instance( - &instance.id, - ReleaseInstanceStatus::Failed, - Some(&err.to_string()), - Some("rollback_failed"), - ) - .await; - } - state - .store - .update_release_execution_status( - &execution.id, - ReleaseExecutionStatus::Failed, - Some(execution.current_batch), - ) - .await - .map_err(PlatformError::Internal)?; - let _ = state - .store - .create_release_execution_event( - &Uuid::new_v4().to_string(), - &execution.id, - None, - "rollback_failed", - serde_json::json!({ - "rollback_version": rollback_version, - "error": err.to_string(), - }), - ) - .await; - return Err(PlatformError::bad_request("Rollback publish failed")); - } - } + let _ = state + .store + .create_release_execution_event( + &Uuid::new_v4().to_string(), + &execution.id, + None, + "rollback_started", + serde_json::json!({ + "release_id": release.id, + "rollback_version": rollback_version, + "requested_by": claims.sub, + "mode": "manual", + }), + ) + .await; let updated = state .store @@ -1045,36 +2649,72 @@ async fn control_release_execution( .map_err(PlatformError::Internal)? .ok_or_else(|| PlatformError::not_found("Release execution not found"))?; - let is_valid = match target_status { - ReleaseExecutionStatus::Paused => matches!( - execution.status, - ReleaseExecutionStatus::Preparing - | ReleaseExecutionStatus::WaitingStart - | ReleaseExecutionStatus::RollingOut - | ReleaseExecutionStatus::Verifying - ), - ReleaseExecutionStatus::RollingOut => execution.status == ReleaseExecutionStatus::Paused, - _ => false, - }; - if !is_valid { - return Err(PlatformError::conflict(invalid_state_message)); - } + validate_release_execution_transition(&execution.status, &target_status) + .map_err(|_| PlatformError::conflict(invalid_state_message))?; + let actor = user_history_actor( + &claims, + state + .store + .get_user(&claims.sub) + .await + .map_err(PlatformError::Internal)? + .as_ref(), + ); - state - .store - .update_release_execution_status( + update_release_execution_status_with_history( + &state, + &release.id, + &execution.id, + Some(execution.status.clone()), + target_status.clone(), + Some(execution.current_batch), + &actor, + serde_json::json!({ + "event_type": event_type, + "changed_by": claims.sub, + "current_batch": execution.current_batch, + }), + ) + .await + .map_err(PlatformError::Internal)?; + if target_status == ReleaseExecutionStatus::Paused { + let _ = state + .store + .set_release_execution_next_batch_at(&execution.id, None) + .await; + let _ = update_batch_schedule_with_history( + &state, + &release.id, &execution.id, - target_status.clone(), - Some(execution.current_batch), + execution.current_batch + 1, + ReleaseInstanceStatus::WaitingBatch, + None, + Some(&format!( + "Paused before batch {}", + execution.current_batch + 1 + )), + &actor, + "instance_status_changed", + serde_json::json!({ + "reason": "execution_paused", + }), + ) + .await; + } + if let Some(next_request_status) = request_status { + set_release_request_status_with_history( + &state, + &release.id, + release.status.clone(), + next_request_status, + &actor, + serde_json::json!({ + "reason": event_type, + "changed_by": claims.sub, + }), ) .await .map_err(PlatformError::Internal)?; - if let Some(next_request_status) = request_status { - state - .store - .set_release_request_status(&release.id, next_request_status) - .await - .map_err(PlatformError::Internal)?; } let _ = state .store @@ -1090,6 +2730,23 @@ async fn control_release_execution( }), ) .await; + append_release_history( + &state, + &release.id, + Some(&execution.id), + None, + ReleaseHistoryScope::Execution, + event_type, + &actor, + None, + None, + serde_json::json!({ + "changed_by": claims.sub, + "status": target_status.to_string(), + }), + ) + .await + .map_err(PlatformError::Internal)?; let updated = state .store @@ -1116,7 +2773,8 @@ async fn publish_release_via_nats( .as_ref() .ok_or_else(|| anyhow::anyhow!("NATS publisher is not configured"))?; - let json_str = serde_json::to_string(ruleset_json)?; + let normalized_ruleset = normalize_release_ruleset_json(ruleset_json)?; + let json_str = serde_json::to_string(&normalized_ruleset)?; let event = SyncEvent::RulePut { tenant_id: project_id.to_string(), name: ruleset_name.to_string(), @@ -1134,6 +2792,83 @@ async fn publish_release_via_nats( publisher.publish_to(prefix, event).await } +fn looks_like_engine_ruleset(ruleset: &JsonValue) -> bool { + ruleset + .get("config") + .and_then(|config| config.get("entry_step")) + .and_then(JsonValue::as_str) + .is_some() + && ruleset.get("steps").is_some_and(JsonValue::is_object) +} + +fn looks_like_studio_ruleset(ruleset: &JsonValue) -> bool { + ruleset + .get("startStepId") + .and_then(JsonValue::as_str) + .is_some() + && ruleset.get("steps").is_some_and(JsonValue::is_array) +} + +fn normalize_release_ruleset_json(ruleset: &JsonValue) -> 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)?); + } + + Err(anyhow::anyhow!( + "Release payload must be either studio format or engine format" + )) +} + +#[allow(clippy::items_after_test_module)] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_release_ruleset_json_converts_studio_snapshot() { + let studio_ruleset = serde_json::json!({ + "config": { + "name": "coupon", + "version": "1.2.3", + "description": "demo", + "timeout": 0, + "enableTrace": false, + "metadata": {} + }, + "startStepId": "done", + "steps": [ + { + "id": "done", + "name": "Done", + "type": "terminal", + "code": "OK", + "message": { + "type": "literal", + "value": "done", + "valueType": "string" + }, + "output": [] + } + ], + "groups": [] + }); + + let normalized = normalize_release_ruleset_json(&studio_ruleset) + .expect("studio snapshot should convert"); + + assert_eq!(normalized["config"]["entry_step"].as_str(), Some("done")); + assert_eq!(normalized["config"]["version"].as_str(), Some("1.2.3")); + assert!(normalized["steps"].is_object()); + assert!(normalized["steps"].get("done").is_some()); + } +} + async fn wait_for_batch_feedback( state: &AppState, execution_id: &str, @@ -1161,7 +2896,7 @@ async fn wait_for_batch_feedback( )); }; match instance.status { - ReleaseInstanceStatus::Success => {} + ReleaseInstanceStatus::Success | ReleaseInstanceStatus::RolledBack => {} ReleaseInstanceStatus::Failed => { return Err(anyhow::anyhow!( "Server {} reported release failure: {}", diff --git a/crates/ordo-platform/src/release/requests.rs b/crates/ordo-platform/src/release/requests.rs index 0b8b601b..550b5246 100644 --- a/crates/ordo-platform/src/release/requests.rs +++ b/crates/ordo-platform/src/release/requests.rs @@ -179,15 +179,29 @@ pub async fn create_release_request( "Release request version must match the draft ruleset version", )); } - let current_version = draft.meta.published_version.clone(); - let baseline_snapshot = load_release_baseline_snapshot( + let environment_baseline = load_release_environment_baseline( &state, - &org_id, &project_id, &req.ruleset_name, - current_version.as_deref(), + &req.environment_id, ) .await?; + let current_version = environment_baseline + .as_ref() + .map(|deployment| deployment.version.clone()) + .or_else(|| draft.meta.published_version.clone()); + let baseline_snapshot = if let Some(deployment) = environment_baseline.as_ref() { + Some(deployment.snapshot.clone()) + } else { + load_release_baseline_snapshot( + &state, + &org_id, + &project_id, + &req.ruleset_name, + current_version.as_deref(), + ) + .await? + }; let target_snapshot = draft.draft.clone(); let approver_users = { @@ -241,6 +255,7 @@ pub async fn create_release_request( rollout_strategy: policy.rollout_strategy.clone(), rollback_policy: policy.rollback_policy.clone(), affected_instance_count: req.affected_instance_count.unwrap_or_default(), + target_ruleset_snapshot: Some(target_snapshot.clone()), }; let mut create_req = req; @@ -266,6 +281,51 @@ pub async fn create_release_request( .await .map_err(PlatformError::Internal)?; + let actor = user_history_actor(&claims, Some(&requester)); + append_release_history( + &state, + &release_id, + None, + None, + ReleaseHistoryScope::Request, + "request_created", + &actor, + None, + None, + serde_json::json!({ + "title": create_req.title, + "ruleset_name": create_req.ruleset_name, + "version": create_req.version, + "environment_id": create_req.environment_id, + "environment_name": environment.name, + "policy_id": create_req.policy_id, + "policy_name": policy.name, + "rollback_version": create_req.rollback_version, + "affected_instance_count": request_snapshot.affected_instance_count, + "version_diff": version_diff, + "content_diff": content_diff, + }), + ) + .await + .map_err(PlatformError::Internal)?; + + append_release_history( + &state, + &release_id, + None, + None, + ReleaseHistoryScope::Request, + "request_status_changed", + &actor, + None, + Some(ReleaseRequestStatus::PendingApproval.to_string()), + serde_json::json!({ + "reason": "request_created", + }), + ) + .await + .map_err(PlatformError::Internal)?; + for (idx, reviewer_id) in policy .approver_ids .iter() @@ -290,6 +350,32 @@ pub async fn create_release_request( ) .await .map_err(PlatformError::Internal)?; + + append_release_history( + &state, + &release_id, + None, + None, + ReleaseHistoryScope::Approval, + "approval_assigned", + &actor, + None, + Some(ReleaseApprovalDecision::Pending.to_string()), + serde_json::json!({ + "stage": (idx as i32) + 1, + "reviewer_id": reviewer_id, + "reviewer_name": approver_users + .iter() + .find(|user| user.id == *reviewer_id) + .map(|user| user.display_name.clone()), + "reviewer_email": approver_users + .iter() + .find(|user| user.id == *reviewer_id) + .map(|user| user.email.clone()), + }), + ) + .await + .map_err(PlatformError::Internal)?; } let release = state @@ -352,6 +438,36 @@ pub async fn get_release_request( Ok(Json(item)) } +pub async fn list_release_request_history( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id, release_id)): Path<(String, String, String)>, +) -> ApiResult>> { + require_project_permission( + &state, + &org_id, + &project_id, + &claims.sub, + PERM_RELEASE_REQUEST_VIEW, + ) + .await?; + + state + .store + .get_release_request(&org_id, &project_id, &release_id) + .await + .map_err(PlatformError::Internal)? + .ok_or_else(|| PlatformError::not_found("Release request not found"))?; + + let history = state + .store + .list_release_request_history(&release_id) + .await + .map_err(PlatformError::Internal)?; + + Ok(Json(history)) +} + pub(super) async fn load_release_baseline_snapshot( state: &AppState, org_id: &str, @@ -378,3 +494,21 @@ pub(super) async fn load_release_baseline_snapshot( .or(latest_publish) .map(|entry| entry.snapshot.clone())) } + +async fn load_release_environment_baseline( + state: &AppState, + project_id: &str, + ruleset_name: &str, + environment_id: &str, +) -> ApiResult> { + let deployments = state + .store + .list_deployments(project_id, Some(ruleset_name), 100) + .await + .map_err(PlatformError::Internal)?; + + Ok(deployments.into_iter().find(|deployment| { + deployment.environment_id == environment_id + && deployment.status == DeploymentStatus::Success + })) +} diff --git a/crates/ordo-platform/src/release/reviews.rs b/crates/ordo-platform/src/release/reviews.rs index 473244a1..0c8e0915 100644 --- a/crates/ordo-platform/src/release/reviews.rs +++ b/crates/ordo-platform/src/release/reviews.rs @@ -113,6 +113,13 @@ async fn review_release( )); } + let reviewer = state + .store + .get_user(&claims.sub) + .await + .map_err(PlatformError::Internal)?; + let actor = user_history_actor(&claims, reviewer.as_ref()); + let approvals = state .store .list_release_approvals(&release_id) @@ -133,12 +140,56 @@ async fn review_release( ReleaseRequestStatus::PendingApproval }; + let reviewed_approval = approvals.iter().find(|item| item.reviewer_id == claims.sub); + append_release_history( + &state, + &release_id, + None, + None, + ReleaseHistoryScope::Approval, + "approval_reviewed", + &actor, + Some(ReleaseApprovalDecision::Pending.to_string()), + Some(decision.to_string()), + serde_json::json!({ + "stage": reviewed_approval.map(|item| item.stage), + "decision": decision, + "comment": comment, + }), + ) + .await + .map_err(PlatformError::Internal)?; + + validate_release_request_transition(&release.status, &next_status) + .map_err(|err| PlatformError::conflict(err.to_string()))?; + state .store - .set_release_request_status(&release_id, next_status) + .set_release_request_status(&release_id, next_status.clone()) .await .map_err(PlatformError::Internal)?; + if next_status != release.status { + append_release_history( + &state, + &release_id, + None, + None, + ReleaseHistoryScope::Request, + "request_status_changed", + &actor, + Some(release.status.to_string()), + Some(next_status.to_string()), + serde_json::json!({ + "reason": "approval_reviewed", + "decision": decision, + "comment": comment, + }), + ) + .await + .map_err(PlatformError::Internal)?; + } + let item = state .store .get_release_request(&org_id, &project_id, &release_id) diff --git a/crates/ordo-platform/src/ruleset_draft.rs b/crates/ordo-platform/src/ruleset_draft.rs index 46f79d8c..441cad94 100644 --- a/crates/ordo-platform/src/ruleset_draft.rs +++ b/crates/ordo-platform/src/ruleset_draft.rs @@ -5,6 +5,7 @@ use ordo_core::{ rule::{ExecutionOptions, RuleExecutor, RuleSet}, trace::ExecutionTrace, }; +use ordo_protocol::StudioRuleSet; use crate::{ error::{ApiResult, PlatformError}, @@ -88,13 +89,19 @@ pub async fn save_draft( .map_err(DraftSaveResponse::Err)?; let id = Uuid::new_v4().to_string(); + let ruleset_value = serde_json::to_value(&req.ruleset).map_err(|e| { + DraftSaveResponse::Err(PlatformError::internal(format!( + "Serialization error: {}", + e + ))) + })?; match state .store .save_draft_ruleset( &id, &project_id, &ruleset_name, - &req.ruleset, + &ruleset_value, req.expected_seq, &claims.sub, ) @@ -144,11 +151,16 @@ pub async fn delete_draft( #[derive(serde::Deserialize)] pub struct TraceRequest { - /// Engine-format ruleset (pre-converted by frontend adapter from editor format). - pub ruleset: serde_json::Value, + /// Studio-format ruleset (converted to engine format by ordo-protocol on the backend). + pub ruleset: StudioRuleSet, pub input: serde_json::Value, } +#[derive(serde::Deserialize)] +pub struct ConvertRulesetRequest { + pub ruleset: StudioRuleSet, +} + #[derive(serde::Serialize)] pub struct TraceResponse { pub code: String, @@ -186,11 +198,16 @@ pub async fn trace_draft( require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_VIEW) .await?; - // Use the engine-format ruleset provided by the frontend (already converted from editor format). - let ruleset_json = serde_json::to_string(&req.ruleset) - .map_err(|e| PlatformError::internal(format!("Failed to serialize ruleset: {}", e)))?; + // Convert studio-format ruleset to engine format, then compile. + let mut ruleset: RuleSet = + req.ruleset + .try_into() + .map_err(|e: ordo_protocol::ConvertError| { + PlatformError::bad_request(format!("Ruleset conversion failed: {}", e)) + })?; - let ruleset = RuleSet::from_json_compiled(&ruleset_json) + ruleset + .compile() .map_err(|e| PlatformError::bad_request(format!("Failed to compile ruleset: {}", e)))?; // Convert input to ordo-core Value @@ -233,6 +250,31 @@ pub async fn trace_draft( })) } +/// POST /api/v1/orgs/:oid/projects/:pid/rulesets/:name/convert +/// +/// Converts a studio-format ruleset to engine format without executing it. +pub async fn convert_draft_ruleset( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id, _ruleset_name)): Path<(String, String, String)>, + Json(req): Json, +) -> ApiResult> { + 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 engine_json = serde_json::to_value(&engine) + .map_err(|e| PlatformError::internal(format!("Engine serialization failed: {}", e)))?; + + Ok(Json(engine_json)) +} + // ── Publish ─────────────────────────────────────────────────────────────────── /// POST /api/v1/orgs/:oid/projects/:pid/rulesets/:name/publish @@ -297,13 +339,17 @@ 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)?; + // Publish via NATS let publish_result = publish_via_nats( &state, &env, &project_id, &ruleset_name, - &draft.draft, + &engine_json, &version, ) .await; @@ -475,12 +521,14 @@ pub async fn redeploy( .await .map_err(PlatformError::Internal)?; + let snapshot_engine_json = studio_draft_to_engine_json(&original.snapshot)?; + let result = publish_via_nats( &state, &env, &project_id, &ruleset_name, - &original.snapshot, + &snapshot_engine_json, &original.version, ) .await; @@ -509,6 +557,20 @@ 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| { + PlatformError::bad_request(format!("Draft is not valid studio format: {}", e)) + })?; + let engine: RuleSet = studio + .try_into() + .map_err(|e: ordo_protocol::ConvertError| { + PlatformError::bad_request(format!("Draft conversion failed: {}", e)) + })?; + serde_json::to_value(&engine) + .map_err(|e| PlatformError::internal(format!("Engine serialization failed: {}", e))) +} + async fn publish_via_nats( state: &AppState, env: &crate::models::ProjectEnvironment, diff --git a/crates/ordo-platform/src/server_registry.rs b/crates/ordo-platform/src/server_registry.rs index 15f21acb..54bf03b5 100644 --- a/crates/ordo-platform/src/server_registry.rs +++ b/crates/ordo-platform/src/server_registry.rs @@ -40,6 +40,8 @@ pub struct RegisterRequest { pub version: Option, /// Optional org to associate this server with pub org_id: Option, + #[serde(default)] + pub capabilities: serde_json::Value, } #[derive(Serialize)] @@ -107,6 +109,7 @@ pub async fn register_server( status: ServerStatus::Online, last_seen: Some(Utc::now()), registered_at: existing.map(|s| s.registered_at).unwrap_or_else(Utc::now), + capabilities: req.capabilities, }; state diff --git a/crates/ordo-platform/src/store.rs b/crates/ordo-platform/src/store.rs index d5122da1..c89a7376 100644 --- a/crates/ordo-platform/src/store.rs +++ b/crates/ordo-platform/src/store.rs @@ -6,12 +6,13 @@ use crate::models::{ FactDefinition, Member, NullPolicy, OrgRole, Organization, PlatformNotification, Project, ProjectEnvironment, ProjectRuleset, ProjectRulesetMeta, ReleaseApprovalDecision, ReleaseApprovalRecord, ReleaseContentDiffSummary, ReleaseExecution, ReleaseExecutionEvent, - ReleaseExecutionInstance, ReleaseExecutionStatus, ReleaseExecutionSummary, - ReleaseInstanceStatus, ReleasePolicy, ReleasePolicyScope, ReleasePolicyTargetType, - ReleaseRequest, ReleaseRequestSnapshot, ReleaseRequestStatus, ReleaseVersionDiff, Role, - RollbackPolicy, RolloutStrategy, RulesetDeployment, RulesetHistoryEntry, RulesetHistorySource, - ServerNode, ServerStatus, TestCase, TestExpectation, UpdateEnvironmentRequest, - UpdateReleasePolicyRequest, UpdateRoleRequest, User, UserRoleAssignment, + ReleaseExecutionInstance, ReleaseExecutionStatus, ReleaseExecutionSummary, ReleaseHistoryActor, + ReleaseHistoryActorType, ReleaseHistoryScope, ReleaseInstanceStatus, ReleasePolicy, + ReleasePolicyScope, ReleasePolicyTargetType, ReleaseRequest, ReleaseRequestHistoryEntry, + ReleaseRequestSnapshot, ReleaseRequestStatus, ReleaseVersionDiff, Role, RollbackPolicy, + RolloutStrategy, RulesetDeployment, RulesetHistoryEntry, RulesetHistorySource, ServerNode, + ServerStatus, TestCase, TestExpectation, UpdateEnvironmentRequest, UpdateReleasePolicyRequest, + UpdateRoleRequest, User, UserRoleAssignment, }; use anyhow::Result; use serde_json::Value as JsonValue; diff --git a/crates/ordo-platform/src/store/releases.rs b/crates/ordo-platform/src/store/releases.rs index 752c2753..38624089 100644 --- a/crates/ordo-platform/src/store/releases.rs +++ b/crates/ordo-platform/src/store/releases.rs @@ -239,6 +239,7 @@ impl PlatformStore { env.name AS environment_name, rr.policy_id, rr.status, rr.title, rr.change_summary, rr.release_note, rr.affected_instance_count, rp.rollout_strategy, + (SELECT COUNT(*)::int FROM release_executions re WHERE re.release_request_id = rr.id) AS execution_attempts, rr.rollback_version, rr.created_by, COALESCE(rr.created_by_name, u.display_name) AS created_by_name, rr.created_by_email, rr.version_diff, rr.content_diff, rr.request_snapshot, rr.created_at, rr.updated_at @@ -274,6 +275,7 @@ impl PlatformStore { env.name AS environment_name, rr.policy_id, rr.status, rr.title, rr.change_summary, rr.release_note, rr.affected_instance_count, rp.rollout_strategy, + (SELECT COUNT(*)::int FROM release_executions re WHERE re.release_request_id = rr.id) AS execution_attempts, rr.rollback_version, rr.created_by, COALESCE(rr.created_by_name, u.display_name) AS created_by_name, rr.created_by_email, rr.version_diff, rr.content_diff, rr.request_snapshot, rr.created_at, rr.updated_at @@ -297,6 +299,37 @@ impl PlatformStore { Ok(Some(item)) } + pub async fn get_release_request_by_id( + &self, + release_id: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT rr.id, rr.org_id, rr.project_id, rr.ruleset_name, rr.version, rr.environment_id, + env.name AS environment_name, rr.policy_id, rr.status, rr.title, + rr.change_summary, rr.release_note, rr.affected_instance_count, + rp.rollout_strategy, + (SELECT COUNT(*)::int FROM release_executions re WHERE re.release_request_id = rr.id) AS execution_attempts, + rr.rollback_version, rr.created_by, COALESCE(rr.created_by_name, u.display_name) AS created_by_name, + rr.created_by_email, rr.version_diff, rr.content_diff, rr.request_snapshot, + rr.created_at, rr.updated_at + FROM release_requests rr + LEFT JOIN project_environments env ON env.id = rr.environment_id + LEFT JOIN release_policies rp ON rp.id = rr.policy_id + LEFT JOIN users u ON u.id = rr.created_by + WHERE rr.id = $1", + ) + .bind(release_id) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + let mut item = row_to_release_request(&row)?; + item.approvals = self.list_release_approvals(&item.id).await?; + Ok(Some(item)) + } + pub async fn create_release_approval( &self, id: &str, @@ -390,8 +423,8 @@ impl PlatformStore { ) -> Result { sqlx::query( "INSERT INTO release_executions - (id, release_request_id, status, current_batch, total_batches, strategy_snapshot, started_at, triggered_by) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7)", + (id, release_request_id, status, current_batch, total_batches, next_batch_at, strategy_snapshot, started_at, triggered_by) + VALUES ($1, $2, $3, $4, $5, NULL, $6, NOW(), $7)", ) .bind(id) .bind(release_request_id) @@ -419,7 +452,7 @@ impl PlatformStore { SET status = $1, current_batch = COALESCE($2, current_batch), finished_at = CASE - WHEN $1 IN ('completed', 'failed') THEN NOW() + WHEN $1 IN ('completed', 'failed', 'rollback_failed') THEN NOW() WHEN $1 IN ('preparing', 'waiting_start', 'rolling_out', 'paused', 'verifying', 'rollback_in_progress') THEN NULL ELSE finished_at END @@ -433,6 +466,23 @@ impl PlatformStore { Ok(()) } + pub async fn set_release_execution_next_batch_at( + &self, + execution_id: &str, + next_batch_at: Option>, + ) -> Result<()> { + sqlx::query( + "UPDATE release_executions + SET next_batch_at = $1 + WHERE id = $2", + ) + .bind(next_batch_at) + .bind(execution_id) + .execute(&self.pool) + .await?; + Ok(()) + } + pub async fn create_release_execution_event( &self, id: &str, @@ -456,24 +506,64 @@ impl PlatformStore { Ok(()) } + #[allow(clippy::too_many_arguments)] + pub async fn create_release_request_history( + &self, + id: &str, + release_request_id: &str, + release_execution_id: Option<&str>, + instance_id: Option<&str>, + scope: ReleaseHistoryScope, + action: &str, + actor: &ReleaseHistoryActor, + from_status: Option<&str>, + to_status: Option<&str>, + detail: serde_json::Value, + ) -> Result<()> { + sqlx::query( + "INSERT INTO release_request_history + (id, release_request_id, release_execution_id, instance_id, scope, action, + actor_type, actor_id, actor_name, actor_email, from_status, to_status, detail, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW())", + ) + .bind(id) + .bind(release_request_id) + .bind(release_execution_id) + .bind(instance_id) + .bind(scope.to_string()) + .bind(action) + .bind(actor.actor_type.to_string()) + .bind(&actor.actor_id) + .bind(&actor.actor_name) + .bind(&actor.actor_email) + .bind(from_status) + .bind(to_status) + .bind(detail) + .execute(&self.pool) + .await?; + Ok(()) + } + pub async fn create_release_execution_instance( &self, instance: &ReleaseExecutionInstance, ) -> Result<()> { sqlx::query( "INSERT INTO release_execution_instances - (id, release_execution_id, instance_id, instance_name, zone, current_version, target_version, - status, message, metric_summary, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10::jsonb, '{}'::jsonb), $11)", + (id, release_execution_id, instance_id, instance_name, zone, batch_index, current_version, target_version, + status, scheduled_at, message, metric_summary, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::jsonb, '{}'::jsonb), $13)", ) .bind(&instance.id) .bind(&instance.release_execution_id) .bind(&instance.instance_id) .bind(&instance.instance_name) .bind(&instance.zone) + .bind(instance.batch_index) .bind(&instance.current_version) .bind(&instance.target_version) .bind(instance.status.to_string()) + .bind(instance.scheduled_at) .bind(&instance.message) .bind(instance.metric_summary.as_ref()) .bind(instance.updated_at) @@ -489,16 +579,23 @@ impl PlatformStore { message: Option<&str>, metric_summary: Option<&str>, ) -> Result<()> { + let promote_current_version = matches!( + status, + ReleaseInstanceStatus::Success | ReleaseInstanceStatus::RolledBack + ); sqlx::query( "UPDATE release_execution_instances SET status = $1, message = $2, - metric_summary = COALESCE($3::jsonb, metric_summary), + scheduled_at = NULL, + current_version = CASE WHEN $3 THEN target_version ELSE current_version END, + metric_summary = COALESCE($4::jsonb, metric_summary), updated_at = NOW() - WHERE id = $4", + WHERE id = $5", ) .bind(status.to_string()) .bind(message) + .bind(promote_current_version) .bind(metric_summary.map(|value| serde_json::json!({ "event": value }))) .bind(instance_id) .execute(&self.pool) @@ -506,14 +603,93 @@ impl PlatformStore { Ok(()) } + pub async fn update_release_execution_instance_plan( + &self, + instance_id: &str, + target_version: &str, + status: ReleaseInstanceStatus, + scheduled_at: Option>, + message: Option<&str>, + ) -> Result<()> { + sqlx::query( + "UPDATE release_execution_instances + SET target_version = $1, + status = $2, + scheduled_at = $3, + message = $4, + updated_at = NOW() + WHERE id = $5", + ) + .bind(target_version) + .bind(status.to_string()) + .bind(scheduled_at) + .bind(message) + .bind(instance_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn update_release_execution_instance_schedule( + &self, + instance_id: &str, + status: ReleaseInstanceStatus, + scheduled_at: Option>, + message: Option<&str>, + ) -> Result<()> { + sqlx::query( + "UPDATE release_execution_instances + SET status = $1, + scheduled_at = $2, + message = COALESCE($3, message), + updated_at = NOW() + WHERE id = $4", + ) + .bind(status.to_string()) + .bind(scheduled_at) + .bind(message) + .bind(instance_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn update_release_execution_batch_schedule( + &self, + execution_id: &str, + batch_index: i32, + status: ReleaseInstanceStatus, + scheduled_at: Option>, + message: Option<&str>, + ) -> Result<()> { + sqlx::query( + "UPDATE release_execution_instances + SET status = $1, + scheduled_at = $2, + message = $3, + updated_at = NOW() + WHERE release_execution_id = $4 + AND batch_index = $5 + AND status NOT IN ('success', 'failed', 'rolled_back', 'skipped')", + ) + .bind(status.to_string()) + .bind(scheduled_at) + .bind(message) + .bind(execution_id) + .bind(batch_index) + .execute(&self.pool) + .await?; + Ok(()) + } + pub async fn find_release_execution_instance_by_target( &self, execution_id: &str, target_instance_id: &str, ) -> Result> { let row = sqlx::query( - "SELECT id, release_execution_id, instance_id, instance_name, zone, current_version, - target_version, status, message, metric_summary, updated_at + "SELECT id, release_execution_id, instance_id, instance_name, zone, batch_index, current_version, + target_version, status, scheduled_at, message, metric_summary, updated_at FROM release_execution_instances WHERE release_execution_id = $1 AND instance_id = $2 LIMIT 1", @@ -526,16 +702,34 @@ impl PlatformStore { .transpose() } + pub async fn get_release_execution_instance( + &self, + instance_id: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT id, release_execution_id, instance_id, instance_name, zone, batch_index, current_version, + target_version, status, scheduled_at, message, metric_summary, updated_at + FROM release_execution_instances + WHERE id = $1 + LIMIT 1", + ) + .bind(instance_id) + .fetch_optional(&self.pool) + .await?; + row.map(|r| row_to_release_execution_instance(&r)) + .transpose() + } + pub async fn list_release_execution_instances( &self, execution_id: &str, ) -> Result> { let rows = sqlx::query( - "SELECT id, release_execution_id, instance_id, instance_name, zone, current_version, - target_version, status, message, metric_summary, updated_at + "SELECT id, release_execution_id, instance_id, instance_name, zone, batch_index, current_version, + target_version, status, scheduled_at, message, metric_summary, updated_at FROM release_execution_instances WHERE release_execution_id = $1 - ORDER BY updated_at DESC, instance_name", + ORDER BY batch_index ASC, instance_name ASC, updated_at DESC", ) .bind(execution_id) .fetch_all(&self.pool) @@ -548,7 +742,7 @@ impl PlatformStore { execution_id: &str, ) -> Result> { let row = sqlx::query( - "SELECT id, release_request_id, status, current_batch, total_batches, strategy_snapshot, started_at + "SELECT id, release_request_id, status, current_batch, total_batches, next_batch_at, strategy_snapshot, started_at FROM release_executions WHERE id = $1", ) @@ -569,7 +763,7 @@ impl PlatformStore { request_id: &str, ) -> Result> { let row = sqlx::query( - "SELECT id, release_request_id, status, current_batch, total_batches, strategy_snapshot, started_at + "SELECT id, release_request_id, status, current_batch, total_batches, next_batch_at, strategy_snapshot, started_at FROM release_executions WHERE release_request_id = $1 ORDER BY started_at DESC @@ -587,20 +781,85 @@ impl PlatformStore { Ok(Some(item)) } - /// On startup, mark any release execution stuck in an active non-terminal state as 'failed'. + pub async fn list_worker_claimable_release_executions( + &self, + limit: i64, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, release_request_id, status, current_batch, total_batches, next_batch_at, strategy_snapshot, started_at + FROM release_executions + WHERE status IN ('preparing', 'waiting_start', 'rolling_out', 'rollback_in_progress') + ORDER BY started_at ASC + LIMIT $1", + ) + .bind(limit.max(1)) + .fetch_all(&self.pool) + .await?; + + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let mut item = row_to_release_execution(&row)?; + item.instances = self.list_release_execution_instances(&item.id).await?; + item.summary = summarize_release_execution_instances(&item.instances); + items.push(item); + } + Ok(items) + } + + pub async fn try_lock_release_execution( + &self, + execution_id: &str, + ) -> Result>> { + let mut conn = self.pool.acquire().await?; + let lock_key = format!("ordo-platform:release-execution:{execution_id}"); + let locked: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock(hashtext($1), 0)") + .bind(lock_key) + .fetch_one(&mut *conn) + .await?; + + if locked { + Ok(Some(conn)) + } else { + Ok(None) + } + } + + pub async fn count_release_executions_by_request(&self, request_id: &str) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*)::bigint + FROM release_executions + WHERE release_request_id = $1", + ) + .bind(request_id) + .fetch_one(&self.pool) + .await?; + Ok(count) + } + + /// On startup, mark any release execution stuck in an active non-terminal state as terminal. /// These are executions where the platform spawned a background task that was killed mid-run. - /// Also updates the parent release_request to 'failed'. + /// Rollback flows become `rollback_failed`; rollout flows become `failed`. pub async fn fail_stuck_active_executions(&self) -> Result { let result = sqlx::query( "WITH stuck AS ( UPDATE release_executions - SET status = 'failed', + SET status = CASE + WHEN status = 'rollback_in_progress' THEN 'rollback_failed' + ELSE 'failed' + END, finished_at = NOW() WHERE status IN ('preparing', 'waiting_start', 'rolling_out', 'verifying', 'rollback_in_progress') - RETURNING release_request_id + RETURNING release_request_id, status ) UPDATE release_requests - SET status = 'failed' + SET status = CASE + WHEN EXISTS ( + SELECT 1 FROM stuck + WHERE stuck.release_request_id = release_requests.id + AND stuck.status = 'rollback_failed' + ) THEN 'rollback_failed' + ELSE 'failed' + END WHERE id IN (SELECT release_request_id FROM stuck) AND status = 'executing'", ) @@ -616,7 +875,7 @@ impl PlatformStore { ) -> Result> { let row = sqlx::query( "SELECT re.id, re.release_request_id, re.status, re.current_batch, re.total_batches, - re.strategy_snapshot, re.started_at + re.next_batch_at, re.strategy_snapshot, re.started_at FROM release_executions re INNER JOIN release_requests rr ON rr.id = re.release_request_id WHERE rr.org_id = $1 AND rr.project_id = $2 @@ -670,4 +929,22 @@ impl PlatformStore { .await?; rows.iter().map(row_to_release_execution_event).collect() } + + pub async fn list_release_request_history( + &self, + release_request_id: &str, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, release_request_id, release_execution_id, instance_id, scope, action, + actor_type, actor_id, actor_name, actor_email, from_status, to_status, + detail, created_at + FROM release_request_history + WHERE release_request_id = $1 + ORDER BY created_at ASC, id ASC", + ) + .bind(release_request_id) + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_release_request_history).collect() + } } diff --git a/crates/ordo-platform/src/store/rows.rs b/crates/ordo-platform/src/store/rows.rs index aaecdc96..ffee54bf 100644 --- a/crates/ordo-platform/src/store/rows.rs +++ b/crates/ordo-platform/src/store/rows.rs @@ -26,6 +26,7 @@ pub(super) fn row_to_project(r: &sqlx::postgres::PgRow) -> Project { pub(super) fn row_to_server(r: &sqlx::postgres::PgRow) -> ServerNode { use std::str::FromStr; let labels: sqlx::types::Json = r.get("labels"); + let capabilities: sqlx::types::Json = r.get("capabilities"); let status_str: String = r.get("status"); ServerNode { id: r.get("id"), @@ -38,6 +39,7 @@ pub(super) fn row_to_server(r: &sqlx::postgres::PgRow) -> ServerNode { status: ServerStatus::from_str(&status_str).unwrap_or(ServerStatus::Offline), last_seen: r.get("last_seen"), registered_at: r.get("registered_at"), + capabilities: capabilities.0, } } @@ -126,6 +128,7 @@ pub(super) fn row_to_release_policy(r: &sqlx::postgres::PgRow) -> Result Result { use std::str::FromStr; let status: String = r.get("status"); + let parsed_status = ReleaseRequestStatus::from_str(&status).map_err(|e| anyhow::anyhow!(e))?; let rollout_strategy: Option> = r.try_get("rollout_strategy").ok(); let version_diff: Option> = @@ -143,7 +146,7 @@ pub(super) fn row_to_release_request(r: &sqlx::postgres::PgRow) -> Result Result Result Result { + use std::str::FromStr; + + let scope: String = r.get("scope"); + let actor_type: String = r.get("actor_type"); + let detail: sqlx::types::Json = r.get("detail"); + + Ok(ReleaseRequestHistoryEntry { + id: r.get("id"), + release_request_id: r.get("release_request_id"), + release_execution_id: r.try_get("release_execution_id").ok(), + instance_id: r.try_get("instance_id").ok(), + scope: ReleaseHistoryScope::from_str(&scope).map_err(|e| anyhow::anyhow!(e))?, + action: r.get("action"), + actor_type: ReleaseHistoryActorType::from_str(&actor_type) + .map_err(|e| anyhow::anyhow!(e))?, + actor_id: r.try_get("actor_id").ok(), + actor_name: r.try_get("actor_name").ok(), + actor_email: r.try_get("actor_email").ok(), + from_status: r.try_get("from_status").ok(), + to_status: r.try_get("to_status").ok(), + detail: detail.0, + created_at: r.get("created_at"), + }) +} + pub(super) fn row_to_org_role(r: &sqlx::postgres::PgRow) -> OrgRole { let permissions: Vec = r.get("permissions"); OrgRole { diff --git a/crates/ordo-platform/src/store/servers.rs b/crates/ordo-platform/src/store/servers.rs index d2e5c9d0..7305ba4c 100644 --- a/crates/ordo-platform/src/store/servers.rs +++ b/crates/ordo-platform/src/store/servers.rs @@ -3,16 +3,17 @@ use super::*; impl PlatformStore { pub async fn upsert_server(&self, server: &ServerNode) -> Result<()> { sqlx::query( - "INSERT INTO servers (id, name, url, token, org_id, labels, version, status, last_seen, registered_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "INSERT INTO servers (id, name, url, token, org_id, labels, version, status, last_seen, registered_at, capabilities) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, url = EXCLUDED.url, token = EXCLUDED.token, org_id = EXCLUDED.org_id, version = EXCLUDED.version, + capabilities = EXCLUDED.capabilities, status = 'online', - last_seen = NOW()", + last_seen = NOW()", ) .bind(&server.id) .bind(&server.name) @@ -24,6 +25,7 @@ impl PlatformStore { .bind(server.status.to_string()) .bind(server.last_seen) .bind(server.registered_at) + .bind(sqlx::types::Json(&server.capabilities)) .execute(&self.pool) .await?; Ok(()) @@ -31,7 +33,7 @@ impl PlatformStore { pub async fn get_server(&self, id: &str) -> Result> { let row = sqlx::query( - "SELECT id, name, url, token, org_id, labels, version, status, last_seen, registered_at + "SELECT id, name, url, token, org_id, labels, version, status, last_seen, registered_at, capabilities FROM servers WHERE id = $1", ) .bind(id) @@ -42,7 +44,7 @@ impl PlatformStore { pub async fn find_server_by_token(&self, token: &str) -> Result> { let row = sqlx::query( - "SELECT id, name, url, token, org_id, labels, version, status, last_seen, registered_at + "SELECT id, name, url, token, org_id, labels, version, status, last_seen, registered_at, capabilities FROM servers WHERE token = $1", ) .bind(token) @@ -54,7 +56,7 @@ impl PlatformStore { pub async fn list_servers(&self, org_id: Option<&str>) -> Result> { let rows = if let Some(oid) = org_id { sqlx::query( - "SELECT id, name, url, token, org_id, labels, version, status, last_seen, registered_at + "SELECT id, name, url, token, org_id, labels, version, status, last_seen, registered_at, capabilities FROM servers WHERE org_id = $1 ORDER BY registered_at DESC", ) .bind(oid) @@ -62,7 +64,7 @@ impl PlatformStore { .await? } else { sqlx::query( - "SELECT id, name, url, token, org_id, labels, version, status, last_seen, registered_at + "SELECT id, name, url, token, org_id, labels, version, status, last_seen, registered_at, capabilities FROM servers ORDER BY registered_at DESC", ) .fetch_all(&self.pool) diff --git a/crates/ordo-platform/src/sync.rs b/crates/ordo-platform/src/sync.rs index 3b987278..d5b81dec 100644 --- a/crates/ordo-platform/src/sync.rs +++ b/crates/ordo-platform/src/sync.rs @@ -43,6 +43,8 @@ pub enum SyncEvent { token: String, version: Option, org_id: Option, + #[serde(default)] + capabilities: Vec, }, ServerHeartbeat { #[serde(default)] @@ -282,6 +284,7 @@ pub fn start_registry_subscriber( token, version, org_id, + capabilities, } => match crate::models::derive_server_id(&url) { Ok(derived_server_id) => { let effective_server_id = if server_id.is_empty() { @@ -323,6 +326,7 @@ pub fn start_registry_subscriber( _ => {} } } + let caps_json = serde_json::Value::Array(capabilities); let server = crate::models::ServerNode { id: effective_server_id, name, @@ -338,6 +342,7 @@ pub fn start_registry_subscriber( registered_at: existing_opt .map(|s| s.registered_at) .unwrap_or_else(chrono::Utc::now), + capabilities: caps_json, }; store.upsert_server(&server).await } @@ -366,32 +371,93 @@ pub fn start_registry_subscriber( .await { Ok(Some(instance)) => { - let update_result = store - .update_release_execution_instance( - &instance.id, - crate::models::ReleaseInstanceStatus::Success, - message - .as_deref() - .or(Some("Server applied release payload")), - Some("release_ack"), - ) - .await; - if let Err(e) = update_result { - Err(e) - } else { - let _ = store - .create_release_execution_event( - &uuid::Uuid::new_v4().to_string(), - &execution_id, - Some(&instance.id), - "release_ack", - serde_json::json!({ - "server_id": server_id, - "message": message, - }), - ) - .await; - Ok(()) + match store.get_release_execution(&execution_id).await { + Ok(Some(execution)) => { + let ack_status = if execution.status + == crate::models::ReleaseExecutionStatus::RollbackInProgress + { + crate::models::ReleaseInstanceStatus::RolledBack + } else { + crate::models::ReleaseInstanceStatus::Success + }; + let history_action = if ack_status + == crate::models::ReleaseInstanceStatus::RolledBack + { + "instance_rolled_back" + } else { + "instance_acknowledged" + }; + let event_type = if ack_status + == crate::models::ReleaseInstanceStatus::RolledBack + { + "rollback_ack" + } else { + "release_ack" + }; + if let Err(err) = + crate::release::validate_release_instance_transition( + &instance.status, + &ack_status, + ) + { + Err(err) + } else { + let update_result = store + .update_release_execution_instance( + &instance.id, + ack_status.clone(), + message + .as_deref() + .or(Some("Server applied release payload")), + Some(event_type), + ) + .await; + if let Err(e) = update_result { + Err(e) + } else { + let actor = crate::release::server_history_actor( + &server_id, + Some(&instance.instance_name), + ); + let from_status = instance.status.to_string(); + let to_status = ack_status.to_string(); + let _ = store + .create_release_request_history( + &uuid::Uuid::new_v4().to_string(), + &execution.request_id, + Some(&execution_id), + Some(&instance.id), + crate::models::ReleaseHistoryScope::Instance, + history_action, + &actor, + Some(from_status.as_str()), + Some(to_status.as_str()), + serde_json::json!({ + "instance_name": instance.instance_name, + "target_instance_id": instance.instance_id, + "server_id": server_id, + "message": message, + }), + ) + .await; + let _ = store + .create_release_execution_event( + &uuid::Uuid::new_v4().to_string(), + &execution_id, + Some(&instance.id), + event_type, + serde_json::json!({ + "server_id": server_id, + "message": message, + }), + ) + .await; + Ok(()) + } + } + } + Ok(None) => Err(anyhow::anyhow!("release execution not found")), + Err(e) => Err(e), } } Ok(None) => Err(anyhow::anyhow!( @@ -410,30 +476,72 @@ pub fn start_registry_subscriber( .await { Ok(Some(instance)) => { - let update_result = store - .update_release_execution_instance( - &instance.id, - crate::models::ReleaseInstanceStatus::Failed, - Some(&error), - Some("release_failed"), - ) - .await; - if let Err(e) = update_result { - Err(e) - } else { - let _ = store - .create_release_execution_event( - &uuid::Uuid::new_v4().to_string(), - &execution_id, - Some(&instance.id), - "release_failed", - serde_json::json!({ - "server_id": server_id, - "error": error, - }), - ) - .await; - Ok(()) + match store.get_release_execution(&execution_id).await { + Ok(Some(execution)) => { + if let Err(err) = + crate::release::validate_release_instance_transition( + &instance.status, + &crate::models::ReleaseInstanceStatus::Failed, + ) + { + Err(err) + } else { + let update_result = store + .update_release_execution_instance( + &instance.id, + crate::models::ReleaseInstanceStatus::Failed, + Some(&error), + Some("release_failed"), + ) + .await; + if let Err(e) = update_result { + Err(e) + } else { + let actor = crate::release::server_history_actor( + &server_id, + Some(&instance.instance_name), + ); + let from_status = instance.status.to_string(); + let to_status = + crate::models::ReleaseInstanceStatus::Failed + .to_string(); + let _ = store + .create_release_request_history( + &uuid::Uuid::new_v4().to_string(), + &execution.request_id, + Some(&execution_id), + Some(&instance.id), + crate::models::ReleaseHistoryScope::Instance, + "instance_failed", + &actor, + Some(from_status.as_str()), + Some(to_status.as_str()), + serde_json::json!({ + "instance_name": instance.instance_name, + "target_instance_id": instance.instance_id, + "server_id": server_id, + "error": error, + }), + ) + .await; + let _ = store + .create_release_execution_event( + &uuid::Uuid::new_v4().to_string(), + &execution_id, + Some(&instance.id), + "release_failed", + serde_json::json!({ + "server_id": server_id, + "error": error, + }), + ) + .await; + Ok(()) + } + } + } + Ok(None) => Err(anyhow::anyhow!("release execution not found")), + Err(e) => Err(e), } } Ok(None) => Err(anyhow::anyhow!( diff --git a/crates/ordo-platform/src/testing.rs b/crates/ordo-platform/src/testing.rs index 6eb14f01..a5a91a8c 100644 --- a/crates/ordo-platform/src/testing.rs +++ b/crates/ordo-platform/src/testing.rs @@ -31,6 +31,7 @@ use ordo_core::{ rule::{ExecutionOptions, RuleExecutor, RuleSet}, trace::ExecutionTrace, }; +use ordo_protocol::StudioRuleSet; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use std::collections::HashMap; @@ -343,14 +344,14 @@ pub async fn run_project_tests( req.include_trace, ) .await - .unwrap_or_else(|_| { + .unwrap_or_else(|err| { tests .iter() .map(|t| TestRunResult { test_id: t.id.clone(), test_name: t.name.clone(), passed: false, - failures: vec!["engine error".to_string()], + failures: vec![err.to_string()], duration_us: 0, actual_code: None, actual_message: None, @@ -555,7 +556,7 @@ async fn resolve_execution_ruleset( provided_ruleset: Option<&JsonValue>, ) -> ApiResult { if let Some(ruleset) = provided_ruleset { - return Ok(ruleset.clone()); + return normalize_execution_ruleset_json(ruleset); } let draft = state @@ -565,13 +566,7 @@ async fn resolve_execution_ruleset( .map_err(PlatformError::Internal)? .ok_or_else(|| PlatformError::not_found("Ruleset draft not found"))?; - if looks_like_engine_ruleset(&draft.draft) { - return Ok(draft.draft); - } - - Err(PlatformError::bad_request( - "Engine-format ruleset payload is required for local test execution", - )) + normalize_execution_ruleset_json(&draft.draft) } fn looks_like_engine_ruleset(ruleset: &JsonValue) -> bool { @@ -583,6 +578,37 @@ fn looks_like_engine_ruleset(ruleset: &JsonValue) -> bool { && ruleset.get("steps").is_some_and(JsonValue::is_object) } +fn looks_like_studio_ruleset(ruleset: &JsonValue) -> bool { + ruleset + .get("startStepId") + .and_then(JsonValue::as_str) + .is_some() + && ruleset.get("steps").is_some_and(JsonValue::is_array) +} + +fn normalize_execution_ruleset_json(ruleset: &JsonValue) -> ApiResult { + 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()).map_err(|e| { + PlatformError::bad_request(format!("Invalid studio ruleset payload: {}", e)) + })?; + let engine: RuleSet = studio + .try_into() + .map_err(|e: ordo_protocol::ConvertError| { + PlatformError::bad_request(format!("Ruleset conversion failed: {}", e)) + })?; + return serde_json::to_value(&engine) + .map_err(|e| PlatformError::internal(format!("Engine serialization failed: {}", e))); + } + + Err(PlatformError::bad_request( + "Ruleset payload must be either studio format or engine format", + )) +} + fn compile_ruleset(ruleset: &JsonValue) -> anyhow::Result { let ruleset_json = serde_json::to_string(ruleset)?; RuleSet::from_json_compiled(&ruleset_json).map_err(|e| anyhow::anyhow!("{}", e)) diff --git a/crates/ordo-protocol/Cargo.toml b/crates/ordo-protocol/Cargo.toml new file mode 100644 index 00000000..eace38f1 --- /dev/null +++ b/crates/ordo-protocol/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ordo-protocol" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Studio ↔ engine protocol conversion layer for Ordo" + +[dependencies] +ordo-core = { path = "../ordo-core", default-features = false, features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +hashbrown = { version = "0.14", features = ["serde"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/ordo-protocol/src/convert.rs b/crates/ordo-protocol/src/convert.rs new file mode 100644 index 00000000..ce88f2ea --- /dev/null +++ b/crates/ordo-protocol/src/convert.rs @@ -0,0 +1,645 @@ +//! Studio → engine conversion (TryFrom for RuleSet) + +use hashbrown::HashMap as FastMap; +use ordo_core::{ + context::Value as CoreValue, + expr::{BinaryOp, Expr, UnaryOp}, + rule::{ + Action, ActionKind, Branch, Condition, FieldMissingBehavior, LogLevel, RuleSet, + RuleSetConfig, Step, StepKind, SubRuleGraph, TerminalResult, + }, +}; + +use crate::types::{ + condition::condition_to_expr_string, + expr::{binary_op_display, expr_to_string, map_binary_op, StudioExpr}, + ruleset::{StudioRuleSet, StudioSubRuleGraph}, + step::{StudioStep, StudioStepKind, StudioTerminalMessage}, +}; + +/// Error returned when studio → engine conversion fails. +#[derive(thiserror::Error, Debug)] +pub enum ConvertError { + #[error("startStepId is missing or empty")] + MissingStartStep, + + #[error("step '{0}' has no nextStepId")] + MissingNextStep(String), + + #[error("sub-rule '{0}' not found in ruleset")] + SubRuleNotFound(String), + + #[error("expression conversion failed in step '{0}': {1}")] + Expr(String, String), +} + +// ── Top-level conversion ────────────────────────────────────────────────────── + +impl TryFrom for RuleSet { + type Error = ConvertError; + + fn try_from(s: StudioRuleSet) -> Result { + if s.start_step_id.is_empty() { + return Err(ConvertError::MissingStartStep); + } + + let config = RuleSetConfig { + name: s.config.name, + tenant_id: None, + version: s.config.version.unwrap_or_else(|| "1.0.0".to_string()), + description: s.config.description.unwrap_or_default(), + entry_step: s.start_step_id, + field_missing: FieldMissingBehavior::Lenient, + max_depth: 100, + timeout_ms: s.config.timeout.unwrap_or(5000), + enable_trace: s.config.enable_trace.unwrap_or(false), + metadata: s.config.metadata.into_iter().collect(), + }; + + let mut steps: FastMap = FastMap::new(); + for studio_step in s.steps { + let step = convert_step(studio_step)?; + steps.insert(step.id.clone(), step); + } + + let mut sub_rules: FastMap = FastMap::new(); + for (name, graph) in s.sub_rules { + let converted = convert_sub_rule_graph(graph)?; + sub_rules.insert(name, converted); + } + + Ok(RuleSet { + config, + steps, + sub_rules, + }) + } +} + +// ── Step conversion ─────────────────────────────────────────────────────────── + +fn convert_step(s: StudioStep) -> Result { + let step_id = s.id.clone(); + let kind = convert_step_kind(s.kind, &step_id)?; + Ok(Step { + id: s.id, + name: s.name, + kind, + }) +} + +fn convert_step_kind(kind: StudioStepKind, step_id: &str) -> Result { + match kind { + StudioStepKind::Decision { + branches, + default_next_step_id, + } => { + let converted: Result, _> = branches + .into_iter() + .map(|b| convert_branch(b, step_id)) + .collect(); + Ok(StepKind::Decision { + branches: converted?, + default_next: default_next_step_id, + }) + } + + StudioStepKind::Action { + assignments, + external_calls, + logging, + next_step_id, + } => { + let mut actions: Vec = Vec::new(); + + for assign in assignments { + let expr = convert_expr(assign.value, step_id)?; + actions.push(Action { + kind: ActionKind::SetVariable { + name: assign.name, + value: expr, + }, + description: String::new(), + }); + } + + for call in external_calls { + let params: Result, _> = call + .params + .into_iter() + .map(|(k, v)| convert_expr(v, step_id).map(|e| (k, e))) + .collect(); + actions.push(Action { + kind: ActionKind::ExternalCall { + service: call.target, + method: call.call_type, + params: params?, + result_variable: call.result_variable, + timeout_ms: call.timeout.unwrap_or(5000), + }, + description: String::new(), + }); + } + + if let Some(log) = logging { + let message = expr_to_log_message(log.message); + let level = parse_log_level(log.level.as_deref()); + actions.push(Action { + kind: ActionKind::Log { message, level }, + description: String::new(), + }); + } + + Ok(StepKind::Action { + actions, + next_step: next_step_id, + }) + } + + StudioStepKind::Terminal { + code, + message, + output, + } => { + let output_fields: Result, _> = output + .into_iter() + .map(|f| convert_expr(f.value, step_id).map(|e| (f.name, e))) + .collect(); + + let result = TerminalResult { + code, + message: terminal_message_to_engine_string(message), + output: output_fields?, + data: CoreValue::Null, + }; + Ok(StepKind::Terminal { result }) + } + + StudioStepKind::SubRule { + ref_name, + bindings, + outputs, + next_step_id, + } => { + let converted_bindings: Result, _> = bindings + .into_iter() + .map(|b| convert_expr(b.expr, step_id).map(|e| (b.field, e))) + .collect(); + let converted_outputs: Vec<(String, String)> = outputs + .into_iter() + .map(|o| (o.parent_var, o.child_var)) + .collect(); + + Ok(StepKind::SubRule { + ref_name, + bindings: converted_bindings?, + outputs: converted_outputs, + next_step: next_step_id, + }) + } + } +} + +fn convert_branch( + b: crate::types::step::StudioBranch, + _step_id: &str, +) -> Result { + let expr_str = condition_to_expr_string(&b.condition); + Ok(Branch { + condition: Condition::ExpressionString(expr_str), + next_step: b.next_step_id, + actions: vec![], + }) +} + +fn convert_sub_rule_graph(g: StudioSubRuleGraph) -> Result { + let mut steps: FastMap = FastMap::new(); + for studio_step in g.steps { + let step = convert_step(studio_step)?; + steps.insert(step.id.clone(), step); + } + Ok(SubRuleGraph { + entry_step: g.entry_step, + steps, + }) +} + +// ── Expr conversion ─────────────────────────────────────────────────────────── + +fn convert_expr(expr: StudioExpr, step_id: &str) -> Result { + match expr { + StudioExpr::Literal { value, .. } => { + let v = json_to_core_value(value) + .map_err(|e| ConvertError::Expr(step_id.to_string(), e))?; + Ok(Expr::Literal(v)) + } + + StudioExpr::Variable { path } => { + let field = if let Some(stripped) = path.strip_prefix("$.") { + stripped.to_string() + } else { + path + }; + Ok(Expr::Field(field)) + } + + StudioExpr::Binary { op, left, right } => { + let bin_op = parse_binary_op_enum(&op) + .map_err(|e| ConvertError::Expr(step_id.to_string(), e))?; + Ok(Expr::Binary { + op: bin_op, + left: Box::new(convert_expr(*left, step_id)?), + right: Box::new(convert_expr(*right, step_id)?), + }) + } + + StudioExpr::Unary { op, operand } => { + let unary_op = match op.as_str() { + "not" | "!" => UnaryOp::Not, + "neg" | "-" => UnaryOp::Neg, + other => { + return Err(ConvertError::Expr( + step_id.to_string(), + format!("unknown unary op: {}", other), + )) + } + }; + Ok(Expr::Unary { + op: unary_op, + operand: Box::new(convert_expr(*operand, step_id)?), + }) + } + + StudioExpr::Function { name, args } => { + let converted: Result, _> = + args.into_iter().map(|a| convert_expr(a, step_id)).collect(); + Ok(Expr::Call { + name, + args: converted?, + }) + } + + StudioExpr::Member { object, property } => { + // Flatten member access into a dotted field path when possible + match convert_expr(*object, step_id)? { + Expr::Field(path) => Ok(Expr::Field(format!("{}.{}", path, property))), + other => Ok(Expr::Binary { + op: BinaryOp::Eq, // fallback — shouldn't happen in practice + left: Box::new(other), + right: Box::new(Expr::Field(property)), + }), + } + } + + StudioExpr::Array { elements } => { + let converted: Result, _> = elements + .into_iter() + .map(|e| convert_expr(e, step_id)) + .collect(); + Ok(Expr::Array(converted?)) + } + + StudioExpr::Object { entries } => { + let converted: Result, _> = entries + .into_iter() + .map(|(k, v)| convert_expr(v, step_id).map(|e| (k, e))) + .collect(); + Ok(Expr::Object(converted?)) + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn json_to_core_value(v: serde_json::Value) -> Result { + serde_json::from_value(v).map_err(|e| e.to_string()) +} + +fn parse_binary_op_enum(op: &str) -> Result { + match map_binary_op(op) { + Some("+") => Ok(BinaryOp::Add), + Some("-") => Ok(BinaryOp::Sub), + Some("*") => Ok(BinaryOp::Mul), + Some("/") => Ok(BinaryOp::Div), + Some("%") => Ok(BinaryOp::Mod), + Some("==") => Ok(BinaryOp::Eq), + Some("!=") => Ok(BinaryOp::Ne), + Some(">") => Ok(BinaryOp::Gt), + Some(">=") => Ok(BinaryOp::Ge), + Some("<") => Ok(BinaryOp::Lt), + Some("<=") => Ok(BinaryOp::Le), + Some("&&") => Ok(BinaryOp::And), + Some("||") => Ok(BinaryOp::Or), + Some("in") => Ok(BinaryOp::In), + Some("not in") => Ok(BinaryOp::NotIn), + Some("contains") => Ok(BinaryOp::Contains), + _ => Err(format!("unknown binary op: {}", binary_op_display(op))), + } +} + +fn expr_to_log_message(expr: StudioExpr) -> String { + match &expr { + StudioExpr::Literal { + value: serde_json::Value::String(s), + .. + } => s.clone(), + other => expr_to_string(other), + } +} + +fn terminal_message_to_engine_string(message: Option) -> String { + match message { + None => String::new(), + Some(StudioTerminalMessage::String(message)) => message, + Some(StudioTerminalMessage::Expr(StudioExpr::Literal { + value: serde_json::Value::String(message), + .. + })) => message, + Some(StudioTerminalMessage::Expr(expr)) => expr_to_string(&expr), + } +} + +fn parse_log_level(level: Option<&str>) -> LogLevel { + match level { + Some("debug") => LogLevel::Debug, + Some("warn") => LogLevel::Warn, + Some("error") => LogLevel::Error, + _ => LogLevel::Info, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ + condition::StudioCondition, + expr::StudioExpr, + ruleset::{StudioConfig, StudioRuleSet}, + step::{StudioAssignment, StudioBranch, StudioStep, StudioStepKind, StudioTerminalMessage}, + }; + + fn base_config(name: &str) -> StudioConfig { + StudioConfig { + name: name.to_string(), + version: Some("1.0.0".to_string()), + description: None, + tags: None, + enable_trace: None, + timeout: None, + input_schema: None, + output_schema: None, + metadata: Default::default(), + } + } + + fn terminal_step(id: &str, code: &str) -> StudioStep { + StudioStep { + id: id.to_string(), + name: id.to_string(), + description: None, + position: None, + kind: StudioStepKind::Terminal { + code: code.to_string(), + message: None, + output: vec![], + }, + } + } + + #[test] + fn test_terminal_message_accepts_expression_object() { + let rs = StudioRuleSet { + config: base_config("terminal_message_expr"), + start_step_id: "done".to_string(), + steps: vec![StudioStep { + id: "done".to_string(), + name: "Done".to_string(), + description: None, + position: None, + kind: StudioStepKind::Terminal { + code: "OK".to_string(), + message: Some(StudioTerminalMessage::Expr(StudioExpr::Literal { + value: serde_json::Value::String("expr message".to_string()), + value_type: Some("string".to_string()), + })), + output: vec![], + }, + }], + groups: None, + metadata: None, + sub_rules: Default::default(), + }; + + let converted = RuleSet::try_from(rs).expect("studio ruleset should convert"); + let step = converted + .steps + .get("done") + .expect("terminal step should exist"); + match &step.kind { + StepKind::Terminal { result } => { + assert_eq!(result.code, "OK"); + assert_eq!(result.message, "expr message"); + } + other => panic!("expected terminal step, got {other:?}"), + } + } + + #[test] + fn test_terminal_message_accepts_legacy_string() { + let rs = StudioRuleSet { + config: base_config("terminal_message_string"), + start_step_id: "done".to_string(), + steps: vec![StudioStep { + id: "done".to_string(), + name: "Done".to_string(), + description: None, + position: None, + kind: StudioStepKind::Terminal { + code: "OK".to_string(), + message: Some(StudioTerminalMessage::String("plain message".to_string())), + output: vec![], + }, + }], + groups: None, + metadata: None, + sub_rules: Default::default(), + }; + + let converted = RuleSet::try_from(rs).expect("studio ruleset should convert"); + let step = converted + .steps + .get("done") + .expect("terminal step should exist"); + match &step.kind { + StepKind::Terminal { result } => assert_eq!(result.message, "plain message"), + other => panic!("expected terminal step, got {other:?}"), + } + } + + #[test] + fn test_simple_decision_ruleset() { + let rs = StudioRuleSet { + config: base_config("test"), + start_step_id: "decide".to_string(), + steps: vec![ + StudioStep { + id: "decide".to_string(), + name: "Decide".to_string(), + description: None, + position: None, + kind: StudioStepKind::Decision { + branches: vec![StudioBranch { + id: "b1".to_string(), + label: None, + condition: StudioCondition::Simple { + left: StudioExpr::Variable { + path: "$.age".to_string(), + }, + operator: "gte".to_string(), + right: StudioExpr::Literal { + value: serde_json::Value::Number(18.into()), + value_type: None, + }, + }, + next_step_id: "adult".to_string(), + }], + default_next_step_id: Some("child".to_string()), + }, + }, + terminal_step("adult", "ADULT"), + terminal_step("child", "CHILD"), + ], + sub_rules: Default::default(), + groups: None, + metadata: None, + }; + + let engine: RuleSet = rs.try_into().unwrap(); + assert_eq!(engine.config.entry_step, "decide"); + assert_eq!(engine.steps.len(), 3); + + let decide = engine.steps.get("decide").unwrap(); + match &decide.kind { + StepKind::Decision { + branches, + default_next, + } => { + assert_eq!(branches.len(), 1); + assert_eq!(default_next.as_deref(), Some("child")); + match &branches[0].condition { + Condition::ExpressionString(s) => assert_eq!(s, "age >= 18"), + _ => panic!("expected ExpressionString"), + } + } + _ => panic!("expected Decision"), + } + } + + #[test] + fn test_action_step_assignment() { + let rs = StudioRuleSet { + config: base_config("test_action"), + start_step_id: "act".to_string(), + steps: vec![ + StudioStep { + id: "act".to_string(), + name: "Act".to_string(), + description: None, + position: None, + kind: StudioStepKind::Action { + assignments: vec![StudioAssignment { + name: "result".to_string(), + value: StudioExpr::Literal { + value: serde_json::Value::Number(42.into()), + value_type: None, + }, + }], + external_calls: vec![], + logging: None, + next_step_id: "done".to_string(), + }, + }, + terminal_step("done", "DONE"), + ], + sub_rules: Default::default(), + groups: None, + metadata: None, + }; + + let engine: RuleSet = rs.try_into().unwrap(); + let act = engine.steps.get("act").unwrap(); + match &act.kind { + StepKind::Action { actions, next_step } => { + assert_eq!(next_step, "done"); + assert_eq!(actions.len(), 1); + match &actions[0].kind { + ActionKind::SetVariable { + name, + value: Expr::Literal(v), + } => { + assert_eq!(name, "result"); + // JSON integer 42 deserializes as CoreValue::Int + assert!( + matches!(v, CoreValue::Int(42)) + || matches!(v, CoreValue::Float(f) if (*f - 42.0).abs() < f64::EPSILON) + ); + } + _ => panic!("expected SetVariable"), + } + } + _ => panic!("expected Action"), + } + } + + #[test] + fn test_missing_start_step_error() { + let rs = StudioRuleSet { + config: base_config("bad"), + start_step_id: String::new(), + steps: vec![], + sub_rules: Default::default(), + groups: None, + metadata: None, + }; + assert!(matches!( + RuleSet::try_from(rs), + Err(ConvertError::MissingStartStep) + )); + } + + #[test] + fn test_logical_condition_to_string() { + use crate::types::condition::condition_to_expr_string; + + let cond = StudioCondition::Logical { + operator: "and".to_string(), + conditions: vec![ + StudioCondition::Simple { + left: StudioExpr::Variable { + path: "$.age".to_string(), + }, + operator: "gt".to_string(), + right: StudioExpr::Literal { + value: 18.into(), + value_type: None, + }, + }, + StudioCondition::Simple { + left: StudioExpr::Variable { + path: "$.active".to_string(), + }, + operator: "eq".to_string(), + right: StudioExpr::Literal { + value: true.into(), + value_type: None, + }, + }, + ], + }; + + let s = condition_to_expr_string(&cond); + assert_eq!(s, "(age > 18 && active == true)"); + } +} diff --git a/crates/ordo-protocol/src/lib.rs b/crates/ordo-protocol/src/lib.rs new file mode 100644 index 00000000..c6fbaf18 --- /dev/null +++ b/crates/ordo-protocol/src/lib.rs @@ -0,0 +1,15 @@ +//! ordo-protocol — Studio ↔ engine protocol conversion layer. +//! +//! The frontend (Studio) speaks a camelCase JSON format with steps as arrays, +//! structured Condition/Expr objects, and editor metadata like positions and groups. +//! The engine (ordo-core) speaks a snake_case format with steps as a HashMap, +//! and conditions as expression strings or pre-compiled `Expr` ASTs. +//! +//! This crate owns all conversion between the two formats so the frontend can +//! send its natural format and the backend handles the rest. + +pub mod convert; +pub mod types; + +pub use convert::ConvertError; +pub use types::*; diff --git a/crates/ordo-protocol/src/types/condition.rs b/crates/ordo-protocol/src/types/condition.rs new file mode 100644 index 00000000..65ef4800 --- /dev/null +++ b/crates/ordo-protocol/src/types/condition.rs @@ -0,0 +1,86 @@ +//! Studio condition types (mirrors the TypeScript Condition model) + +use serde::{Deserialize, Serialize}; + +use super::expr::{expr_to_string, StudioExpr}; + +/// Studio condition — mirrors the frontend `Condition` union type. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum StudioCondition { + Simple { + left: StudioExpr, + operator: String, + right: StudioExpr, + }, + Logical { + operator: String, + conditions: Vec, + }, + Not { + condition: Box, + }, + Expression { + expression: String, + }, + Constant { + value: bool, + }, +} + +/// Convert a `StudioCondition` to an expression string ordo-core can parse. +pub fn condition_to_expr_string(cond: &StudioCondition) -> String { + match cond { + StudioCondition::Simple { + left, + operator, + right, + } => simple_condition_to_string(left, operator, right), + StudioCondition::Logical { + operator, + conditions, + } => { + if conditions.is_empty() { + return "true".to_string(); + } + let op = match operator.as_str() { + "and" | "&&" => "&&", + "or" | "||" => "||", + other => other, + }; + let parts: Vec = conditions.iter().map(condition_to_expr_string).collect(); + format!("({})", parts.join(&format!(" {} ", op))) + } + StudioCondition::Not { condition } => { + format!("!({})", condition_to_expr_string(condition)) + } + StudioCondition::Expression { expression } => expression.clone(), + StudioCondition::Constant { value } => value.to_string(), + } +} + +fn simple_condition_to_string(left: &StudioExpr, operator: &str, right: &StudioExpr) -> String { + let l = expr_to_string(left); + let r = expr_to_string(right); + + match operator { + "eq" => format!("{} == {}", l, r), + "neq" | "ne" => format!("{} != {}", l, r), + "gt" => format!("{} > {}", l, r), + "gte" => format!("{} >= {}", l, r), + "lt" => format!("{} < {}", l, r), + "lte" => format!("{} <= {}", l, r), + "in" => format!("{} in {}", l, r), + "not_in" => format!("{} not in {}", l, r), + "contains" => format!("{} contains {}", l, r), + "not_contains" => format!("!({} contains {})", l, r), + "is_null" => format!("{} == null", l), + "is_not_null" => format!("{} != null", l), + "is_empty" => format!("{} == \"\"", l), + "is_not_empty" => format!("{} != \"\"", l), + "starts_with" => format!("starts_with({}, {})", l, r), + "ends_with" => format!("ends_with({}, {})", l, r), + "regex" => format!("regex_match({}, {})", l, r), + other => format!("{} {} {}", l, other, r), + } +} diff --git a/crates/ordo-protocol/src/types/expr.rs b/crates/ordo-protocol/src/types/expr.rs new file mode 100644 index 00000000..ca0283af --- /dev/null +++ b/crates/ordo-protocol/src/types/expr.rs @@ -0,0 +1,135 @@ +//! Studio expression types (mirrors the TypeScript Expr model) + +use serde::{Deserialize, Serialize}; + +/// Studio expression — mirrors the frontend `Expr` union type. +/// +/// JSON uses `{ "type": "literal", "value": 42, "valueType": "number" }` etc. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum StudioExpr { + Literal { + value: serde_json::Value, + #[serde(rename = "valueType", default)] + value_type: Option, + }, + Variable { + path: String, + }, + Binary { + op: String, + left: Box, + right: Box, + }, + Unary { + op: String, + operand: Box, + }, + Member { + object: Box, + property: String, + }, + Array { + elements: Vec, + }, + Object { + entries: Vec<(String, StudioExpr)>, + }, + Function { + name: String, + args: Vec, + }, +} + +/// Convert a `StudioExpr` to an expression string that ordo-core can parse. +pub fn expr_to_string(expr: &StudioExpr) -> String { + match expr { + StudioExpr::Literal { value, .. } => json_value_to_literal(value), + StudioExpr::Variable { path } => { + // "$.user.age" → "user.age" (context field) + // "$result" → "$result" (variable, keep $ prefix) + if let Some(stripped) = path.strip_prefix("$.") { + stripped.to_string() + } else { + path.clone() + } + } + StudioExpr::Binary { op, left, right } => { + let op_str = binary_op_display(op); + format!( + "({} {} {})", + expr_to_string(left), + op_str, + expr_to_string(right) + ) + } + StudioExpr::Unary { op, operand } => { + let op_str = match op.as_str() { + "not" | "!" => "!", + "neg" | "-" => "-", + other => other, + }; + format!("{}({})", op_str, expr_to_string(operand)) + } + StudioExpr::Function { name, args } => { + let args_str: Vec = args.iter().map(expr_to_string).collect(); + format!("{}({})", name, args_str.join(", ")) + } + StudioExpr::Member { object, property } => { + format!("{}.{}", expr_to_string(object), property) + } + StudioExpr::Array { elements } => { + let items: Vec = elements.iter().map(expr_to_string).collect(); + format!("[{}]", items.join(", ")) + } + StudioExpr::Object { .. } => "null".to_string(), + } +} + +fn json_value_to_literal(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) + } + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Array(arr) => { + let items: Vec = arr.iter().map(json_value_to_literal).collect(); + format!("[{}]", items.join(", ")) + } + serde_json::Value::Object(_) => "null".to_string(), + } +} + +/// Map a studio operator string to an engine-parseable operator string. +/// +/// Returns `None` for unknown operators (caller should handle the raw string). +pub(crate) fn map_binary_op(op: &str) -> Option<&'static str> { + match op { + "add" | "+" => Some("+"), + "sub" | "-" => Some("-"), + "mul" | "*" => Some("*"), + "div" | "/" => Some("/"), + "mod" | "%" => Some("%"), + "eq" | "==" => Some("=="), + "neq" | "ne" | "!=" => Some("!="), + "gt" | ">" => Some(">"), + "gte" | ">=" => Some(">="), + "lt" | "<" => Some("<"), + "lte" | "<=" => Some("<="), + "and" | "&&" => Some("&&"), + "or" | "||" => Some("||"), + "in" => Some("in"), + "not_in" => Some("not in"), + "contains" => Some("contains"), + _ => None, + } +} + +/// Map a studio binary op string to its display form (falls back to the raw string). +pub(crate) fn binary_op_display(op: &str) -> String { + map_binary_op(op) + .map(str::to_string) + .unwrap_or_else(|| op.to_string()) +} diff --git a/crates/ordo-protocol/src/types/mod.rs b/crates/ordo-protocol/src/types/mod.rs new file mode 100644 index 00000000..bd317aed --- /dev/null +++ b/crates/ordo-protocol/src/types/mod.rs @@ -0,0 +1,12 @@ +pub mod condition; +pub mod expr; +pub mod ruleset; +pub mod step; + +pub use condition::StudioCondition; +pub use expr::StudioExpr; +pub use ruleset::{StudioConfig, StudioRuleSet, StudioSubRuleGraph}; +pub use step::{ + StudioAssignment, StudioBranch, StudioExternalCall, StudioLogging, StudioOutputField, + StudioStep, StudioStepKind, StudioSubRuleBinding, StudioSubRuleOutput, +}; diff --git a/crates/ordo-protocol/src/types/ruleset.rs b/crates/ordo-protocol/src/types/ruleset.rs new file mode 100644 index 00000000..f21f9110 --- /dev/null +++ b/crates/ordo-protocol/src/types/ruleset.rs @@ -0,0 +1,56 @@ +//! Studio ruleset types (mirrors the TypeScript RuleSet / RuleSetConfig model) + +use hashbrown::HashMap; +use serde::{Deserialize, Serialize}; + +use super::step::StudioStep; + +/// Top-level studio ruleset — what the frontend sends and stores. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StudioRuleSet { + pub config: StudioConfig, + pub start_step_id: String, + pub steps: Vec, + /// Named inline sub-rule graphs + #[serde(default)] + pub sub_rules: HashMap, + /// Visual groups — stored as-is, not used during execution + #[serde(default, skip_serializing_if = "Option::is_none")] + pub groups: Option, + /// Ruleset-level metadata + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Ruleset configuration block. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StudioConfig { + pub name: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub enable_trace: Option, + /// Timeout in milliseconds + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub input_schema: Option, + #[serde(default)] + pub output_schema: Option, + #[serde(default)] + pub metadata: HashMap, +} + +/// An inline sub-rule graph embedded in a ruleset. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StudioSubRuleGraph { + pub entry_step: String, + pub steps: Vec, +} diff --git a/crates/ordo-protocol/src/types/step.rs b/crates/ordo-protocol/src/types/step.rs new file mode 100644 index 00000000..968581fb --- /dev/null +++ b/crates/ordo-protocol/src/types/step.rs @@ -0,0 +1,134 @@ +//! Studio step types (mirrors the TypeScript Step model) + +use serde::{Deserialize, Serialize}; + +use super::condition::StudioCondition; +use super::expr::StudioExpr; + +/// A step in the studio format. +/// +/// The `type` field is flattened from `StudioStepKind` and discriminates the step kind. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StudioStep { + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, + // position is ignored during conversion (visual-only) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, + #[serde(flatten)] + pub kind: StudioStepKind, +} + +/// Discriminated step kind — `type` field in JSON. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum StudioStepKind { + Decision { + #[serde(default)] + branches: Vec, + #[serde(rename = "defaultNextStepId", default)] + default_next_step_id: Option, + }, + Action { + #[serde(default)] + assignments: Vec, + #[serde(rename = "externalCalls", default)] + external_calls: Vec, + #[serde(default)] + logging: Option, + #[serde(rename = "nextStepId")] + next_step_id: String, + }, + Terminal { + code: String, + #[serde(default)] + message: Option, + #[serde(default)] + output: Vec, + }, + #[serde(rename = "sub_rule")] + SubRule { + #[serde(rename = "refName")] + ref_name: String, + #[serde(default)] + bindings: Vec, + #[serde(default)] + outputs: Vec, + #[serde(rename = "nextStepId")] + next_step_id: String, + }, +} + +/// Terminal message accepts both the modern expression object shape and the +/// legacy plain string shape for backward compatibility. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum StudioTerminalMessage { + Expr(StudioExpr), + String(String), +} + +/// A branch in a decision step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StudioBranch { + pub id: String, + #[serde(default)] + pub label: Option, + pub condition: StudioCondition, + #[serde(rename = "nextStepId")] + pub next_step_id: String, +} + +/// A variable assignment in an action step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StudioAssignment { + pub name: String, + pub value: StudioExpr, +} + +/// An external service call in an action step. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StudioExternalCall { + #[serde(rename = "type")] + pub call_type: String, + pub target: String, + #[serde(default)] + pub params: std::collections::HashMap, + #[serde(default)] + pub result_variable: Option, + #[serde(default)] + pub timeout: Option, +} + +/// Logging configuration in an action step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StudioLogging { + pub message: StudioExpr, + #[serde(default)] + pub level: Option, +} + +/// An output field in a terminal step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StudioOutputField { + pub name: String, + pub value: StudioExpr, +} + +/// Input binding for a sub-rule step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StudioSubRuleBinding { + pub field: String, + pub expr: StudioExpr, +} + +/// Output mapping for a sub-rule step. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StudioSubRuleOutput { + pub parent_var: String, + pub child_var: String, +} diff --git a/crates/ordo-server/src/capability_registry.rs b/crates/ordo-server/src/capability_registry.rs index 16fefd6b..9c0238fa 100644 --- a/crates/ordo-server/src/capability_registry.rs +++ b/crates/ordo-server/src/capability_registry.rs @@ -57,6 +57,10 @@ impl CapabilityInvoker for ServerCapabilityRegistry { fn describe(&self, capability: &str) -> Option { self.inner.describe(capability) } + + fn list_capabilities(&self) -> Vec { + self.inner.list_capabilities() + } } pub fn build_server_capability_invoker( @@ -188,6 +192,7 @@ impl CapabilityProvider for PrometheusMetricCapability { CapabilityDescriptor::new("metrics.prometheus", CapabilityCategory::Action) .with_description("Bridge capability calls into the Prometheus rule metric sink") .with_config(CapabilityConfig::new(CapabilityCategory::Action)) + .with_operations(["counter", "gauge"]) } fn invoke(&self, request: &CapabilityRequest) -> Result { @@ -222,6 +227,7 @@ impl CapabilityProvider for AuditCapability { CapabilityDescriptor::new("audit.logger", CapabilityCategory::Action) .with_description("Bridge capability calls into the structured audit logger") .with_config(CapabilityConfig::new(CapabilityCategory::Action)) + .with_operations(["emit", "rule_executed"]) } fn invoke(&self, request: &CapabilityRequest) -> Result { @@ -270,6 +276,7 @@ impl CapabilityProvider for HttpCapability { CapabilityDescriptor::new("network.http", CapabilityCategory::Network) .with_description("Issue outbound HTTP requests through a capability provider") .with_config(CapabilityConfig::new(CapabilityCategory::Network).timeout(10_000)) + .with_operations(["GET", "POST", "PUT", "DELETE", "PATCH"]) } fn invoke(&self, request: &CapabilityRequest) -> Result { diff --git a/crates/ordo-server/src/debug/api.rs b/crates/ordo-server/src/debug/api.rs index edd3f89a..f0cf4cdd 100644 --- a/crates/ordo-server/src/debug/api.rs +++ b/crates/ordo-server/src/debug/api.rs @@ -154,6 +154,7 @@ fn build_rule_trace_with_types( ordo_core::prelude::StepKind::Decision { .. } => "decision", ordo_core::prelude::StepKind::Action { .. } => "action", ordo_core::prelude::StepKind::Terminal { .. } => "terminal", + ordo_core::prelude::StepKind::SubRule { .. } => "sub_rule", }) .unwrap_or("unknown") .to_string(); diff --git a/crates/ordo-server/src/main.rs b/crates/ordo-server/src/main.rs index 78a9c440..d01d6e06 100644 --- a/crates/ordo-server/src/main.rs +++ b/crates/ordo-server/src/main.rs @@ -649,6 +649,10 @@ async fn main() -> anyhow::Result<()> { let log_url = platform_url.clone(); let log_name = http_server_name.clone(); + let http_capabilities_json = + serde_json::to_value(http_capability_invoker.list_capabilities()) + .unwrap_or(serde_json::json!([])); + tokio::spawn(async move { let client = reqwest::Client::builder() .timeout(Duration::from_secs(5)) @@ -663,6 +667,7 @@ async fn main() -> anyhow::Result<()> { "url": http_server_url, "token": http_token, "version": http_version, + "capabilities": http_capabilities_json, }); let hb_payload = serde_json::json!({ "server_id": http_server_id }); @@ -723,6 +728,7 @@ async fn main() -> anyhow::Result<()> { let nats_server_name = server_name.clone(); let nats_server_url = server_url.clone(); let nats_version = version.clone(); + let nats_capabilities = capability_invoker.list_capabilities(); let log_name2 = nats_server_name.clone(); let log_url2 = nats_server_url.clone(); @@ -735,6 +741,7 @@ async fn main() -> anyhow::Result<()> { token: String::new(), version: Some(nats_version.clone()), org_id: None, + capabilities: nats_capabilities, }; let mut interval = tokio::time::interval(Duration::from_secs(30)); diff --git a/crates/ordo-server/src/sync/event.rs b/crates/ordo-server/src/sync/event.rs index 400ea965..f7751176 100644 --- a/crates/ordo-server/src/sync/event.rs +++ b/crates/ordo-server/src/sync/event.rs @@ -3,6 +3,7 @@ //! Events are published by the writer instance after successful mutations //! and consumed by reader instances to update their in-memory caches. +use ordo_core::prelude::CapabilityDescriptor; use serde::{Deserialize, Serialize}; /// A sync event describing a mutation that occurred on the writer instance. @@ -44,6 +45,8 @@ pub enum SyncEvent { token: String, version: Option, org_id: Option, + #[serde(default)] + capabilities: Vec, }, /// A server instance sent a heartbeat. ServerHeartbeat { diff --git a/crates/ordo-wasm/src/lib.rs b/crates/ordo-wasm/src/lib.rs index 3724a4d2..e2c05d6a 100644 --- a/crates/ordo-wasm/src/lib.rs +++ b/crates/ordo-wasm/src/lib.rs @@ -517,84 +517,167 @@ fn analyze_ruleset_jit_compatibility(ruleset: &RuleSet) -> JITRulesetAnalysis { let mut incompatible_count = 0; let mut all_fields: std::collections::HashMap> = std::collections::HashMap::new(); + let mut visited_sub_rules = std::collections::HashSet::new(); + + #[allow(clippy::too_many_arguments)] + fn record_analysis( + expressions: &mut Vec, + compatible_count: &mut usize, + incompatible_count: &mut usize, + all_fields: &mut std::collections::HashMap>, + step_id: &str, + step_name: &str, + location: String, + expression: String, + analysis: JITExprAnalysis, + ) { + for field in &analysis.accessed_fields { + all_fields + .entry(field.clone()) + .or_default() + .push(step_id.to_string()); + } - // Analyze each step - for (step_id, step) in &ruleset.steps { - match &step.kind { - StepKind::Decision { branches, .. } => { - for (branch_idx, branch) in branches.iter().enumerate() { - // Analyze the condition - let (expr_opt, expr_str) = match &branch.condition { - Condition::Always => (None, "true".to_string()), - Condition::Expression(expr) => (Some(expr.clone()), format!("{:?}", expr)), - Condition::ExpressionString(s) => match ExprParser::parse(s) { - Ok(expr) => (Some(expr), s.clone()), - Err(_) => (None, s.clone()), - }, - }; - - if let Some(expr) = expr_opt { - let analysis = analyze_expr_jit_compatibility(&expr); + if analysis.jit_compatible { + *compatible_count += 1; + } else { + *incompatible_count += 1; + } - // Track fields - for field in &analysis.accessed_fields { - all_fields - .entry(field.clone()) - .or_default() - .push(step_id.clone()); - } + expressions.push(JITExpressionEntry { + step_id: step_id.to_string(), + step_name: step_name.to_string(), + location, + expression, + analysis, + }); + } - if analysis.jit_compatible { - compatible_count += 1; - } else { - incompatible_count += 1; + #[allow(clippy::too_many_arguments)] + fn analyze_step_collection( + steps: Vec<(&String, &Step)>, + ruleset: &RuleSet, + namespace: Option<&str>, + expressions: &mut Vec, + compatible_count: &mut usize, + incompatible_count: &mut usize, + all_fields: &mut std::collections::HashMap>, + visited_sub_rules: &mut std::collections::HashSet, + ) { + for (step_id, step) in steps { + let scoped_step_id = namespace + .map(|ns| format!("{ns}::{step_id}")) + .unwrap_or_else(|| step_id.to_string()); + + match &step.kind { + StepKind::Decision { branches, .. } => { + for (branch_idx, branch) in branches.iter().enumerate() { + let (expr_opt, expr_str) = match &branch.condition { + Condition::Always => (None, "true".to_string()), + Condition::Expression(expr) => { + (Some(expr.clone()), format!("{:?}", expr)) + } + Condition::ExpressionString(s) => match ExprParser::parse(s) { + Ok(expr) => (Some(expr), s.clone()), + Err(_) => (None, s.clone()), + }, + }; + + if let Some(expr) = expr_opt { + let analysis = analyze_expr_jit_compatibility(&expr); + record_analysis( + expressions, + compatible_count, + incompatible_count, + all_fields, + &scoped_step_id, + &step.name, + format!("branch:{branch_idx}"), + expr_str, + analysis, + ); } - - expressions.push(JITExpressionEntry { - step_id: step_id.clone(), - step_name: step.name.clone(), - location: format!("branch:{}", branch_idx), - expression: expr_str, - analysis, - }); } } - } - StepKind::Action { actions, .. } => { - for action in actions { - if let ActionKind::SetVariable { name: _, value } = &action.kind { - // value is an Expr, analyze it directly - let analysis = analyze_expr_jit_compatibility(value); - - for field in &analysis.accessed_fields { - all_fields - .entry(field.clone()) - .or_default() - .push(step_id.clone()); + StepKind::Action { actions, .. } => { + for action in actions { + if let ActionKind::SetVariable { name: _, value } = &action.kind { + let analysis = analyze_expr_jit_compatibility(value); + record_analysis( + expressions, + compatible_count, + incompatible_count, + all_fields, + &scoped_step_id, + &step.name, + "assignment".to_string(), + format!("{:?}", value), + analysis, + ); } - - if analysis.jit_compatible { - compatible_count += 1; - } else { - incompatible_count += 1; + } + } + StepKind::Terminal { .. } => { + // Terminal steps typically don't have complex expressions to analyze. + } + StepKind::SubRule { + ref_name, bindings, .. + } => { + for (binding_name, expr) in bindings { + let mut analysis = analyze_expr_jit_compatibility(expr); + if !analysis + .unsupported_features + .contains(&"sub_rule_binding".to_string()) + { + analysis + .unsupported_features + .push("sub_rule_binding".to_string()); + analysis.jit_compatible = false; } - expressions.push(JITExpressionEntry { - step_id: step_id.clone(), - step_name: step.name.clone(), - location: "assignment".to_string(), - expression: format!("{:?}", value), + record_analysis( + expressions, + compatible_count, + incompatible_count, + all_fields, + &scoped_step_id, + &step.name, + format!("sub_rule_binding:{binding_name}"), + format!("{:?}", expr), analysis, - }); + ); + } + + if visited_sub_rules.insert(ref_name.clone()) { + if let Some(graph) = ruleset.sub_rules.get(ref_name.as_str()) { + analyze_step_collection( + graph.steps.iter().collect(), + ruleset, + Some(ref_name.as_str()), + expressions, + compatible_count, + incompatible_count, + all_fields, + visited_sub_rules, + ); + } } } } - StepKind::Terminal { .. } => { - // Terminal steps typically don't have complex expressions to analyze - } } } + analyze_step_collection( + ruleset.steps.iter().collect(), + ruleset, + None, + &mut expressions, + &mut compatible_count, + &mut incompatible_count, + &mut all_fields, + &mut visited_sub_rules, + ); + let total = compatible_count + incompatible_count; let overall_compatible = incompatible_count == 0 && total > 0; @@ -780,3 +863,85 @@ mod tests { assert_eq!(result_obj.code, "SUCCESS"); } } + +#[cfg(test)] +mod native_tests { + use super::*; + use ordo_core::context::Value; + use ordo_core::expr::{BinaryOp, Expr}; + use ordo_core::rule::{Branch, Step, StepKind, SubRuleGraph, TerminalResult}; + + #[test] + fn jit_analysis_includes_sub_rule_bindings_and_nested_steps() { + let mut ruleset = RuleSet::new("sub-rule-jit", "start"); + ruleset.add_step(Step { + id: "start".to_string(), + name: "Start".to_string(), + kind: StepKind::SubRule { + ref_name: "eligibility".to_string(), + bindings: vec![( + "amount".to_string(), + Expr::Binary { + left: Box::new(Expr::Field("input.amount".to_string())), + right: Box::new(Expr::Literal(Value::int(10))), + op: BinaryOp::Add, + }, + )], + outputs: vec![("approved".to_string(), "approved".to_string())], + next_step: "done".to_string(), + }, + }); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("SUCCESS") + .with_output("approved", Expr::Field("approved".to_string())), + )); + ruleset.add_sub_rule( + "eligibility", + SubRuleGraph { + entry_step: "check".to_string(), + steps: [ + ( + "check".to_string(), + Step { + id: "check".to_string(), + name: "Check".to_string(), + kind: StepKind::Decision { + branches: vec![Branch { + condition: Condition::Expression(Expr::Binary { + left: Box::new(Expr::Field("amount".to_string())), + right: Box::new(Expr::Literal(Value::int(100))), + op: BinaryOp::Gt, + }), + next_step: "accept".to_string(), + actions: vec![], + }], + default_next: Some("accept".to_string()), + }, + }, + ), + ( + "accept".to_string(), + Step::terminal("accept", "Accept", TerminalResult::new("SUCCESS")), + ), + ] + .into_iter() + .collect(), + }, + ); + + let analysis = analyze_ruleset_jit_compatibility(&ruleset); + + assert!(analysis + .expressions + .iter() + .any(|entry| entry.location == "sub_rule_binding:amount")); + assert!(analysis + .expressions + .iter() + .any(|entry| entry.step_id == "eligibility::check")); + assert_eq!(analysis.total_expressions, 2); + assert_eq!(analysis.incompatible_count, 1); + } +} diff --git a/deploy/nomad/devcontainer-entrypoint.sh b/deploy/nomad/devcontainer-entrypoint.sh index 3563d24d..edd1bd83 100755 --- a/deploy/nomad/devcontainer-entrypoint.sh +++ b/deploy/nomad/devcontainer-entrypoint.sh @@ -33,6 +33,7 @@ mkdir -p \ : > "$LOG_DIR/ordo-server.log" : > "$LOG_DIR/ordo-platform.log" +: > "$LOG_DIR/ordo-platform-worker.log" : > "$LOG_DIR/ordo-studio.log" export DATA_DIR @@ -148,6 +149,18 @@ start_platform_watch() { PLATFORM_PID=$! } +start_platform_worker_watch() { + cd "$WORKSPACE" + ( + cargo watch \ + -w crates \ + -w Cargo.toml \ + -w Cargo.lock \ + -x "run -p ordo-platform --bin ordo-platform-worker -- --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-worker.log" & + PLATFORM_WORKER_PID=$! +} + start_studio() { cd "$WORKSPACE/ordo-editor/apps/studio" # Pre-warm Vite dep cache so the first proxied request doesn't hit Traefik's timeout. @@ -166,7 +179,7 @@ start_studio() { } shutdown() { - for pid in ${SERVER_PID:-} ${PLATFORM_PID:-} ${STUDIO_PID:-}; do + for pid in ${SERVER_PID:-} ${PLATFORM_PID:-} ${PLATFORM_WORKER_PID:-} ${STUDIO_PID:-}; do if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true fi @@ -191,6 +204,8 @@ wait_for_tcp "$DATABASE_HOST" "$DATABASE_PORT" "postgres" start_platform_watch PIDS+=("$PLATFORM_PID") wait_for_http "http://127.0.0.1:${PLATFORM_PORT}/health" "ordo-platform" +start_platform_worker_watch +PIDS+=("$PLATFORM_WORKER_PID") start_studio PIDS+=("$STUDIO_PID") @@ -198,6 +213,7 @@ echo "==> Devcontainer services started" echo " studio log: $LOG_DIR/ordo-studio.log" echo " server log: $LOG_DIR/ordo-server.log" echo " platform log: $LOG_DIR/ordo-platform.log" +echo " worker log: $LOG_DIR/ordo-platform-worker.log" wait -n "${PIDS[@]}" exit 1 diff --git a/deploy/nomad/ordo-devcontainer.nomad b/deploy/nomad/ordo-devcontainer.nomad index 5d45257a..63a37720 100644 --- a/deploy/nomad/ordo-devcontainer.nomad +++ b/deploy/nomad/ordo-devcontainer.nomad @@ -25,7 +25,7 @@ variable "datacenter" { variable "image" { type = string - default = "ordo-devcontainer:uid1001-watch8" + default = "ordo-devcontainer:local" } variable "node_name" { diff --git a/ordo-editor/apps/docs/.vitepress/config.mts b/ordo-editor/apps/docs/.vitepress/config.mts index 8cb55606..f39eed1d 100644 --- a/ordo-editor/apps/docs/.vitepress/config.mts +++ b/ordo-editor/apps/docs/.vitepress/config.mts @@ -77,6 +77,7 @@ export default withMermaid(defineConfig({ { text: 'Rule Structure', link: '/en/guide/rule-structure' }, { text: 'Expression Syntax', link: '/en/guide/expression-syntax' }, { text: 'Built-in Functions', link: '/en/guide/builtin-functions' }, + { text: 'Execution Model', link: '/en/guide/execution-model' }, ] }, { @@ -171,6 +172,7 @@ export default withMermaid(defineConfig({ { text: '规则结构', link: '/zh/guide/rule-structure' }, { text: '表达式语法', link: '/zh/guide/expression-syntax' }, { text: '内置函数', link: '/zh/guide/builtin-functions' }, + { text: '执行模型', link: '/zh/guide/execution-model' }, ] }, { diff --git a/ordo-editor/apps/docs/en/guide/execution-model.md b/ordo-editor/apps/docs/en/guide/execution-model.md new file mode 100644 index 00000000..dd7d2fb1 --- /dev/null +++ b/ordo-editor/apps/docs/en/guide/execution-model.md @@ -0,0 +1,137 @@ +# Execution Model: VM, JIT, and Host Calls + +JSON rules are not executable by themselves — they are input data. What actually runs is native code compiled into the server binary, plus bytecode VM or JIT code for expression evaluation. + +Ordo's execution chain has four layers: + +1. **Rule data** — JSON generated by the platform +2. **Step scheduler** — native code that walks the step graph +3. **Expression layer** — interpreter, bytecode VM, or JIT +4. **Capability layer** — providers for HTTP, metrics, audit, and other side effects + +Capabilities are not a replacement for the VM. They are host-call boundaries in the execution chain. + +## From JSON to the CPU + +At runtime: + +1. The server reads rule JSON +2. It deserializes it into `RuleSet`, `Step`, and `ActionKind` +3. A native executor schedules and runs steps +4. Expressions inside those steps are evaluated by the expression layer +5. Outbound behavior crosses into capability providers + +Step scheduling is native Rust. VM and JIT handle expression evaluation. Capabilities handle interaction with the outside world. + +## What the step scheduler does + +The step scheduler decides: + +- which step is active +- which branch a decision step should take +- which actions an action step should execute +- when execution reaches a terminal step + +This is a native state machine loop — not the VM. + +## What the expression VM does + +The bytecode VM handles expression evaluation: + +- branch conditions +- the right-hand side of `set_variable` +- metric values +- terminal outputs +- parameter expressions inside `ExternalCall` + +`BytecodeVM` is a classic dispatch loop: load an instruction, branch on the opcode, read or write register slots. The CPU is executing native Rust machine code that happens to interpret expression bytecode. + +Relevant source: + +- [`crates/ordo-core/src/expr/compiler.rs`](https://github.com/Ordo-Engine/Ordo/blob/main/crates/ordo-core/src/expr/compiler.rs) +- [`crates/ordo-core/src/expr/vm.rs`](https://github.com/Ordo-Engine/Ordo/blob/main/crates/ordo-core/src/expr/vm.rs) + +## What the JIT does + +Both the VM and JIT execute native machine code. The difference is that the VM dispatches instruction by instruction, while the JIT emits machine code for hot expressions up front and lets the CPU run that code directly. + +The JIT accelerates pure compute paths: + +- numeric comparisons +- boolean logic +- field access +- expression composition + +It does not perform network IO, audit logging, or metrics emission. + +## What a host call is + +An outbound capability is not just another opcode — it is a host function call. + +The flow: + +1. The executor computes request parameters locally +2. It calls `capability_invoker.invoke(...)` +3. The provider performs the real runtime behavior +4. The result is returned into rule context + +This is the same boundary you see in WASM host imports, Lua calling C functions, JVM calling JNI, or a database execution plan invoking an external function. + +## How `ExternalCall` participates in execution + +In the current execution model, `ExternalCall` works like this: + +1. Read `service`, `method`, and `params` from the action +2. Evaluate each parameter expression +3. Build a `CapabilityRequest` +4. Invoke the capability boundary +5. If `result_variable` is set, write the response back into context + +Implementation: + +- [`crates/ordo-core/src/rule/executor.rs`](https://github.com/Ordo-Engine/Ordo/blob/main/crates/ordo-core/src/rule/executor.rs) +- [`crates/ordo-core/src/rule/compiled_executor.rs`](https://github.com/Ordo-Engine/Ordo/blob/main/crates/ordo-core/src/rule/compiled_executor.rs) + +```mermaid +flowchart TD + A[Rule JSON] --> B[Deserialize to RuleSet] + B --> C[Native step scheduler] + C --> D[Evaluate param expressions] + D --> E[VM or JIT] + E --> F[Build CapabilityRequest] + F --> G[Host call: capability invoker] + G --> H[Capability provider] + H --> I[HTTP / audit / metrics / other runtime] + I --> J[CapabilityResponse] + J --> K[Write result_variable] + K --> L[Continue next step] +``` + +## Example: `network.http` + +When a rule calls `network.http`: + +1. The executor evaluates `url`, `json_body`, and other parameters into `Value` +2. It sends a capability request +3. The `network.http` provider makes the actual HTTP request through `reqwest` +4. The provider wraps the response into `CapabilityResponse` +5. Later rule steps read `$result.payload` + +Real network IO happens in the host layer. The VM or JIT only computes the URL, body, headers, and any expressions over the returned result. Sockets, timeouts, Tokio scheduling, and syscalls belong to the host runtime. + +## What's supported today + +`ExternalCall` now works in both the interpreter path and the compiled executor. Built-in capabilities include `network.http`, `metrics.prometheus`, and `audit.logger`. + +That means a rule can stay on the compiled execution path, keep using the VM or JIT for parameter evaluation, and only cross into the host runtime at the action boundary. + +## Where this should go + +The foundation is now in place. The next step is not "make the VM do HTTP", but to deepen the host-call model across the rest of the platform: + +1. Move more side effects behind capability boundaries +2. Expand compiled executor coverage for host-call actions +3. Strengthen timeout, retry, breaker, and observability policies at the capability layer +4. Keep platform-generated rule models aligned with runtime capability support + +VM and JIT handle fast computation. Capabilities handle crossing the engine boundary. They are complementary, not competing. diff --git a/ordo-editor/apps/docs/zh/guide/execution-model.md b/ordo-editor/apps/docs/zh/guide/execution-model.md new file mode 100644 index 00000000..90c87d93 --- /dev/null +++ b/ordo-editor/apps/docs/zh/guide/execution-model.md @@ -0,0 +1,135 @@ +# 执行模型:VM、JIT 与 Host Call + +JSON 规则本身不会被 CPU 直接执行,它只是输入数据。真正执行的是 server 里已经编译好的本地代码,以及在表达式层出现的 VM 或 JIT 代码。 + +整个执行链可以拆成 4 层: + +1. **规则数据层** — 平台生成的 JSON 规则 +2. **步骤调度层** — 负责遍历 step 图的原生代码 +3. **表达式执行层** — 解释器、字节码 VM、或 JIT +4. **宿主能力层** — capability provider,负责 HTTP、metrics、audit 等副作用 + +`capability` 不是 VM 的替代品,而是执行链中的 host call 边界。 + +## 从 JSON 到 CPU + +运行时真正发生的是: + +1. server 读取 JSON +2. 反序列化成 `RuleSet` / `Step` / `ActionKind` +3. 本地执行器按 step 调度执行 +4. step 中需要求值的表达式交给表达式层 +5. 遇到外部能力时切到 capability provider + +step 调度本身是原生 Rust 代码;VM/JIT 主要负责表达式求值;capability 负责和外部世界交互。 + +## 步骤调度层在做什么 + +步骤调度层决定: + +- 当前在哪个 step +- decision step 该走哪条 branch +- action step 需要执行哪些 action +- 什么时候到 terminal step 返回结果 + +这一层本质上是一个原生状态机循环,不是 VM。 + +## 表达式 VM 在做什么 + +字节码 VM 只负责表达式求值: + +- branch condition +- `set_variable` 右边的表达式 +- `metric` action 的 value +- terminal output +- `ExternalCall` 参数中的表达式 + +`BytecodeVM` 是一个典型的 dispatch loop:读取一条指令,按 opcode 分支,再读写寄存器槽。CPU 真正执行的仍然是 Rust 编译出来的机器码,只是这些机器码解释的是另一层表达式字节码。 + +对应源码: + +- [`crates/ordo-core/src/expr/compiler.rs`](https://github.com/Ordo-Engine/Ordo/blob/main/crates/ordo-core/src/expr/compiler.rs) +- [`crates/ordo-core/src/expr/vm.rs`](https://github.com/Ordo-Engine/Ordo/blob/main/crates/ordo-core/src/expr/vm.rs) + +## JIT 在做什么 + +JIT 和 VM 都在执行本地机器码,区别在于有没有 dispatch loop。VM 每一步都要取指、分发、执行;JIT 则把热表达式提前编译成一段机器码,CPU 直接运行。 + +JIT 优化的是纯计算路径: + +- 数值比较 +- 布尔判断 +- 字段访问 +- 表达式组合 + +JIT 不负责做网络、日志、指标这些副作用。 + +## Host Call 是什么 + +外部能力的本质不是"规则语言的一条普通指令",而是一次宿主函数调用: + +1. 执行器在本地把参数算好 +2. 调用 `capability_invoker.invoke(...)` +3. provider 在宿主运行时里完成真实行为 +4. 再把结果返回给规则上下文 + +这和 WASM 调 host import、Lua 调 C function、JVM 调 JNI、数据库执行计划调外部函数是同一种边界。 + +## `ExternalCall` 在执行链里怎么参与 + +当前执行链里,`ExternalCall` 会: + +1. 从 action 里读取 `service`、`method`、`params` +2. 逐个求值参数表达式 +3. 组装 `CapabilityRequest` +4. 调用 capability invoker +5. 如果配置了 `result_variable`,把响应写回上下文 + +对应实现见: + +- [`crates/ordo-core/src/rule/executor.rs`](https://github.com/Ordo-Engine/Ordo/blob/main/crates/ordo-core/src/rule/executor.rs) +- [`crates/ordo-core/src/rule/compiled_executor.rs`](https://github.com/Ordo-Engine/Ordo/blob/main/crates/ordo-core/src/rule/compiled_executor.rs) + +```mermaid +flowchart TD + A[Rule JSON] --> B[Deserialize to RuleSet] + B --> C[Native step scheduler] + C --> D[Evaluate param expressions] + D --> E[VM or JIT] + E --> F[Build CapabilityRequest] + F --> G[Host call: capability invoker] + G --> H[Capability provider] + H --> I[HTTP / audit / metrics / other runtime] + I --> J[CapabilityResponse] + J --> K[Write result_variable] + K --> L[Continue next step] +``` + +## 以 `network.http` 为例 + +如果规则里调用的是 `network.http`: + +1. 执行器先把 `url`、`json_body` 等参数表达式算成 `Value` +2. 然后发出 capability 请求 +3. `network.http` provider 用 `reqwest` 发起真实 HTTP 请求 +4. provider 把响应包装成 `CapabilityResponse` +5. 规则后续步骤再继续读取 `$result.payload` + +真正的网络 IO 发生在 host 层,而不是 VM 里。VM/JIT 只负责计算 URL、body、headers,以及对返回值求值。HTTP socket、超时、Tokio runtime、syscall 都属于宿主运行时。 + +## 目前支持什么 + +解释执行和 compiled executor 现在都支持 `ExternalCall`,内置 capability 包括 `network.http`、`metrics.prometheus`、`audit.logger`。 + +这意味着规则图仍然可以走编译执行链,参数表达式继续用 VM/JIT 计算,外部调用只在 action 节点跨到 host capability。 + +## 长期应该走向哪里 + +当前已经具备 host-call action 的基础形态,后续重点不再是“能不能调 capability”,而是继续把这条链路和平台能力做深: + +1. 让更多外部副作用统一走 capability 边界 +2. 继续扩展 compiled executor 对宿主调用的覆盖面 +3. 在 capability 层补齐更强的超时、重试、熔断与观测 +4. 让平台生成模型和运行时能力保持一一对应 + +VM/JIT 负责算得快,capability 负责跨出引擎边界,两者是衔接关系。 diff --git a/ordo-editor/apps/studio/src/api/platform-client.ts b/ordo-editor/apps/studio/src/api/platform-client.ts index a462ba57..a7963969 100644 --- a/ordo-editor/apps/studio/src/api/platform-client.ts +++ b/ordo-editor/apps/studio/src/api/platform-client.ts @@ -3,6 +3,7 @@ * Handles auth, organizations, projects, and members. */ +import type { RuleSet } from '@ordo-engine/editor-core'; import { i18n } from '@/i18n'; import type { AppendRulesetHistoryEntry, @@ -36,6 +37,7 @@ import type { ReleaseExecutionEvent, ReleasePolicy, ReleaseRequest, + ReleaseRequestHistoryEntry, ReleaseTargetPreview, ReviewReleaseRequest, Role, @@ -615,6 +617,23 @@ export const rulesetDraftApi = { }); }, + convert( + token: string, + orgId: string, + projectId: string, + name: string, + ruleset: RuleSet + ): Promise> { + return request( + `/orgs/${orgId}/projects/${projectId}/rulesets/${encodeURIComponent(name)}/convert`, + { + method: 'POST', + token, + body: JSON.stringify({ ruleset }), + } + ); + }, + /** Returns the saved draft on success, or a DraftConflictResponse (status 409) on conflict. */ async save( token: string, @@ -865,6 +884,17 @@ export const releaseApi = { return request(`/orgs/${orgId}/projects/${projectId}/releases/${releaseId}`, { token }); }, + getRequestHistory( + token: string, + orgId: string, + projectId: string, + releaseId: string + ): Promise { + return request(`/orgs/${orgId}/projects/${projectId}/releases/${releaseId}/history`, { + token, + }); + }, + executeRequest( token: string, orgId: string, @@ -985,7 +1015,7 @@ export const engineApi = { projectId: string, rulesetName: string, input: Record, - ruleset: Record + ruleset: RuleSet ): Promise<{ code: string; message: string; diff --git a/ordo-editor/apps/studio/src/api/types.ts b/ordo-editor/apps/studio/src/api/types.ts index 9fe8b423..9430de39 100644 --- a/ordo-editor/apps/studio/src/api/types.ts +++ b/ordo-editor/apps/studio/src/api/types.ts @@ -85,6 +85,16 @@ export interface Project { export type ServerStatus = 'online' | 'offline' | 'degraded'; +export interface CapabilityInfo { + name: string; + description: string; + operations: string[]; + config: { + category: 'network' | 'compute' | 'action'; + timeout_ms: number | null; + }; +} + export interface ServerInfo { id: string; name: string; @@ -95,6 +105,7 @@ export interface ServerInfo { status: ServerStatus; last_seen: string | null; registered_at: string; + capabilities: CapabilityInfo[]; } export interface BindServerRequest { @@ -495,6 +506,7 @@ export type ReleaseRequestStatus = | 'executing' | 'completed' | 'failed' + | 'rollback_failed' | 'rolled_back'; export type ReleaseApprovalDecision = 'pending' | 'approved' | 'rejected'; @@ -607,6 +619,9 @@ export interface ReleaseRequest { version_diff: ReleaseVersionDiff; content_diff: ReleaseContentDiffSummary; request_snapshot: ReleaseRequestSnapshot; + execution_attempts: number; + max_execution_attempts: number; + is_closed: boolean; } export interface ReleaseTargetServerPreview { @@ -654,11 +669,14 @@ export type ReleaseExecutionStatus = | 'paused' | 'verifying' | 'rollback_in_progress' + | 'rollback_failed' | 'completed' | 'failed'; export type ReleaseInstanceStatus = | 'pending' + | 'waiting_batch' + | 'scheduled' | 'dispatching' | 'updating' | 'verifying' @@ -671,9 +689,11 @@ export interface ReleaseExecutionInstance { id: string; instance_name: string; zone?: string; + batch_index: number; current_version: string; target_version: string; status: ReleaseInstanceStatus; + scheduled_at?: string; updated_at?: string; message?: string; metric_summary?: { @@ -695,6 +715,33 @@ export interface ReleaseExecutionEvent { created_at: string; } +export type ReleaseHistoryScope = + | 'request' + | 'approval' + | 'execution' + | 'batch' + | 'instance' + | 'rollback'; + +export type ReleaseHistoryActorType = 'user' | 'system' | 'server'; + +export interface ReleaseRequestHistoryEntry { + id: string; + release_request_id: string; + release_execution_id?: string | null; + instance_id?: string | null; + scope: ReleaseHistoryScope; + action: string; + actor_type: ReleaseHistoryActorType; + actor_id?: string | null; + actor_name?: string | null; + actor_email?: string | null; + from_status?: string | null; + to_status?: string | null; + detail: Record; + created_at: string; +} + export interface PlatformNotification { id: string; org_id: string; @@ -724,6 +771,7 @@ export interface ReleaseExecution { started_at: string; current_batch: number; total_batches: number; + next_batch_at?: string; strategy: RolloutStrategy; summary: { total_instances: number; diff --git a/ordo-editor/apps/studio/src/components/trace/RuleTraceRunner.vue b/ordo-editor/apps/studio/src/components/trace/RuleTraceRunner.vue index 1e4fd8c2..06bbf70c 100644 --- a/ordo-editor/apps/studio/src/components/trace/RuleTraceRunner.vue +++ b/ordo-editor/apps/studio/src/components/trace/RuleTraceRunner.vue @@ -2,10 +2,9 @@ import { computed, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { MessagePlugin } from 'tdesign-vue-next'; -import { convertToEngineFormat } from '@ordo-engine/editor-core'; import { engineApi, rulesetDraftApi, testApi } from '@/api/platform-client'; import { useAuthStore } from '@/stores/auth'; -import { isEngineRuleset, normalizeRuleset } from '@/utils/ruleset'; +import { normalizeRuleset } from '@/utils/ruleset'; import type { ProjectRulesetMeta, TestCase } from '@/api/types'; const props = defineProps<{ @@ -198,18 +197,15 @@ async function runTrace() { rightTab.value = 'result'; try { - // Fetch draft and convert from editor format to engine format using the adapter. + // Fetch draft (stored in studio format) and send to trace endpoint. + // The backend (ordo-protocol) handles format conversion to the engine. const draft = await rulesetDraftApi.get( auth.token, props.orgId, props.projectId, selectedRuleset.value ); - const engineRuleset = isEngineRuleset(draft.draft) - ? (draft.draft as unknown as Record) - : (convertToEngineFormat( - normalizeRuleset(draft.draft, selectedRuleset.value) - ) as unknown as Record); + const studioRuleset = normalizeRuleset(draft.draft, selectedRuleset.value); result.value = await engineApi.executeWithTrace( auth.token, @@ -217,7 +213,7 @@ async function runTrace() { props.projectId, selectedRuleset.value, parsed, - engineRuleset + studioRuleset ); addHistory({ rulesetName: selectedRuleset.value, diff --git a/ordo-editor/apps/studio/src/i18n/locales/en.ts b/ordo-editor/apps/studio/src/i18n/locales/en.ts index 4602e37a..fe05c4d6 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/en.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/en.ts @@ -1070,6 +1070,7 @@ export default { tabApproval: 'Approval', tabDiff: 'Diff', tabExecution: 'Execution', + tabHistory: 'History', backToRequests: 'Back to Requests', requestDetailTitle: 'Release Request Detail', requestDetailSubtitle: @@ -1132,8 +1133,21 @@ export default { pauseActionFailed: 'Failed to pause release execution', resumeActionSuccess: 'Release execution resumed', resumeActionFailed: 'Failed to resume release execution', + rollbackDialog: 'Confirm rollback', + rollbackConfirm: + 'Are you sure you want to roll back release request "{title}"? Instances will be rolled back in batches to version {version}.', + rollbackConfirmBtn: 'Confirm rollback', rollbackActionSuccess: 'Rollback started', rollbackActionFailed: 'Failed to start rollback', + requestClosed: 'Closed', + requestClosedHint: + 'This release request is terminal and can no longer be executed or rolled back.', + executionAttempts: 'Execution attempts', + requestLifecycle: 'Lifecycle', + requestLifecycleOpen: 'Open', + requestLifecycleClosed: 'Closed', + retryLimitReachedHint: + 'This release request has reached the maximum retry limit ({count}) and cannot run again.', policyCreated: 'Release policy created', policyCreateFailed: 'Failed to create release policy', approveAction: 'Approve', @@ -1186,6 +1200,7 @@ export default { executing: 'Executing', completed: 'Completed', failed: 'Failed', + rollback_failed: 'Rollback failed', rolled_back: 'Rolled back', }, approvalMap: { @@ -1196,12 +1211,73 @@ export default { executionConsoleTitle: 'Execution console', elapsedTime: 'Elapsed', batchLabel: 'Batch', + batchIndex: 'Batch index', startedAt: 'Started at', + nextBatchAt: 'Next batch starts', + scheduledAt: 'Scheduled at', + countdown: 'Countdown', createdAt: 'Created', executionCompleted: 'Release completed successfully', executionFailed: 'Release execution failed', + executionRollbackFailed: 'Rollback execution failed', executionRolledBack: 'Release rolling back', requestInfo: 'Request info', + operationHistory: 'Operation history', + historyEmpty: 'No operation history yet', + statusFlow: 'Status flow', + systemActor: 'System', + rawDetail: 'View raw detail', + historyField: { + reason: 'Reason', + ruleset: 'Ruleset', + version: 'Version', + environment: 'Environment', + policy: 'Policy', + batch: 'Batch', + totalBatches: 'Total batches', + instance: 'Instance', + targetInstance: 'Target instance', + rollbackVersion: 'Rollback version', + message: 'Message', + error: 'Error', + nextBatchAt: 'Next batch at', + waitSeconds: 'Wait seconds', + }, + historyActionMap: { + request_created: 'Request created', + request_status_changed: 'Request status changed', + approval_assigned: 'Approval assigned', + approval_reviewed: 'Approval reviewed', + execution_created: 'Execution created', + execution_started: 'Execution started', + execution_status_changed: 'Execution status changed', + execution_paused: 'Execution paused', + execution_resumed: 'Execution resumed', + batch_dispatch_started: 'Batch dispatch started', + batch_feedback_succeeded: 'Batch feedback succeeded', + batch_feedback_failed: 'Batch feedback failed', + batch_publish_failed: 'Batch publish failed', + batch_wait_started: 'Waiting for next batch', + batch_wait_resumed: 'Next batch wait resumed', + instance_initialized: 'Instance initialized', + instance_rollback_queued: 'Instance queued for rollback', + instance_status_changed: 'Instance status changed', + instance_acknowledged: 'Instance acknowledged', + instance_rolled_back: 'Instance rolled back', + instance_failed: 'Instance failed', + rollback_requested: 'Rollback requested', + rollback_completed: 'Rollback completed', + rollback_failed: 'Rollback failed', + auto_rollback_started: 'Auto rollback started', + auto_rollback_scheduled: 'Auto rollback scheduled', + auto_rollback_succeeded: 'Auto rollback succeeded', + auto_rollback_failed: 'Auto rollback failed', + rollback_batch_dispatch_started: 'Rollback batch dispatch started', + rollback_batch_succeeded: 'Rollback batch succeeded', + rollback_batch_failed: 'Rollback batch failed', + rollback_batch_wait_started: 'Waiting for next rollback batch', + rollback_batch_wait_finished: 'Next rollback batch window opened', + }, rolloutStrategyDetails: 'Rollout strategy', rollbackPolicyDetails: 'Rollback policy', autoRollback: 'Auto rollback', @@ -1220,11 +1296,14 @@ export default { paused: 'Paused', verifying: 'Verifying', rollback_in_progress: 'Rolling back', + rollback_failed: 'Rollback failed', completed: 'Completed', failed: 'Failed', }, instanceStatusMap: { pending: 'Pending', + waiting_batch: 'Waiting for batch', + scheduled: 'Scheduled', dispatching: 'Dispatching', updating: 'Updating', verifying: 'Verifying', 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 d935b37b..7dc7e311 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts @@ -1046,6 +1046,7 @@ export default { tabApproval: '审批', tabDiff: '差异', tabExecution: '执行', + tabHistory: '历史', backToRequests: '返回发布单', requestDetailTitle: '发布单详情', requestDetailSubtitle: '在执行前查看发布上下文、审批链和规则差异。', @@ -1105,8 +1106,18 @@ export default { pauseActionFailed: '暂停发布执行失败', resumeActionSuccess: '发布执行已恢复', resumeActionFailed: '恢复发布执行失败', + rollbackDialog: '确认回退', + rollbackConfirm: '确定要回退发布单「{title}」吗?系统会按批次将实例回退到版本 {version}。', + rollbackConfirmBtn: '确认回退', rollbackActionSuccess: '已开始回退', rollbackActionFailed: '启动回退失败', + requestClosed: '已结单', + requestClosedHint: '该发布单已进入终态,不允许再次执行或回退。', + executionAttempts: '执行次数', + requestLifecycle: '生命周期', + requestLifecycleOpen: '进行中/可操作', + requestLifecycleClosed: '已结单', + retryLimitReachedHint: '该发布单已达到最大重试次数({count} 次),不能再次执行。', policyCreated: '发布策略已创建', policyCreateFailed: '创建发布策略失败', approveAction: '批准', @@ -1152,6 +1163,7 @@ export default { executing: '执行中', completed: '已完成', failed: '失败', + rollback_failed: '回退失败', rolled_back: '已回退', }, approvalMap: { @@ -1162,12 +1174,73 @@ export default { executionConsoleTitle: '执行控制台', elapsedTime: '已用时', batchLabel: '批次', + batchIndex: '批次序号', startedAt: '开始时间', + nextBatchAt: '下一批开始', + scheduledAt: '计划开始', + countdown: '倒计时', createdAt: '创建时间', executionCompleted: '发布已成功完成', executionFailed: '发布执行失败', + executionRollbackFailed: '回退执行失败', executionRolledBack: '正在回退', requestInfo: '发布单信息', + operationHistory: '操作历史', + historyEmpty: '暂无历史记录', + statusFlow: '状态流转', + systemActor: '系统', + rawDetail: '查看原始详情', + historyField: { + reason: '原因', + ruleset: '规则集', + version: '版本', + environment: '环境', + policy: '策略', + batch: '批次', + totalBatches: '总批次', + instance: '实例', + targetInstance: '目标实例', + rollbackVersion: '回退版本', + message: '消息', + error: '错误', + nextBatchAt: '下一批时间', + waitSeconds: '等待秒数', + }, + historyActionMap: { + request_created: '创建发布单', + request_status_changed: '发布单状态变更', + approval_assigned: '分配审批', + approval_reviewed: '审批完成', + execution_created: '创建执行任务', + execution_started: '开始执行', + execution_status_changed: '执行状态变更', + execution_paused: '暂停执行', + execution_resumed: '恢复执行', + batch_dispatch_started: '开始批次下发', + batch_feedback_succeeded: '批次执行成功', + batch_feedback_failed: '批次执行失败', + batch_publish_failed: '批次发布失败', + batch_wait_started: '等待下一批', + batch_wait_resumed: '恢复下一批等待', + instance_initialized: '初始化实例', + instance_rollback_queued: '实例加入回退队列', + instance_status_changed: '实例状态变更', + instance_acknowledged: '实例确认成功', + instance_rolled_back: '实例回退完成', + instance_failed: '实例确认失败', + rollback_requested: '发起回退', + rollback_completed: '回退完成', + rollback_failed: '回退失败', + auto_rollback_started: '自动回退开始', + auto_rollback_scheduled: '自动回退已排队', + auto_rollback_succeeded: '自动回退成功', + auto_rollback_failed: '自动回退失败', + rollback_batch_dispatch_started: '开始批次回退', + rollback_batch_succeeded: '批次回退成功', + rollback_batch_failed: '批次回退失败', + rollback_batch_wait_started: '等待下一批回退', + rollback_batch_wait_finished: '下一批回退窗口已开启', + }, rolloutStrategyDetails: '放量策略', rollbackPolicyDetails: '回退策略', autoRollback: '自动回退', @@ -1186,11 +1259,14 @@ export default { paused: '已暂停', verifying: '验证中', rollback_in_progress: '回退中', + rollback_failed: '回退失败', completed: '已完成', failed: '失败', }, instanceStatusMap: { pending: '待执行', + waiting_batch: '等待批次', + scheduled: '已计划', dispatching: '下发中', updating: '更新中', verifying: '验证中', 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 078c1075..bd7ceb67 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts @@ -1046,6 +1046,7 @@ export default { tabApproval: '審批', tabDiff: '差異', tabExecution: '執行', + tabHistory: '歷史', backToRequests: '返回發佈單', requestDetailTitle: '發佈單詳情', requestDetailSubtitle: '在執行前查看發佈上下文、審批鏈與規則差異。', @@ -1105,8 +1106,18 @@ export default { pauseActionFailed: '暫停發佈執行失敗', resumeActionSuccess: '發佈執行已恢復', resumeActionFailed: '恢復發佈執行失敗', + rollbackDialog: '確認回退', + rollbackConfirm: '確定要回退發佈單「{title}」嗎?系統會按批次將實例回退到版本 {version}。', + rollbackConfirmBtn: '確認回退', rollbackActionSuccess: '已開始回退', rollbackActionFailed: '啟動回退失敗', + requestClosed: '已結單', + requestClosedHint: '該發佈單已進入終態,不允許再次執行或回退。', + executionAttempts: '執行次數', + requestLifecycle: '生命週期', + requestLifecycleOpen: '進行中/可操作', + requestLifecycleClosed: '已結單', + retryLimitReachedHint: '該發佈單已達到最大重試次數({count} 次),不能再次執行。', policyCreated: '發佈策略已建立', policyCreateFailed: '建立發佈策略失敗', approveAction: '核准', @@ -1152,6 +1163,7 @@ export default { executing: '執行中', completed: '已完成', failed: '失敗', + rollback_failed: '回退失敗', rolled_back: '已回退', }, approvalMap: { @@ -1162,12 +1174,73 @@ export default { executionConsoleTitle: '執行控制台', elapsedTime: '已用時', batchLabel: '批次', + batchIndex: '批次序號', startedAt: '開始時間', + nextBatchAt: '下一批開始', + scheduledAt: '計畫開始', + countdown: '倒數', createdAt: '建立時間', executionCompleted: '發佈已成功完成', executionFailed: '發佈執行失敗', + executionRollbackFailed: '回退執行失敗', executionRolledBack: '正在回退', requestInfo: '發佈單資訊', + operationHistory: '操作歷史', + historyEmpty: '暫無操作記錄', + statusFlow: '狀態流轉', + systemActor: '系統', + rawDetail: '查看原始詳情', + historyField: { + reason: '原因', + ruleset: '規則集', + version: '版本', + environment: '環境', + policy: '策略', + batch: '批次', + totalBatches: '總批次', + instance: '實例', + targetInstance: '目標實例', + rollbackVersion: '回退版本', + message: '訊息', + error: '錯誤', + nextBatchAt: '下一批時間', + waitSeconds: '等待秒數', + }, + historyActionMap: { + request_created: '建立發佈單', + request_status_changed: '發佈單狀態變更', + approval_assigned: '分配審批', + approval_reviewed: '審批完成', + execution_created: '建立執行任務', + execution_started: '開始執行', + execution_status_changed: '執行狀態變更', + execution_paused: '暫停執行', + execution_resumed: '恢復執行', + batch_dispatch_started: '開始批次下發', + batch_feedback_succeeded: '批次執行成功', + batch_feedback_failed: '批次執行失敗', + batch_publish_failed: '批次發佈失敗', + batch_wait_started: '等待下一批', + batch_wait_resumed: '恢復下一批等待', + instance_initialized: '初始化實例', + instance_rollback_queued: '實例加入回退佇列', + instance_status_changed: '實例狀態變更', + instance_acknowledged: '實例確認成功', + instance_rolled_back: '實例回退完成', + instance_failed: '實例確認失敗', + rollback_requested: '發起回退', + rollback_completed: '回退完成', + rollback_failed: '回退失敗', + auto_rollback_started: '自動回退開始', + auto_rollback_scheduled: '自動回退已排隊', + auto_rollback_succeeded: '自動回退成功', + auto_rollback_failed: '自動回退失敗', + rollback_batch_dispatch_started: '開始批次回退', + rollback_batch_succeeded: '批次回退成功', + rollback_batch_failed: '批次回退失敗', + rollback_batch_wait_started: '等待下一批回退', + rollback_batch_wait_finished: '下一批回退窗口已開啟', + }, rolloutStrategyDetails: '放量策略', rollbackPolicyDetails: '回退策略', autoRollback: '自動回退', @@ -1186,11 +1259,14 @@ export default { paused: '已暫停', verifying: '驗證中', rollback_in_progress: '回退中', + rollback_failed: '回退失敗', completed: '已完成', failed: '失敗', }, instanceStatusMap: { pending: '待執行', + waiting_batch: '等待批次', + scheduled: '已排程', dispatching: '下發中', updating: '更新中', verifying: '驗證中', diff --git a/ordo-editor/apps/studio/src/stores/test.ts b/ordo-editor/apps/studio/src/stores/test.ts index eaeabf1e..04437193 100644 --- a/ordo-editor/apps/studio/src/stores/test.ts +++ b/ordo-editor/apps/studio/src/stores/test.ts @@ -1,8 +1,8 @@ import { defineStore } from 'pinia'; import { ref } from 'vue'; -import { convertToEngineFormat } from '@ordo-engine/editor-core'; +import type { RuleSet } from '@ordo-engine/editor-core'; import { rulesetDraftApi, testApi } from '@/api/platform-client'; -import { isEngineRuleset, normalizeRuleset } from '@/utils/ruleset'; +import { normalizeRuleset } from '@/utils/ruleset'; import { useAuthStore } from './auth'; import type { ProjectTestRunResult, TestCase, TestCaseInput, TestRunResult } from '@/api/types'; @@ -19,18 +19,18 @@ export const useTestStore = defineStore('test', () => { const projectRunResult = ref(null); const projectRunning = ref(false); - async function buildEngineRuleset( + async function buildPlatformRuleset( orgId: string, projectId: string, rulesetName: string ): Promise> { if (!auth.token) throw new Error('Not authenticated'); + // Platform owns studio -> engine conversion. Fetch the draft in studio + // format, normalize it on the client, then ask platform to convert it + // before invoking the test execution endpoints. const draft = await rulesetDraftApi.get(auth.token, orgId, projectId, rulesetName); - if (isEngineRuleset(draft.draft)) { - return draft.draft as unknown as Record; - } - const normalized = normalizeRuleset(draft.draft, rulesetName); - return convertToEngineFormat(normalized) as unknown as Record; + const studioRuleset: RuleSet = normalizeRuleset(draft.draft, rulesetName); + return rulesetDraftApi.convert(auth.token, orgId, projectId, rulesetName, studioRuleset); } // ── Ruleset-level operations ────────────────────────────────────────────── @@ -94,7 +94,7 @@ export const useTestStore = defineStore('test', () => { if (!auth.token) return; running.value = true; try { - const ruleset = await buildEngineRuleset(orgId, projectId, rulesetName); + const ruleset = await buildPlatformRuleset(orgId, projectId, rulesetName); const results = await testApi.runAll(auth.token, projectId, rulesetName, { ruleset, include_trace: true, @@ -116,7 +116,7 @@ export const useTestStore = defineStore('test', () => { if (!auth.token) return; runningOne.value = new Set([...runningOne.value, testId]); try { - const ruleset = await buildEngineRuleset(orgId, projectId, rulesetName); + const ruleset = await buildPlatformRuleset(orgId, projectId, rulesetName); const result = await testApi.runOne(auth.token, projectId, rulesetName, testId, { ruleset, include_trace: true, @@ -151,7 +151,7 @@ export const useTestStore = defineStore('test', () => { try { const rulesetEntries = await Promise.all( rulesetNames.map(async (rulesetName) => { - const ruleset = await buildEngineRuleset(orgId, projectId, rulesetName); + const ruleset = await buildPlatformRuleset(orgId, projectId, rulesetName); return [rulesetName, ruleset] as const; }) ); diff --git a/ordo-editor/apps/studio/src/views/project/ReleaseCenterView.vue b/ordo-editor/apps/studio/src/views/project/ReleaseCenterView.vue index 9d72ca72..0734b07d 100644 --- a/ordo-editor/apps/studio/src/views/project/ReleaseCenterView.vue +++ b/ordo-editor/apps/studio/src/views/project/ReleaseCenterView.vue @@ -36,9 +36,14 @@ const policyCount = computed(() => policies.value.length); const recentRequests = computed(() => [...requests.value].slice(0, 5)); const isLiveExecution = computed(() => - ['preparing', 'waiting_start', 'rolling_out', 'paused', 'verifying'].includes( - currentExecution.value?.status ?? '' - ) + [ + 'preparing', + 'waiting_start', + 'rolling_out', + 'paused', + 'verifying', + 'rollback_in_progress', + ].includes(currentExecution.value?.status ?? '') ); const failedInstances = computed( @@ -109,13 +114,13 @@ onUnmounted(() => { function requestStatusTheme(status: string) { if (status === 'completed') return 'success'; if (status === 'pending_approval' || status === 'executing') return 'warning'; - if (status === 'rejected' || status === 'failed') return 'danger'; + if (status === 'rejected' || status === 'failed' || status === 'rollback_failed') return 'danger'; return 'default'; } function executionStatusTheme(status: string) { if (status === 'completed') return 'success'; - if (status === 'failed') return 'danger'; + if (status === 'failed' || status === 'rollback_failed') return 'danger'; if (status === 'paused') return 'default'; if (status === 'rollback_in_progress') return 'warning'; return 'warning'; diff --git a/ordo-editor/apps/studio/src/views/project/ReleaseRequestDetailView.vue b/ordo-editor/apps/studio/src/views/project/ReleaseRequestDetailView.vue index 582cabba..3f2edb54 100644 --- a/ordo-editor/apps/studio/src/views/project/ReleaseRequestDetailView.vue +++ b/ordo-editor/apps/studio/src/views/project/ReleaseRequestDetailView.vue @@ -2,9 +2,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { MessagePlugin } from 'tdesign-vue-next'; +import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'; import { releaseApi } from '@/api/platform-client'; -import type { ReleaseExecution, ReleaseRequest } from '@/api/types'; +import type { ReleaseExecution, ReleaseRequest, ReleaseRequestHistoryEntry } from '@/api/types'; import { StudioPageHeader } from '@/components/ui'; import ReleaseNav from '@/components/project/ReleaseNav.vue'; import { useRolloutStrategyLabel } from '@/constants/release-center'; @@ -24,7 +24,9 @@ const executeLoading = ref(false); const controlLoading = ref<'pause' | 'resume' | 'rollback' | null>(null); const request = ref(null); const execution = ref(null); +const history = ref([]); const elapsedSeconds = ref(0); +const clockNow = ref(Date.now()); const activeTab = ref('overview'); let pollTimer: ReturnType | null = null; @@ -50,7 +52,9 @@ const canReview = computed( ); const isLiveExecution = computed(() => - ['preparing', 'waiting_start', 'rolling_out', 'paused'].includes(execution.value?.status ?? '') + ['preparing', 'waiting_start', 'rolling_out', 'paused', 'rollback_in_progress'].includes( + execution.value?.status ?? '' + ) ); const displayExecutionStatus = computed(() => { @@ -75,30 +79,57 @@ const requestDisplayStatus = computed(() => { }); const hasExecution = computed(() => execution.value !== null); +const historyEntries = computed(() => [...history.value].reverse()); const canPauseExecution = computed( () => !!execution.value && ['preparing', 'waiting_start', 'rolling_out', 'verifying'].includes(execution.value.status) ); const canResumeExecution = computed(() => execution.value?.status === 'paused'); +const executionAttemptsLabel = computed(() => + request.value + ? `${request.value.execution_attempts}/${request.value.max_execution_attempts}` + : '—' +); +const canStartExecution = computed( + () => + canExecute.value && + !!request.value && + !request.value.is_closed && + request.value.status === 'approved' && + !isLiveExecution.value +); const canRollbackExecution = computed( () => + !!request.value && + !request.value.is_closed && !!execution.value && - ['completed', 'failed', 'paused'].includes( + ['completed', 'failed', 'rollback_failed', 'paused'].includes( displayExecutionStatus.value ?? execution.value.status ) ); const canRetryExecution = computed( - () => canExecute.value && requestDisplayStatus.value === 'failed' && !isLiveExecution.value + () => + canExecute.value && + !!request.value && + !request.value.is_closed && + request.value.status === 'failed' && + request.value.execution_attempts < request.value.max_execution_attempts && + !isLiveExecution.value ); -const showExecuteAction = computed( - () => canExecute.value && (requestDisplayStatus.value === 'approved' || canRetryExecution.value) +const showExecuteAction = computed(() => canStartExecution.value || canRetryExecution.value); +const retryLimitReached = computed( + () => + !!request.value && + request.value.status === 'failed' && + request.value.execution_attempts >= request.value.max_execution_attempts ); const executionTabDot = computed(() => { if (!displayExecutionStatus.value) return null; if (displayExecutionStatus.value === 'completed') return 'success'; - if (['failed', 'rollback_in_progress'].includes(displayExecutionStatus.value)) return 'danger'; + if (['failed', 'rollback_failed', 'rollback_in_progress'].includes(displayExecutionStatus.value)) + return 'danger'; if (isLiveExecution.value) return 'live'; return null; }); @@ -133,14 +164,14 @@ const hasDiffChanges = computed(() => { function statusTheme(status: string) { if (['approved', 'completed'].includes(status)) return 'success'; if (['pending_approval', 'executing'].includes(status)) return 'warning'; - if (['rejected', 'failed'].includes(status)) return 'danger'; + if (['rejected', 'failed', 'rollback_failed'].includes(status)) return 'danger'; return 'default'; } function instanceTheme(status: string) { if (status === 'success') return 'success'; if (['failed', 'rolled_back'].includes(status)) return 'danger'; - if (['updating', 'dispatching', 'verifying'].includes(status)) return 'warning'; + if (['scheduled', 'updating', 'dispatching', 'verifying'].includes(status)) return 'warning'; return 'default'; } @@ -155,6 +186,19 @@ function formatElapsed(s: number) { return m > 0 ? `${m}m ${s % 60}s` : `${s}s`; } +const nextBatchCountdownSeconds = computed(() => { + if (!execution.value?.next_batch_at) return null; + const diffMs = new Date(execution.value.next_batch_at).getTime() - clockNow.value; + if (!isLiveExecution.value || diffMs <= 0) return null; + return Math.ceil(diffMs / 1000); +}); +const showNextBatchWindow = computed( + () => + !!execution.value?.next_batch_at && + isLiveExecution.value && + new Date(execution.value.next_batch_at).getTime() > clockNow.value +); + function formatDatetime(iso: string | undefined | null) { if (!iso) return '—'; return new Date(iso).toLocaleString(undefined, { @@ -166,10 +210,100 @@ function formatDatetime(iso: string | undefined | null) { }); } +function historyScopeTheme(scope: string) { + if (scope === 'rollback') return 'danger'; + if (scope === 'execution' || scope === 'batch') return 'warning'; + if (scope === 'approval') return 'success'; + return 'default'; +} + +function formatHistoryActor(entry: ReleaseRequestHistoryEntry) { + return entry.actor_name || entry.actor_email || entry.actor_id || t('releaseCenter.systemActor'); +} + +function formatHistoryStatus(entry: ReleaseRequestHistoryEntry) { + if (!entry.from_status && !entry.to_status) return null; + return `${entry.from_status || '—'} → ${entry.to_status || '—'}`; +} + +function formatHistoryAction(action: string) { + const key = `releaseCenter.historyActionMap.${action}`; + const translated = t(key); + return translated === key ? action : translated; +} + +function formatHistoryValue(value: unknown) { + if (value === null || value === undefined || value === '') return '—'; + if (Array.isArray(value)) return value.join(', '); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function getHistoryDetailPairs(entry: ReleaseRequestHistoryEntry) { + const detail = entry.detail || {}; + const pairs: Array<{ label: string; value: string }> = []; + const push = (label: string, value: unknown) => { + if (value === null || value === undefined || value === '') return; + pairs.push({ label, value: formatHistoryValue(value) }); + }; + + push(t('releaseCenter.historyField.reason'), detail.reason); + push(t('releaseCenter.historyField.ruleset'), detail.ruleset_name); + push(t('releaseCenter.historyField.version'), detail.version); + push( + t('releaseCenter.historyField.environment'), + detail.environment_name ?? detail.environment_id + ); + push(t('releaseCenter.historyField.policy'), detail.policy_name ?? detail.policy_id); + push(t('releaseCenter.historyField.batch'), detail.batch_index); + push(t('releaseCenter.historyField.totalBatches'), detail.total_batches); + push(t('releaseCenter.historyField.instance'), detail.instance_name); + push(t('releaseCenter.historyField.targetInstance'), detail.target_instance_id); + push(t('releaseCenter.historyField.rollbackVersion'), detail.rollback_version); + push(t('releaseCenter.historyField.message'), detail.message); + push(t('releaseCenter.historyField.error'), detail.error); + push(t('releaseCenter.historyField.nextBatchAt'), detail.next_batch_at); + push( + t('releaseCenter.historyField.waitSeconds'), + detail.wait_seconds ?? detail.remaining_wait_seconds + ); + + return pairs; +} + +function getHistoryHeadline(entry: ReleaseRequestHistoryEntry) { + const detail = entry.detail || {}; + if (detail.instance_name) { + return `${formatHistoryAction(entry.action)} · ${formatHistoryValue(detail.instance_name)}`; + } + if (detail.batch_index) { + return `${formatHistoryAction(entry.action)} · Batch ${formatHistoryValue(detail.batch_index)}`; + } + if (detail.version) { + return `${formatHistoryAction(entry.action)} · v${formatHistoryValue(detail.version)}`; + } + return formatHistoryAction(entry.action); +} + +function prettyHistoryDetail(detail: Record) { + return JSON.stringify(detail, null, 2); +} + +async function loadHistory() { + if (!auth.token) return; + history.value = await releaseApi.getRequestHistory( + auth.token, + route.params.orgId as string, + route.params.projectId as string, + route.params.releaseId as string + ); +} + function startClock() { if (!execution.value?.started_at) return; const t0 = new Date(execution.value.started_at).getTime(); clockTimer = setInterval(() => { + clockNow.value = Date.now(); elapsedSeconds.value = Math.floor((Date.now() - t0) / 1000); }, 1000); } @@ -186,13 +320,22 @@ function startPolling() { pollTimer = setInterval(async () => { if (!auth.token || !request.value) return; try { - const ex = await releaseApi.getRequestExecution( - auth.token, - route.params.orgId as string, - route.params.projectId as string, - request.value.id - ); + const [ex, latestHistory] = await Promise.all([ + releaseApi.getRequestExecution( + auth.token, + route.params.orgId as string, + route.params.projectId as string, + request.value.id + ), + releaseApi.getRequestHistory( + auth.token, + route.params.orgId as string, + route.params.projectId as string, + request.value.id + ), + ]); execution.value = ex; + history.value = latestHistory; if (ex && !isLiveExecution.value) { stopPolling(); stopClock(); @@ -228,19 +371,30 @@ onMounted(async () => { rbacStore.fetchRoles(route.params.orgId as string), rbacStore.fetchMyRoles(route.params.orgId as string), ]); - request.value = await releaseApi.getRequest( - auth.token, - route.params.orgId as string, - route.params.projectId as string, - route.params.releaseId as string - ); - const ex = await releaseApi.getRequestExecution( - auth.token, - route.params.orgId as string, - route.params.projectId as string, - route.params.releaseId as string - ); + const [loadedRequest, ex, loadedHistory] = await Promise.all([ + releaseApi.getRequest( + auth.token, + route.params.orgId as string, + route.params.projectId as string, + route.params.releaseId as string + ), + releaseApi.getRequestExecution( + auth.token, + route.params.orgId as string, + route.params.projectId as string, + route.params.releaseId as string + ), + releaseApi.getRequestHistory( + auth.token, + route.params.orgId as string, + route.params.projectId as string, + route.params.releaseId as string + ), + ]); + request.value = loadedRequest; execution.value = ex; + history.value = loadedHistory; + clockNow.value = Date.now(); if (ex?.started_at) elapsedSeconds.value = Math.floor((Date.now() - new Date(ex.started_at).getTime()) / 1000); if (ex) { @@ -286,6 +440,7 @@ async function submitReview() { request.value.id, { comment: reviewDialog.value.comment || undefined } ); + await loadHistory(); reviewDialog.value.visible = false; MessagePlugin.success( reviewDialog.value.mode === 'approve' @@ -319,6 +474,7 @@ async function executeRelease() { route.params.projectId as string, request.value.id ); + await loadHistory(); startClock(); startPolling(); MessagePlugin.success( @@ -333,6 +489,30 @@ async function executeRelease() { async function controlExecution(action: 'pause' | 'resume' | 'rollback') { if (!auth.token || !request.value) return; + + if (action === 'rollback') { + const release = request.value; + const dlg = DialogPlugin.confirm({ + header: t('releaseCenter.rollbackDialog'), + body: t('releaseCenter.rollbackConfirm', { + title: release.title, + version: release.rollback_version || release.version_diff.rollback_version || '—', + }), + confirmBtn: { content: t('releaseCenter.rollbackConfirmBtn'), theme: 'danger' }, + cancelBtn: t('common.cancel'), + onConfirm: async () => { + dlg.hide(); + await doControlExecution('rollback'); + }, + }); + return; + } + + await doControlExecution(action); +} + +async function doControlExecution(action: 'pause' | 'resume' | 'rollback') { + if (!auth.token || !request.value) return; controlLoading.value = action; try { const ex = @@ -363,6 +543,7 @@ async function controlExecution(action: 'pause' | 'resume' | 'rollback') { route.params.projectId as string, request.value.id ); + await loadHistory(); if (action === 'pause') { stopClock(); stopPolling(); @@ -467,6 +648,9 @@ async function controlExecution(action: 'pause' | 'resume' | 'rollback') { {{ t(`releaseCenter.statusMap.${requestDisplayStatus || request.status}`) }} + + {{ t('releaseCenter.requestClosed') }} +
{{ t('releaseCenter.currentVersion') }} @@ -497,6 +681,10 @@ async function controlExecution(action: 'pause' | 'resume' | 'rollback') { {{ t('releaseCenter.createdAt') }} {{ formatDatetime(request.created_at) }}
+
+ {{ t('releaseCenter.executionAttempts') }} + {{ executionAttemptsLabel }} +
@@ -544,7 +732,31 @@ async function controlExecution(action: 'pause' | 'resume' | 'rollback') { {{ t('releaseCenter.createdAt') }} {{ formatDatetime(request.created_at) }} +
+ {{ t('releaseCenter.executionAttempts') }} + {{ executionAttemptsLabel }} +
+
+ {{ t('releaseCenter.requestLifecycle') }} + {{ + request.is_closed + ? t('releaseCenter.requestLifecycleClosed') + : t('releaseCenter.requestLifecycleOpen') + }} +
+ @@ -1040,6 +1252,13 @@ async function controlExecution(action: 'pause' | 'resume' | 'rollback') { > ✓ {{ t('releaseCenter.executionCompleted') }} + {{ t('releaseCenter.startedAt') }} {{ formatDatetime(execution.started_at) }} +
+ {{ t('releaseCenter.nextBatchAt') }} + {{ formatDatetime(execution.next_batch_at) }} +
+
+ {{ t('releaseCenter.countdown') }} + {{ formatElapsed(nextBatchCountdownSeconds) }} +
{{ t('releaseCenter.instanceName') }} {{ t('releaseCenter.zone') }} + {{ t('releaseCenter.batchIndex') }} {{ t('releaseCenter.currentVersion') }} {{ t('releaseCenter.targetVersion') }} + {{ t('releaseCenter.scheduledAt') }} {{ t('releaseCenter.status') }} {{ t('releaseCenter.message') }} @@ -1133,8 +1363,10 @@ async function controlExecution(action: 'pause' | 'resume' | 'rollback') { {{ inst.instance_name }} {{ inst.zone || '—' }} + {{ inst.batch_index }} {{ inst.current_version || '—' }} {{ inst.target_version }} + {{ formatDatetime(inst.scheduled_at) }} {{ t(`releaseCenter.instanceStatusMap.${inst.status}`) }} @@ -1149,6 +1381,46 @@ async function controlExecution(action: 'pause' | 'resume' | 'rollback') { + + +
+ +
{{ t('releaseCenter.operationHistory') }}
+
+
+
+
+ {{ formatDatetime(item.created_at) }} + + {{ item.scope }} + + {{ getHistoryHeadline(item) }} +
+
{{ formatHistoryActor(item) }}
+
+
+ {{ t('releaseCenter.statusFlow') }}: {{ formatHistoryStatus(item) }} +
+
+
+ {{ pair.label }} + {{ pair.value }} +
+
+
+ {{ t('releaseCenter.rawDetail') }} +
{{ prettyHistoryDetail(item.detail) }}
+
+
+
+ +
+
+
@@ -1202,6 +1474,97 @@ async function controlExecution(action: 'pause' | 'resume' | 'rollback') { overflow-y: auto; } +.history-list { + display: grid; + gap: 12px; +} + +.history-item { + border: 1px solid var(--ordo-border-color, rgba(15, 23, 42, 0.08)); + border-radius: 14px; + padding: 14px 16px; + background: rgba(248, 250, 252, 0.72); +} + +.history-item__head, +.history-item__meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.history-item__head { + justify-content: space-between; +} + +.history-item__time, +.history-item__actor, +.history-item__status { + color: var(--ordo-text-secondary); + font-size: 12px; +} + +.history-item__action { + font-size: 12px; + background: rgba(15, 23, 42, 0.06); + padding: 2px 6px; + border-radius: 999px; +} + +.history-item__status { + margin-top: 8px; +} + +.history-item__grid { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.history-item__cell { + display: grid; + gap: 4px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.7); +} + +.history-item__label { + color: var(--ordo-text-secondary); + font-size: 11px; +} + +.history-item__value { + color: var(--ordo-text-primary); + font-size: 13px; + line-height: 1.4; + word-break: break-word; +} + +.history-item__raw { + margin-top: 10px; +} + +.history-item__raw summary { + cursor: pointer; + color: var(--ordo-text-secondary); + font-size: 12px; +} + +.history-item__detail { + margin: 8px 0 0; + padding: 10px 12px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.06); + color: var(--ordo-text-primary); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + .skeleton-list { display: grid; gap: 12px; diff --git a/ordo-editor/packages/core/src/engine/adapter.ts b/ordo-editor/packages/core/src/engine/adapter.ts index 96fde2f9..fa3b40ec 100644 --- a/ordo-editor/packages/core/src/engine/adapter.ts +++ b/ordo-editor/packages/core/src/engine/adapter.ts @@ -15,6 +15,7 @@ import type { DecisionStep, ActionStep, TerminalStep, + SubRuleStep, Branch as EditorBranch, } from '../model'; @@ -34,6 +35,12 @@ interface EngineRuleSet { metadata: Record; }; steps: Record; + sub_rules?: Record; +} + +interface EngineSubRuleGraph { + entry_step: string; + steps: Record; } /** @@ -43,7 +50,7 @@ interface EngineStep { id: string; name: string; // Flattened StepKind fields - one of these will be present based on "type" - type: 'decision' | 'action' | 'terminal'; + type: 'decision' | 'action' | 'terminal' | 'sub_rule'; // Decision fields branches?: EngineBranch[]; default_next?: string | null; @@ -52,6 +59,10 @@ interface EngineStep { next_step?: string; // Terminal fields result?: EngineTerminalResult; + // SubRule fields + ref_name?: string; + bindings?: Array<[string, any]>; // Vec<(String, Expr)> + outputs?: Array<[string, string]>; // Vec<(String, String)> } interface EngineBranch { @@ -95,6 +106,18 @@ export function convertToEngineFormat(editorRuleset: RuleSet): EngineRuleSet { stepsMap[step.id] = convertStep(step); } + // Build sub_rules map + const subRulesMap: Record = {}; + if (editorRuleset.subRules) { + for (const [name, graph] of Object.entries(editorRuleset.subRules)) { + const graphSteps: Record = {}; + for (const step of graph.steps) { + graphSteps[step.id] = convertStep(step); + } + subRulesMap[name] = { entry_step: graph.entryStep, steps: graphSteps }; + } + } + // Build config const config = { name: editorRuleset.config.name || 'unnamed', @@ -111,6 +134,7 @@ export function convertToEngineFormat(editorRuleset: RuleSet): EngineRuleSet { return { config, steps: stepsMap, + ...(Object.keys(subRulesMap).length > 0 && { sub_rules: subRulesMap }), }; } @@ -125,6 +149,8 @@ function convertStep(step: Step): EngineStep { return convertActionStep(step as ActionStep); case 'terminal': return convertTerminalStep(step as TerminalStep); + case 'sub_rule': + return convertSubRuleStep(step as SubRuleStep); default: throw new Error(`Unknown step type: ${(step as any).type}`); } @@ -454,6 +480,29 @@ function convertTerminalStep(step: TerminalStep): EngineStep { }; } +/** + * Convert sub-rule step to engine format + */ +function convertSubRuleStep(step: SubRuleStep): EngineStep { + const bindings: Array<[string, any]> = (step.bindings || []).map((b) => [ + b.field, + convertToEngineExpr(b.expr), + ]); + const outputs: Array<[string, string]> = (step.outputs || []).map((o) => [ + o.parentVar, + o.childVar, + ]); + return { + id: step.id, + name: step.name, + type: 'sub_rule', + ref_name: step.refName, + bindings, + outputs, + next_step: step.nextStepId, + }; +} + /** * Convert editor value to engine Expr format * The engine expects Expr which is a Rust enum serialized as: @@ -742,6 +791,21 @@ export function validateEngineCompatibility(ruleset: RuleSet): string[] { // Terminal steps don't reference other steps break; + case 'sub_rule': { + const subRuleStep = step as SubRuleStep; + if (subRuleStep.nextStepId && !stepIds.has(subRuleStep.nextStepId)) { + errors.push( + `Step '${step.id}' nextStepId references non-existent step '${subRuleStep.nextStepId}'` + ); + } + if (ruleset.subRules && !ruleset.subRules[subRuleStep.refName]) { + errors.push( + `Step '${step.id}' references non-existent sub-rule '${subRuleStep.refName}'` + ); + } + break; + } + default: errors.push(`Step '${(step as Step).id}' has unknown type: ${(step as any).type}`); } diff --git a/ordo-editor/packages/core/src/engine/reverse-adapter.ts b/ordo-editor/packages/core/src/engine/reverse-adapter.ts index c3208999..44451da2 100644 --- a/ordo-editor/packages/core/src/engine/reverse-adapter.ts +++ b/ordo-editor/packages/core/src/engine/reverse-adapter.ts @@ -7,7 +7,16 @@ * This is the inverse of convertToEngineFormat() in adapter.ts. */ -import type { RuleSet, Step, DecisionStep, ActionStep, TerminalStep, Branch } from '../model'; +import type { + RuleSet, + Step, + DecisionStep, + ActionStep, + TerminalStep, + SubRuleStep, + SubRuleGraph, + Branch, +} from '../model'; import { Expr } from '../model'; /** @@ -26,12 +35,18 @@ interface EngineRuleSet { metadata?: Record; }; steps: Record; + sub_rules?: Record; +} + +interface EngineSubRuleGraph { + entry_step: string; + steps: Record; } interface EngineStep { id: string; name: string; - type: 'decision' | 'action' | 'terminal'; + type: 'decision' | 'action' | 'terminal' | 'sub_rule'; // Decision branches?: EngineBranch[]; default_next?: string | null; @@ -40,6 +55,10 @@ interface EngineStep { next_step?: string; // Terminal result?: EngineTerminalResult; + // SubRule + ref_name?: string; + bindings?: Array<[string, any]>; + outputs?: Array<[string, string]>; } interface EngineBranch { @@ -75,6 +94,16 @@ interface EngineTerminalResult { export function convertFromEngineFormat(engine: EngineRuleSet): RuleSet { const steps: Step[] = Object.values(engine.steps).map(convertEngineStep); + const subRules: Record = {}; + if (engine.sub_rules) { + for (const [name, graph] of Object.entries(engine.sub_rules)) { + subRules[name] = { + entryStep: graph.entry_step, + steps: Object.values(graph.steps).map(convertEngineStep), + }; + } + } + return { config: { name: engine.config.name, @@ -86,6 +115,7 @@ export function convertFromEngineFormat(engine: EngineRuleSet): RuleSet { }, startStepId: engine.config.entry_step, steps, + ...(Object.keys(subRules).length > 0 && { subRules }), }; } @@ -97,6 +127,8 @@ function convertEngineStep(step: EngineStep): Step { return convertEngineActionStep(step); case 'terminal': return convertEngineTerminalStep(step); + case 'sub_rule': + return convertEngineSubRuleStep(step); default: // Unknown type — treat as terminal with error return { @@ -295,6 +327,21 @@ function convertEngineTerminalStep(step: EngineStep): TerminalStep { }; } +function convertEngineSubRuleStep(step: EngineStep): SubRuleStep { + return { + id: step.id, + name: step.name, + type: 'sub_rule', + refName: step.ref_name ?? '', + bindings: (step.bindings ?? []).map(([field, expr]) => ({ + field, + expr: convertFromEngineExpr(expr), + })), + outputs: (step.outputs ?? []).map(([parentVar, childVar]) => ({ parentVar, childVar })), + nextStepId: step.next_step ?? '', + }; +} + /** * Convert Engine Expr (Rust enum serialised as tagged object) → Editor value object * Engine: { "Literal": } | { "Field": "" } diff --git a/ordo-editor/packages/core/src/model/ruleset.ts b/ordo-editor/packages/core/src/model/ruleset.ts index 2d74d675..a3dc9f3d 100644 --- a/ordo-editor/packages/core/src/model/ruleset.ts +++ b/ordo-editor/packages/core/src/model/ruleset.ts @@ -273,6 +273,14 @@ export interface RuleSetConfig { } /** RuleSet - the main rule definition */ +/** Inline sub-rule graph embedded in a RuleSet */ +export interface SubRuleGraph { + /** Entry step ID within this sub-graph */ + entryStep: string; + /** All steps in the sub-graph */ + steps: Step[]; +} + export interface RuleSet { /** Configuration */ config: RuleSetConfig; @@ -280,6 +288,8 @@ export interface RuleSet { startStepId: string; /** All steps in the ruleset */ steps: Step[]; + /** Named inline sub-rule graphs referenced by SubRuleStep.refName */ + subRules?: Record; /** Step groups for visual organization */ groups?: StepGroup[]; /** Metadata */ diff --git a/ordo-editor/packages/core/src/model/step.ts b/ordo-editor/packages/core/src/model/step.ts index a758f54e..0c674f8c 100644 --- a/ordo-editor/packages/core/src/model/step.ts +++ b/ordo-editor/packages/core/src/model/step.ts @@ -7,7 +7,7 @@ import { Condition } from './condition'; import { Expr } from './expr'; /** Step types */ -export type StepType = 'decision' | 'action' | 'terminal'; +export type StepType = 'decision' | 'action' | 'terminal' | 'sub_rule'; /** Base step interface */ export interface BaseStep { @@ -113,8 +113,37 @@ export interface TerminalStep extends BaseStep { output?: OutputField[]; } +/** Input binding for a sub-rule: inject a parent-context expression into a named input field */ +export interface SubRuleBinding { + /** Field name in the child context */ + field: string; + /** Expression evaluated in the parent context */ + expr: Expr; +} + +/** Output mapping for a sub-rule: copy a child variable back to a parent variable */ +export interface SubRuleOutput { + /** Variable name in the parent context (without $ prefix) */ + parentVar: string; + /** Variable name in the child context (without $ prefix) */ + childVar: string; +} + +/** Sub-rule step - executes an inline sub-graph and returns control to the parent */ +export interface SubRuleStep extends BaseStep { + type: 'sub_rule'; + /** Name of the sub-rule graph defined in the ruleset */ + refName: string; + /** Input bindings: expressions from the parent context injected into the child context */ + bindings?: SubRuleBinding[]; + /** Output mappings: variables from the child context written back to the parent context */ + outputs?: SubRuleOutput[]; + /** Next step ID after the sub-rule completes */ + nextStepId: string; +} + /** Step union type */ -export type StepUnion = DecisionStep | ActionStep | TerminalStep; +export type StepUnion = DecisionStep | ActionStep | TerminalStep | SubRuleStep; // ============================================================================ // Step builder helpers @@ -220,6 +249,30 @@ export const Step = { }; }, + /** Create a sub-rule step */ + subRule(options: { + id?: string; + name: string; + description?: string; + refName: string; + bindings?: SubRuleBinding[]; + outputs?: SubRuleOutput[]; + nextStepId: string; + position?: { x: number; y: number }; + }): SubRuleStep { + return { + id: options.id || generateStepId(), + name: options.name, + description: options.description, + type: 'sub_rule', + refName: options.refName, + bindings: options.bindings, + outputs: options.outputs, + nextStepId: options.nextStepId, + position: options.position, + }; + }, + /** Create a variable assignment */ assign(name: string, value: Expr): VariableAssignment { return { name, value }; @@ -246,6 +299,11 @@ export function isTerminalStep(step: Step): step is TerminalStep { return step.type === 'terminal'; } +/** Check if a step is a sub-rule step */ +export function isSubRuleStep(step: Step): step is SubRuleStep { + return step.type === 'sub_rule'; +} + /** Get the next step IDs from a step */ export function getNextStepIds(step: Step): string[] { switch (step.type) { @@ -259,5 +317,8 @@ export function getNextStepIds(step: Step): string[] { case 'terminal': return []; + + case 'sub_rule': + return [step.nextStepId]; } } diff --git a/ordo-editor/packages/vue/src/components/flow/utils/converter.ts b/ordo-editor/packages/vue/src/components/flow/utils/converter.ts index f9e527ec..5603c4e0 100644 --- a/ordo-editor/packages/vue/src/components/flow/utils/converter.ts +++ b/ordo-editor/packages/vue/src/components/flow/utils/converter.ts @@ -19,7 +19,7 @@ import { import { EDGE_COLORS } from '../types'; /** Node types for Vue Flow */ -export type FlowNodeType = 'decision' | 'action' | 'terminal' | 'group'; +export type FlowNodeType = 'decision' | 'action' | 'terminal' | 'sub_rule' | 'group'; /** Edge types */ export type FlowEdgeType = 'exec' | 'exec-branch' | 'data'; From 35a983012df4cad1f7b7d89dbe4774dd66591bb7 Mon Sep 17 00:00:00 2001 From: Pama-Lee Date: Sat, 25 Apr 2026 13:22:22 +0800 Subject: [PATCH 2/4] Add platform worker process --- .../migrations/0014_server_capabilities.sql | 2 + .../0015_release_execution_runtime_state.sql | 28 +++ .../0016_release_request_history.sql | 26 ++ .../src/bin/ordo-platform-worker.rs | 39 +++ crates/ordo-platform/src/lib.rs | 226 ++++++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 crates/ordo-platform/migrations/0014_server_capabilities.sql create mode 100644 crates/ordo-platform/migrations/0015_release_execution_runtime_state.sql create mode 100644 crates/ordo-platform/migrations/0016_release_request_history.sql create mode 100644 crates/ordo-platform/src/bin/ordo-platform-worker.rs create mode 100644 crates/ordo-platform/src/lib.rs diff --git a/crates/ordo-platform/migrations/0014_server_capabilities.sql b/crates/ordo-platform/migrations/0014_server_capabilities.sql new file mode 100644 index 00000000..c4bc37ba --- /dev/null +++ b/crates/ordo-platform/migrations/0014_server_capabilities.sql @@ -0,0 +1,2 @@ +ALTER TABLE servers + ADD COLUMN IF NOT EXISTS capabilities JSONB NOT NULL DEFAULT '[]'; diff --git a/crates/ordo-platform/migrations/0015_release_execution_runtime_state.sql b/crates/ordo-platform/migrations/0015_release_execution_runtime_state.sql new file mode 100644 index 00000000..2ccde958 --- /dev/null +++ b/crates/ordo-platform/migrations/0015_release_execution_runtime_state.sql @@ -0,0 +1,28 @@ +ALTER TABLE release_executions +ADD COLUMN IF NOT EXISTS next_batch_at TIMESTAMPTZ; + +ALTER TABLE release_execution_instances +ADD COLUMN IF NOT EXISTS batch_index INTEGER NOT NULL DEFAULT 1, +ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ; + +ALTER TABLE release_execution_instances +DROP CONSTRAINT IF EXISTS release_execution_instances_status_check; + +ALTER TABLE release_execution_instances +ADD CONSTRAINT release_execution_instances_status_check CHECK ( + status IN ( + 'pending', + 'waiting_batch', + 'scheduled', + 'dispatching', + 'updating', + 'verifying', + 'success', + 'failed', + 'rolled_back', + 'skipped' + ) +); + +CREATE INDEX IF NOT EXISTS release_execution_instances_execution_batch_idx +ON release_execution_instances(release_execution_id, batch_index); diff --git a/crates/ordo-platform/migrations/0016_release_request_history.sql b/crates/ordo-platform/migrations/0016_release_request_history.sql new file mode 100644 index 00000000..641c16c5 --- /dev/null +++ b/crates/ordo-platform/migrations/0016_release_request_history.sql @@ -0,0 +1,26 @@ +CREATE TABLE release_request_history ( + id TEXT PRIMARY KEY, + release_request_id TEXT NOT NULL REFERENCES release_requests(id) ON DELETE CASCADE, + release_execution_id TEXT REFERENCES release_executions(id) ON DELETE CASCADE, + instance_id TEXT, + scope TEXT NOT NULL CHECK ( + scope IN ('request', 'approval', 'execution', 'batch', 'instance', 'rollback') + ), + action TEXT NOT NULL, + actor_type TEXT NOT NULL CHECK ( + actor_type IN ('user', 'system', 'server') + ), + actor_id TEXT, + actor_name TEXT, + actor_email TEXT, + from_status TEXT, + to_status TEXT, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX release_request_history_request_created_idx +ON release_request_history(release_request_id, created_at DESC); + +CREATE INDEX release_request_history_execution_created_idx +ON release_request_history(release_execution_id, created_at DESC); diff --git a/crates/ordo-platform/src/bin/ordo-platform-worker.rs b/crates/ordo-platform/src/bin/ordo-platform-worker.rs new file mode 100644 index 00000000..8f1d15b8 --- /dev/null +++ b/crates/ordo-platform/src/bin/ordo-platform-worker.rs @@ -0,0 +1,39 @@ +//! Background worker for release rollout and rollback execution. + +use std::sync::Arc; +use std::time::Duration; + +use clap::Parser; +use ordo_platform::{ + bootstrap_platform_store, build_app_state, config::PlatformConfig, connect_platform_store, + init_tracing, publish_existing_tenants, release, start_server_registry_maintenance, +}; +use tracing::info; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let config = Arc::new(PlatformConfig::parse()); + init_tracing(&config)?; + + if let Err(e) = config.validate() { + return Err(anyhow::anyhow!("Configuration error: {}", e)); + } + + info!("Starting ordo-platform-worker"); + if config.nats_enabled() { + info!( + "NATS sync enabled (url={}, prefix={})", + config.nats_url.as_deref().unwrap_or(""), + config.nats_subject_prefix + ); + } + + let store = connect_platform_store(&config).await?; + bootstrap_platform_store(&store, false).await?; + start_server_registry_maintenance(store.clone()); + + let state = build_app_state(config, store, true).await?; + publish_existing_tenants(&state).await; + + release::run_release_worker_loop(state, Duration::from_secs(2)).await +} diff --git a/crates/ordo-platform/src/lib.rs b/crates/ordo-platform/src/lib.rs new file mode 100644 index 00000000..1d8797f4 --- /dev/null +++ b/crates/ordo-platform/src/lib.rs @@ -0,0 +1,226 @@ +//! Shared Ordo Platform library used by the HTTP API and background workers. + +use std::sync::Arc; +use std::time::Duration; + +use config::PlatformConfig; +use store::PlatformStore; +use template::TemplateStore; + +pub mod auth; +pub mod catalog; +pub mod config; +pub mod contract; +pub mod environment; +pub mod error; +pub mod github; +pub mod i18n; +pub mod member; +pub mod middleware; +pub mod models; +pub mod notification; +pub mod org; +pub mod project; +pub mod proxy; +pub mod rbac; +pub mod release; +pub mod ruleset_draft; +pub mod ruleset_history; +pub mod server_registry; +pub mod store; +pub mod sub_org_member; +pub mod sync; +pub mod template; +pub mod templates_api; +pub mod testing; + +/// Shared application state. +#[derive(Clone)] +pub struct AppState { + pub store: Arc, + pub config: Arc, + pub http_client: reqwest::Client, + pub templates: Arc, + pub sync_publisher: Option>, + pub marketplace_cache: Arc, +} + +pub fn init_tracing(config: &PlatformConfig) -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + config + .log_level + .parse() + .unwrap_or_else(|_| "info".parse().unwrap()) + }), + ) + .try_init() + .map_err(|err| anyhow::anyhow!("failed to initialize tracing: {}", err)) +} + +pub fn resolve_templates_dir(configured: &std::path::Path) -> std::path::PathBuf { + if configured.exists() { + return configured.to_path_buf(); + } + + let fallbacks = [ + std::path::PathBuf::from("./crates/ordo-platform/templates"), + std::path::PathBuf::from("./templates"), + std::path::PathBuf::from("/app/templates"), + ]; + + if let Some(path) = fallbacks.into_iter().find(|path| path.exists()) { + tracing::info!( + configured = %configured.display(), + resolved = %path.display(), + "Using fallback templates directory", + ); + return path; + } + + configured.to_path_buf() +} + +pub async fn connect_platform_store(config: &PlatformConfig) -> anyhow::Result> { + let pool = sqlx::PgPool::connect(&config.database_url).await?; + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(Arc::new(PlatformStore::new(pool).await?)) +} + +pub async fn bootstrap_platform_store( + store: &Arc, + fail_active_executions: bool, +) -> anyhow::Result<()> { + let orgs = store.list_all_orgs().await.unwrap_or_default(); + for org in &orgs { + if let Err(e) = store.seed_system_roles(&org.id).await { + tracing::warn!("seed_system_roles failed for org {}: {}", org.id, e); + } + } + + let all_projects = store.list_all_projects().await.unwrap_or_default(); + for project in all_projects { + if let Err(e) = store + .migrate_project_server_to_environment(&project.id, project.server_id.as_deref()) + .await + { + tracing::warn!("migrate env failed for project {}: {}", project.id, e); + } + } + + if let Err(e) = store.backfill_project_rulesets_from_history().await { + tracing::warn!("backfill_project_rulesets_from_history failed: {}", e); + } + + match store.fail_stuck_queued_deployments().await { + Ok(n) if n > 0 => tracing::warn!( + count = n, + "Marked stuck queued deployments as failed on startup" + ), + Ok(_) => {} + Err(e) => tracing::warn!("fail_stuck_queued_deployments: {}", e), + } + + if fail_active_executions { + match store.fail_stuck_active_executions().await { + Ok(n) if n > 0 => tracing::warn!( + count = n, + "Marked stuck active release executions as failed on startup" + ), + Ok(_) => {} + Err(e) => tracing::warn!("fail_stuck_active_executions: {}", e), + } + } + + Ok(()) +} + +pub fn start_server_registry_maintenance(store: Arc) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + let now = chrono::Utc::now(); + let degraded_threshold = now - chrono::Duration::seconds(90); + let offline_threshold = now - chrono::Duration::minutes(10); + let prune_threshold = now - chrono::Duration::minutes(30); + + let _ = store.mark_stale_servers_degraded(degraded_threshold).await; + let _ = store.mark_stale_servers_offline(offline_threshold).await; + let _ = store.delete_stale_offline_servers(prune_threshold).await; + } + }) +} + +pub async fn build_app_state( + config: Arc, + store: Arc, + start_control_subscriber: bool, +) -> anyhow::Result { + let http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let templates_dir = resolve_templates_dir(&config.templates_dir); + let templates = Arc::new( + TemplateStore::load_from_dir(&templates_dir).unwrap_or_else(|e| { + tracing::warn!("Failed to load templates from {:?}: {:#}", templates_dir, e); + TemplateStore::default() + }), + ); + + let sync_publisher = if let Some(nats_url) = config.nats_url.as_deref() { + let jetstream = sync::connect(nats_url) + .await + .map_err(|e| anyhow::anyhow!("Failed to connect to NATS at {}: {}", nats_url, e))?; + sync::ensure_stream(&jetstream, &config.nats_subject_prefix) + .await + .map_err(|e| anyhow::anyhow!("Failed to ensure NATS stream: {}", e))?; + + if start_control_subscriber { + let consumer_id = format!("{}-worker", config.resolve_instance_id()); + let registry_consumer = sync::create_control_consumer( + &jetstream, + &consumer_id, + &config.nats_subject_prefix, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to create NATS registry consumer: {}", e))?; + sync::start_registry_subscriber(registry_consumer, store.clone()); + } + + Some(Arc::new(sync::NatsPublisher::new( + jetstream, + config.nats_subject_prefix.clone(), + config.resolve_instance_id(), + ))) + } else { + None + }; + + Ok(AppState { + store, + config, + http_client, + templates, + sync_publisher, + marketplace_cache: github::MarketplaceCache::new(), + }) +} + +pub async fn publish_existing_tenants(state: &AppState) { + if let Some(publisher) = &state.sync_publisher { + if let Ok(projects) = state.store.list_all_projects().await { + for project in projects { + let _ = publisher + .publish(sync::SyncEvent::TenantUpsert { + tenant_id: project.id.clone(), + name: project.name.clone(), + enabled: true, + }) + .await; + } + } + } +} From 80588a55c1c0d81ed1a7809bec0c34d2c45a5e8f Mon Sep 17 00:00:00 2001 From: Pama-Lee Date: Sat, 25 Apr 2026 13:48:31 +0800 Subject: [PATCH 3/4] Support sub-rules in compiled runtime --- crates/ordo-cli/src/runtime.rs | 89 +++++- crates/ordo-core/src/rule/compiled.rs | 198 +++++++++++- .../ordo-core/src/rule/compiled_executor.rs | 287 +++++++++++++++++- crates/ordo-core/src/rule/compiler.rs | 246 ++++++++++----- crates/ordo-core/src/rule/executor.rs | 183 ++++++++++- 5 files changed, 916 insertions(+), 87 deletions(-) diff --git a/crates/ordo-cli/src/runtime.rs b/crates/ordo-cli/src/runtime.rs index e2814760..38a9577e 100644 --- a/crates/ordo-cli/src/runtime.rs +++ b/crates/ordo-cli/src/runtime.rs @@ -245,7 +245,10 @@ fn optional_tags( mod tests { use super::*; use ordo_core::expr::Expr; - use ordo_core::rule::{Action, ActionKind, RuleSetCompiler, Step, TerminalResult}; + use ordo_core::rule::{ + Action, ActionKind, Condition, RuleSetCompiler, Step, StepKind, SubRuleGraph, + TerminalResult, + }; use std::time::{SystemTime, UNIX_EPOCH}; fn unique_temp_ordo_path(prefix: &str) -> std::path::PathBuf { @@ -304,4 +307,88 @@ mod tests { std::fs::remove_file(path).unwrap(); } + + #[test] + fn compiled_ordo_sub_rule_executes_through_cli_runtime() { + let mut sub_steps = hashbrown::HashMap::new(); + sub_steps.insert( + "classify".to_string(), + Step::decision("classify", "Classify") + .branch(Condition::from_string("score >= 90"), "gold") + .default("silver") + .build(), + ); + sub_steps.insert( + "gold".to_string(), + Step::action( + "gold", + "Gold", + vec![Action { + kind: ActionKind::SetVariable { + name: "tier".to_string(), + value: Expr::literal("gold"), + }, + description: String::new(), + }], + "done", + ), + ); + sub_steps.insert( + "silver".to_string(), + Step::action( + "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("cli_compiled_sub_rule", "start"); + ruleset.add_sub_rule( + "tiering", + SubRuleGraph { + entry_step: "classify".to_string(), + steps: sub_steps, + }, + ); + ruleset.add_step(Step { + id: "start".to_string(), + name: "Start".to_string(), + kind: StepKind::SubRule { + ref_name: "tiering".to_string(), + bindings: vec![("score".to_string(), Expr::field("score"))], + outputs: vec![("tier".to_string(), "tier".to_string())], + next_step: "done".to_string(), + }, + }); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK").with_output("tier", Expr::field("$tier")), + )); + + ruleset.validate().unwrap(); + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + let path = unique_temp_ordo_path("ordo-cli-sub-rule-runtime"); + compiled.save_to_file(&path).unwrap(); + + let loaded = load_rule(path.to_str().unwrap()).unwrap(); + let input: Value = serde_json::from_str(r#"{"score":95}"#).unwrap(); + let result = execute_loaded_rule(&loaded, input, false).unwrap(); + + assert_eq!(result.code, "OK"); + assert_eq!(result.output.get_path("tier"), Some(&Value::string("gold"))); + + std::fs::remove_file(path).unwrap(); + } } diff --git a/crates/ordo-core/src/rule/compiled.rs b/crates/ordo-core/src/rule/compiled.rs index c70d9717..08531869 100644 --- a/crates/ordo-core/src/rule/compiled.rs +++ b/crates/ordo-core/src/rule/compiled.rs @@ -26,7 +26,7 @@ use std::path::Path; use std::sync::Arc; const MAGIC: &[u8; 4] = b"ORDO"; -const VERSION: u16 = 2; +const VERSION: u16 = 3; const FLAG_HAS_SIGNATURE: u16 = 0b0001; /// Maximum allowed size for collections during deserialization (prevent DoS attacks) @@ -136,6 +136,7 @@ pub struct CompiledRuleSet { pub metadata: CompiledMetadata, pub entry_step: u32, pub steps: Vec, + pub sub_rules: HashMap, pub expressions: Vec, pub string_pool: Vec, pub signature: Option, @@ -160,6 +161,7 @@ impl CompiledRuleSet { metadata, entry_step, steps, + sub_rules: HashMap::new(), expressions, string_pool, signature: None, @@ -169,6 +171,11 @@ impl CompiledRuleSet { ruleset } + pub fn with_sub_rules(mut self, sub_rules: HashMap) -> Self { + self.sub_rules = sub_rules; + self + } + pub fn get_step(&self, step_hash: u32) -> Result<&CompiledStep> { let index = self.step_index @@ -191,6 +198,12 @@ impl CompiledRuleSet { .ok_or_else(|| OrdoError::parse_error("String pool index out of range")) } + pub fn get_sub_rule(&self, name: u32) -> Result<&CompiledSubRuleGraph> { + self.sub_rules + .get(&name) + .ok_or_else(|| OrdoError::parse_error("Sub-rule not found in compiled ruleset")) + } + pub fn rebuild_index(&mut self) { self.step_index.clear(); for (idx, step) in self.steps.iter().enumerate() { @@ -325,9 +338,29 @@ impl CompiledRuleSet { steps.push(CompiledStep::deserialize(&mut cursor)?); } + let sub_rules = if version >= 3 { + let sub_rule_count = read_u32(&mut cursor)? as usize; + if sub_rule_count > MAX_COLLECTION_SIZE { + return Err(OrdoError::parse_error(format!( + "Sub-rule count {} exceeds maximum {}", + sub_rule_count, MAX_COLLECTION_SIZE + ))); + } + let mut sub_rules = HashMap::with_capacity(sub_rule_count); + for _ in 0..sub_rule_count { + let name = read_u32(&mut cursor)?; + let graph = CompiledSubRuleGraph::deserialize(&mut cursor)?; + sub_rules.insert(name, graph); + } + sub_rules + } else { + HashMap::new() + }; + let entry_step = read_u32(&mut cursor)?; - let mut ruleset = Self::new(metadata, entry_step, steps, expressions, string_pool); + let mut ruleset = Self::new(metadata, entry_step, steps, expressions, string_pool) + .with_sub_rules(sub_rules); ruleset.signature = signature; Ok(ruleset) } @@ -404,6 +437,12 @@ impl CompiledRuleSet { step.serialize(out); } + write_u32(out, self.sub_rules.len() as u32); + for (name, graph) in &self.sub_rules { + write_u32(out, *name); + graph.serialize(out); + } + write_u32(out, self.entry_step); } } @@ -479,6 +518,13 @@ pub enum CompiledStep { outputs: Vec, data: Value, }, + SubRule { + id_hash: u32, + ref_name: u32, + bindings: Vec, + outputs: Vec, + next_step: u32, + }, } impl CompiledStep { @@ -487,6 +533,7 @@ impl CompiledStep { CompiledStep::Decision { id_hash, .. } => *id_hash, CompiledStep::Action { id_hash, .. } => *id_hash, CompiledStep::Terminal { id_hash, .. } => *id_hash, + CompiledStep::SubRule { id_hash, .. } => *id_hash, } } @@ -535,6 +582,26 @@ impl CompiledStep { } write_value(out, data); } + CompiledStep::SubRule { + id_hash, + ref_name, + bindings, + outputs, + next_step, + } => { + write_u8(out, 3); + write_u32(out, *id_hash); + write_u32(out, *ref_name); + write_u32(out, bindings.len() as u32); + for binding in bindings { + binding.serialize(out); + } + write_u32(out, outputs.len() as u32); + for output in outputs { + output.serialize(out); + } + write_u32(out, *next_step); + } } } @@ -587,11 +654,138 @@ impl CompiledStep { data, }) } + 3 => { + let id_hash = read_u32(cursor)?; + let ref_name = read_u32(cursor)?; + let binding_count = read_u32(cursor)? as usize; + let mut bindings = Vec::with_capacity(binding_count); + for _ in 0..binding_count { + bindings.push(CompiledSubRuleBinding::deserialize(cursor)?); + } + let output_count = read_u32(cursor)? as usize; + let mut outputs = Vec::with_capacity(output_count); + for _ in 0..output_count { + outputs.push(CompiledSubRuleOutput::deserialize(cursor)?); + } + let next_step = read_u32(cursor)?; + Ok(CompiledStep::SubRule { + id_hash, + ref_name, + bindings, + outputs, + next_step, + }) + } _ => Err(OrdoError::parse_error("Unknown compiled step tag")), } } } +#[derive(Debug, Clone)] +pub struct CompiledSubRuleGraph { + pub entry_step: u32, + pub steps: Vec, + step_index: HashMap, +} + +impl CompiledSubRuleGraph { + pub fn new(entry_step: u32, steps: Vec) -> Self { + let mut graph = Self { + entry_step, + steps, + step_index: HashMap::new(), + }; + graph.rebuild_index(); + graph + } + + pub fn get_step(&self, step_hash: u32) -> Result<&CompiledStep> { + let index = + self.step_index + .get(&step_hash) + .copied() + .ok_or_else(|| OrdoError::StepNotFound { + step_id: format!("{step_hash}"), + })?; + self.steps + .get(index) + .ok_or_else(|| OrdoError::StepNotFound { + step_id: format!("{step_hash}"), + }) + } + + fn rebuild_index(&mut self) { + self.step_index.clear(); + for (idx, step) in self.steps.iter().enumerate() { + self.step_index.insert(step.id_hash(), idx); + } + } + + fn serialize(&self, out: &mut Vec) { + write_u32(out, self.entry_step); + write_u32(out, self.steps.len() as u32); + for step in &self.steps { + step.serialize(out); + } + } + + fn deserialize(cursor: &mut Cursor<'_>) -> Result { + let entry_step = read_u32(cursor)?; + let step_count = read_u32(cursor)? as usize; + if step_count > MAX_COLLECTION_SIZE { + return Err(OrdoError::parse_error(format!( + "Sub-rule step count {} exceeds maximum {}", + step_count, MAX_COLLECTION_SIZE + ))); + } + let mut steps = Vec::with_capacity(step_count); + for _ in 0..step_count { + steps.push(CompiledStep::deserialize(cursor)?); + } + Ok(Self::new(entry_step, steps)) + } +} + +#[derive(Debug, Clone)] +pub struct CompiledSubRuleBinding { + pub name: u32, + pub expr: u32, +} + +impl CompiledSubRuleBinding { + fn serialize(&self, out: &mut Vec) { + write_u32(out, self.name); + write_u32(out, self.expr); + } + + fn deserialize(cursor: &mut Cursor<'_>) -> Result { + Ok(Self { + name: read_u32(cursor)?, + expr: read_u32(cursor)?, + }) + } +} + +#[derive(Debug, Clone)] +pub struct CompiledSubRuleOutput { + pub parent_variable: u32, + pub child_variable: u32, +} + +impl CompiledSubRuleOutput { + fn serialize(&self, out: &mut Vec) { + write_u32(out, self.parent_variable); + write_u32(out, self.child_variable); + } + + fn deserialize(cursor: &mut Cursor<'_>) -> Result { + Ok(Self { + parent_variable: read_u32(cursor)?, + child_variable: read_u32(cursor)?, + }) + } +} + #[derive(Debug, Clone)] pub struct CompiledBranch { pub condition: CompiledCondition, diff --git a/crates/ordo-core/src/rule/compiled_executor.rs b/crates/ordo-core/src/rule/compiled_executor.rs index 70ea5ec1..bd6ced7c 100644 --- a/crates/ordo-core/src/rule/compiled_executor.rs +++ b/crates/ordo-core/src/rule/compiled_executor.rs @@ -1,7 +1,8 @@ //! Executor for compiled rulesets use super::compiled::{ - CompiledAction, CompiledCondition, CompiledRuleSet, CompiledStep, FIELD_MISSING_LENIENT, + CompiledAction, CompiledCondition, CompiledRuleSet, CompiledStep, CompiledSubRuleBinding, + CompiledSubRuleGraph, CompiledSubRuleOutput, FIELD_MISSING_LENIENT, }; use super::metrics::{MetricSink, NoOpMetricSink}; use super::{ExecutionResult, TerminalResult}; @@ -39,6 +40,7 @@ pub struct CompiledRuleExecutor { vm: BytecodeVM, metric_sink: Arc, capability_invoker: Option>, + max_call_depth: usize, } impl Default for CompiledRuleExecutor { @@ -53,6 +55,7 @@ impl CompiledRuleExecutor { vm: BytecodeVM::new(), metric_sink: Arc::new(NoOpMetricSink), capability_invoker: None, + max_call_depth: 10, } } @@ -61,6 +64,7 @@ impl CompiledRuleExecutor { vm: BytecodeVM::new(), metric_sink, capability_invoker: None, + max_call_depth: 10, } } @@ -77,6 +81,7 @@ impl CompiledRuleExecutor { let mut ctx = Context::new(input); let mut current_step = ruleset.entry_step; let mut depth = 0usize; + let remaining_call_depth = self.max_call_depth; loop { // Amortized timeout: skip the first 16 steps, then check every 16 steps. @@ -139,6 +144,24 @@ impl CompiledRuleExecutor { current_step = *next_step; depth += 1; } + CompiledStep::SubRule { + ref_name, + bindings, + outputs, + next_step, + .. + } => { + let child_ctx = self.execute_sub_rule( + ruleset, + *ref_name, + bindings, + &ctx, + remaining_call_depth, + )?; + self.copy_sub_rule_outputs(ruleset, outputs, &child_ctx, &mut ctx)?; + current_step = *next_step; + depth += 1; + } CompiledStep::Terminal { code, message, @@ -165,6 +188,138 @@ impl CompiledRuleExecutor { } } + fn execute_sub_graph( + &self, + ruleset: &CompiledRuleSet, + graph: &CompiledSubRuleGraph, + input: Value, + remaining_call_depth: usize, + ) -> Result { + let mut ctx = Context::new(input); + let mut current_step = graph.entry_step; + let mut depth = 0usize; + + loop { + if depth >= ruleset.metadata.max_depth as usize { + return Err(OrdoError::MaxDepthExceeded { + max_depth: ruleset.metadata.max_depth as usize, + }); + } + + let step = graph.get_step(current_step)?; + match step { + CompiledStep::Decision { + branches, + default_next, + .. + } => { + let mut matched = false; + for branch in branches { + if self.evaluate_condition(ruleset, &branch.condition, &ctx)? { + for action in &branch.actions { + self.execute_action(ruleset, action, &mut ctx)?; + } + current_step = branch.next_step; + matched = true; + break; + } + } + if matched { + depth += 1; + continue; + } + if let Some(next) = default_next { + current_step = *next; + depth += 1; + continue; + } + return Err(OrdoError::eval_error( + "No matching branch and no default branch", + )); + } + CompiledStep::Action { + actions, next_step, .. + } => { + for action in actions { + self.execute_action(ruleset, action, &mut ctx)?; + } + current_step = *next_step; + depth += 1; + } + CompiledStep::SubRule { + ref_name, + bindings, + outputs, + next_step, + .. + } => { + let child_ctx = self.execute_sub_rule( + ruleset, + *ref_name, + bindings, + &ctx, + remaining_call_depth, + )?; + self.copy_sub_rule_outputs(ruleset, outputs, &child_ctx, &mut ctx)?; + current_step = *next_step; + depth += 1; + } + CompiledStep::Terminal { .. } => return Ok(ctx), + } + } + } + + fn execute_sub_rule( + &self, + ruleset: &CompiledRuleSet, + ref_name: u32, + bindings: &[CompiledSubRuleBinding], + parent_ctx: &Context, + remaining_call_depth: usize, + ) -> Result { + if remaining_call_depth == 0 { + let name = ruleset.get_string(ref_name).unwrap_or(""); + return Err(OrdoError::eval_error(format!( + "SubRule max nesting depth ({}) exceeded calling '{}'", + self.max_call_depth, name + ))); + } + + let graph = ruleset.get_sub_rule(ref_name)?; + 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)?, + ); + } + + self.execute_sub_graph( + ruleset, + graph, + Value::object(child_data), + remaining_call_depth - 1, + ) + } + + fn copy_sub_rule_outputs( + &self, + ruleset: &CompiledRuleSet, + outputs: &[CompiledSubRuleOutput], + child_ctx: &Context, + parent_ctx: &mut Context, + ) -> Result<()> { + for output in outputs { + let child_variable = ruleset.get_string(output.child_variable)?; + if let Some(value) = child_ctx.variables().get(child_variable) { + let parent_variable = ruleset.get_string(output.parent_variable)?; + parent_ctx.set_variable(parent_variable, value.clone()); + } + } + Ok(()) + } + fn evaluate_condition( &self, ruleset: &CompiledRuleSet, @@ -395,7 +550,9 @@ mod tests { }; use crate::expr::Expr; use crate::rule::metrics::MetricSink; - use crate::rule::{Action, ActionKind, RuleSet, RuleSetCompiler, Step, TerminalResult}; + use crate::rule::{ + Action, ActionKind, RuleSet, RuleSetCompiler, Step, StepKind, SubRuleGraph, TerminalResult, + }; use std::sync::atomic::{AtomicUsize, Ordering}; struct TestMetricSink { @@ -608,6 +765,132 @@ mod tests { assert_eq!(result.output.get_path("amount"), Some(&Value::int(17))); } + #[test] + fn compiled_ruleset_sub_rule_survives_serialize_roundtrip() { + let mut normalize_steps = hashbrown::HashMap::new(); + normalize_steps.insert( + "set_score".to_string(), + Step::action( + "set_score", + "Set Score", + vec![Action { + kind: ActionKind::SetVariable { + name: "normalized".to_string(), + value: Expr::field("raw_score"), + }, + description: String::new(), + }], + "done", + ), + ); + normalize_steps.insert( + "done".to_string(), + Step::terminal("done", "Done", TerminalResult::new("OK")), + ); + + let mut classify_steps = hashbrown::HashMap::new(); + classify_steps.insert( + "normalize".to_string(), + Step { + id: "normalize".to_string(), + name: "Normalize".to_string(), + kind: StepKind::SubRule { + ref_name: "normalize_score".to_string(), + bindings: vec![("raw_score".to_string(), Expr::field("score"))], + outputs: vec![("score_for_tier".to_string(), "normalized".to_string())], + next_step: "check".to_string(), + }, + }, + ); + classify_steps.insert( + "check".to_string(), + Step::decision("check", "Check") + .branch( + crate::rule::Condition::from_string("$score_for_tier >= 90"), + "gold", + ) + .default("silver") + .build(), + ); + classify_steps.insert( + "gold".to_string(), + Step::action( + "gold", + "Gold", + vec![Action { + kind: ActionKind::SetVariable { + name: "tier".to_string(), + value: Expr::literal("gold"), + }, + description: String::new(), + }], + "done", + ), + ); + classify_steps.insert( + "silver".to_string(), + Step::action( + "silver", + "Silver", + vec![Action { + kind: ActionKind::SetVariable { + name: "tier".to_string(), + value: Expr::literal("silver"), + }, + description: String::new(), + }], + "done", + ), + ); + classify_steps.insert( + "done".to_string(), + Step::terminal("done", "Done", TerminalResult::new("OK")), + ); + + let mut ruleset = RuleSet::new("compiled_sub_rule_roundtrip", "classify"); + ruleset.add_sub_rule( + "normalize_score", + SubRuleGraph { + entry_step: "set_score".to_string(), + steps: normalize_steps, + }, + ); + ruleset.add_sub_rule( + "classify_score", + SubRuleGraph { + entry_step: "normalize".to_string(), + steps: classify_steps, + }, + ); + ruleset.add_step(Step { + id: "classify".to_string(), + name: "Classify".to_string(), + kind: StepKind::SubRule { + ref_name: "classify_score".to_string(), + bindings: vec![("score".to_string(), Expr::field("score"))], + outputs: vec![("tier".to_string(), "tier".to_string())], + next_step: "done".to_string(), + }, + }); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK").with_output("tier", Expr::field("$tier")), + )); + + ruleset.validate().unwrap(); + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + let bytes = compiled.serialize(); + let decoded = CompiledRuleSet::deserialize(&bytes).unwrap(); + let executor = CompiledRuleExecutor::new(); + + let input = serde_json::from_str(r#"{"score": 95}"#).unwrap(); + let result = executor.execute(&decoded, input).unwrap(); + + assert_eq!(result.code, "OK"); + assert_eq!(result.output.get_path("tier"), Some(&Value::string("gold"))); + } + #[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/compiler.rs b/crates/ordo-core/src/rule/compiler.rs index 98fe04cb..7683c9a5 100644 --- a/crates/ordo-core/src/rule/compiler.rs +++ b/crates/ordo-core/src/rule/compiler.rs @@ -2,10 +2,11 @@ use super::compiled::{ CompiledAction, CompiledBranch, CompiledCondition, CompiledMetadata, CompiledOutput, - CompiledRuleSet, CompiledStep, + CompiledRuleSet, CompiledStep, CompiledSubRuleBinding, CompiledSubRuleGraph, + CompiledSubRuleOutput, }; use super::model::{FieldMissingBehavior, RuleSet}; -use super::step::{ActionKind, Condition, LogLevel, StepKind, TerminalResult}; +use super::step::{ActionKind, Condition, LogLevel, Step, StepKind, SubRuleGraph, TerminalResult}; use crate::context::Value; use crate::error::{OrdoError, Result}; use crate::expr::{Expr, ExprCompiler, ExprParser}; @@ -40,92 +41,36 @@ impl RuleSetCompiler { let mut expressions = Vec::new(); let mut steps = Vec::with_capacity(ruleset.steps.len()); let mut step_hashes = HashMap::new(); + let mut sub_rule_names = HashMap::new(); for step_id in ruleset.steps.keys() { step_hashes.insert(step_id.as_str(), hash_step_id(step_id)); } + for name in ruleset.sub_rules.keys() { + sub_rule_names.insert(name.clone(), string_pool.intern(name)); + } // Check for hash collisions check_hash_collisions(&step_hashes)?; for step in ruleset.steps.values() { - let id_hash = step_hashes[step.id.as_str()]; - match &step.kind { - StepKind::Decision { - branches, - default_next, - } => { - let mut compiled_branches = Vec::with_capacity(branches.len()); - for branch in branches { - let condition = compile_condition(&branch.condition, &mut expressions)?; - let next_step = - *step_hashes.get(branch.next_step.as_str()).ok_or_else(|| { - OrdoError::StepNotFound { - step_id: branch.next_step.clone(), - } - })?; - let actions = - compile_actions(&branch.actions, &mut expressions, &mut string_pool)?; - compiled_branches.push(CompiledBranch { - condition, - next_step, - actions, - }); - } - let default_next = match default_next { - Some(id) => Some(*step_hashes.get(id.as_str()).ok_or_else(|| { - OrdoError::StepNotFound { - step_id: id.clone(), - } - })?), - None => None, - }; - steps.push(CompiledStep::Decision { - id_hash, - branches: compiled_branches, - default_next, - }); - } - StepKind::Action { actions, next_step } => { - let compiled_actions = - compile_actions(actions, &mut expressions, &mut string_pool)?; - let next_step_hash = *step_hashes.get(next_step.as_str()).ok_or_else(|| { - OrdoError::StepNotFound { - step_id: next_step.clone(), - } - })?; - steps.push(CompiledStep::Action { - id_hash, - actions: compiled_actions, - next_step: next_step_hash, - }); - } - StepKind::Terminal { result } => { - let compiled = compile_terminal(result, &mut expressions, &mut string_pool)?; - steps.push(CompiledStep::Terminal { - id_hash, - code: compiled.code, - message: compiled.message, - outputs: compiled.outputs, - data: compiled.data, - }); - } - StepKind::SubRule { next_step, .. } => { - // SubRule steps are not compiled into the binary format. - // They require the interpreted executor to run the embedded sub-graph. - let next_step_hash = *step_hashes.get(next_step.as_str()).ok_or_else(|| { - OrdoError::StepNotFound { - step_id: next_step.clone(), - } - })?; - // Represent as an action step with no actions (transparent pass-through) - steps.push(CompiledStep::Action { - id_hash, - actions: vec![], - next_step: next_step_hash, - }); - } - } + steps.push(compile_step( + step, + &step_hashes, + &sub_rule_names, + &mut expressions, + &mut string_pool, + )?); + } + + let mut sub_rules = HashMap::with_capacity(ruleset.sub_rules.len()); + for (name, graph) in &ruleset.sub_rules { + let name_idx = *sub_rule_names.get(name).ok_or_else(|| { + OrdoError::parse_error(format!("Sub-rule '{}' not interned", name)) + })?; + let compiled = + compile_sub_rule_graph(graph, &sub_rule_names, &mut expressions, &mut string_pool)?; + sub_rules.insert(name_idx, compiled); } let entry_step = step_hashes @@ -141,10 +86,151 @@ impl RuleSetCompiler { steps, expressions, string_pool.into_vec(), - )) + ) + .with_sub_rules(sub_rules)) } } +fn compile_step( + step: &Step, + step_hashes: &HashMap<&str, u32>, + sub_rule_names: &HashMap, + expressions: &mut Vec, + string_pool: &mut StringPool, +) -> Result { + let id_hash = step_hashes[step.id.as_str()]; + match &step.kind { + StepKind::Decision { + branches, + default_next, + } => { + let mut compiled_branches = Vec::with_capacity(branches.len()); + for branch in branches { + let condition = compile_condition(&branch.condition, expressions)?; + let next_step = *step_hashes.get(branch.next_step.as_str()).ok_or_else(|| { + OrdoError::StepNotFound { + step_id: branch.next_step.clone(), + } + })?; + let actions = compile_actions(&branch.actions, expressions, string_pool)?; + compiled_branches.push(CompiledBranch { + condition, + next_step, + actions, + }); + } + let default_next = + match default_next { + Some(id) => Some(*step_hashes.get(id.as_str()).ok_or_else(|| { + OrdoError::StepNotFound { + step_id: id.clone(), + } + })?), + None => None, + }; + Ok(CompiledStep::Decision { + id_hash, + branches: compiled_branches, + default_next, + }) + } + StepKind::Action { actions, next_step } => { + let compiled_actions = compile_actions(actions, expressions, string_pool)?; + let next_step_hash = + *step_hashes + .get(next_step.as_str()) + .ok_or_else(|| OrdoError::StepNotFound { + step_id: next_step.clone(), + })?; + Ok(CompiledStep::Action { + id_hash, + actions: compiled_actions, + next_step: next_step_hash, + }) + } + StepKind::Terminal { result } => { + let compiled = compile_terminal(result, expressions, string_pool)?; + Ok(CompiledStep::Terminal { + id_hash, + code: compiled.code, + message: compiled.message, + outputs: compiled.outputs, + data: compiled.data, + }) + } + StepKind::SubRule { + ref_name, + bindings, + outputs, + next_step, + } => { + let ref_name = *sub_rule_names.get(ref_name).ok_or_else(|| { + OrdoError::parse_error(format!("Sub-rule '{}' not found", ref_name)) + })?; + let bindings = bindings + .iter() + .map(|(name, expr)| CompiledSubRuleBinding { + name: string_pool.intern(name), + expr: compile_expr(expr, expressions), + }) + .collect(); + let outputs = outputs + .iter() + .map(|(parent_variable, child_variable)| CompiledSubRuleOutput { + parent_variable: string_pool.intern(parent_variable), + child_variable: string_pool.intern(child_variable), + }) + .collect(); + let next_step = + *step_hashes + .get(next_step.as_str()) + .ok_or_else(|| OrdoError::StepNotFound { + step_id: next_step.clone(), + })?; + Ok(CompiledStep::SubRule { + id_hash, + ref_name, + bindings, + outputs, + next_step, + }) + } + } +} + +fn compile_sub_rule_graph( + graph: &SubRuleGraph, + sub_rule_names: &HashMap, + expressions: &mut Vec, + string_pool: &mut StringPool, +) -> Result { + let mut step_hashes = HashMap::new(); + for step_id in graph.steps.keys() { + step_hashes.insert(step_id.as_str(), hash_step_id(step_id)); + } + check_hash_collisions(&step_hashes)?; + + let mut steps = Vec::with_capacity(graph.steps.len()); + for step in graph.steps.values() { + steps.push(compile_step( + step, + &step_hashes, + sub_rule_names, + expressions, + string_pool, + )?); + } + + let entry_step = step_hashes + .get(graph.entry_step.as_str()) + .copied() + .ok_or_else(|| OrdoError::StepNotFound { + step_id: graph.entry_step.clone(), + })?; + + Ok(CompiledSubRuleGraph::new(entry_step, steps)) +} + fn compile_condition( condition: &Condition, expressions: &mut Vec, diff --git a/crates/ordo-core/src/rule/executor.rs b/crates/ordo-core/src/rule/executor.rs index 9c345007..c4e56be1 100644 --- a/crates/ordo-core/src/rule/executor.rs +++ b/crates/ordo-core/src/rule/executor.rs @@ -306,6 +306,7 @@ impl RuleExecutor { 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, @@ -535,6 +536,7 @@ impl RuleExecutor { /// Execute a sub-rule graph and return the resulting context and optional trace frames. fn execute_sub_graph( &self, + sub_rules: &hashbrown::HashMap, graph: &SubRuleGraph, input: Value, field_missing: &FieldMissingBehavior, @@ -559,14 +561,62 @@ impl RuleExecutor { step_id: current.clone(), })?; - let (result, dur) = if tracing { + let (result, dur, sub_frames) = if let StepKind::SubRule { + ref_name, + bindings, + outputs, + next_step, + } = &step.kind + { + 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 = 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 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), + 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()); + } + } + ( + StepResult::Continue { + next_step: next_step.as_str(), + }, + dur, + if tracing { Some(child_frames) } else { None }, + ) + } 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) + (r, t.elapsed().as_micros() as u64, None) } else { ( self.execute_step(step, &mut ctx, field_missing, remaining_call_depth)?, 0, + None, ) }; @@ -583,6 +633,9 @@ impl RuleExecutor { if self.trace_config.capture_variables { st.variables_snapshot = Some(ctx.variables().clone()); } + if let Some(frames) = sub_frames { + st.sub_rule_frames = Some(frames); + } frames.push(st); } @@ -1479,6 +1532,132 @@ mod tests { ); } + #[test] + fn test_nested_sub_rule_executes_and_traces_frames() { + use crate::rule::step::{Action, ActionKind, SubRuleGraph}; + use crate::trace::TraceConfig; + + let mut normalize_steps = hashbrown::HashMap::new(); + normalize_steps.insert( + "set_score".to_string(), + Step::action( + "set_score", + "Set Score", + vec![Action { + kind: ActionKind::SetVariable { + name: "normalized".to_string(), + value: Expr::field("raw_score"), + }, + description: String::new(), + }], + "done", + ), + ); + normalize_steps.insert( + "done".to_string(), + Step::terminal("done", "Done", TerminalResult::new("OK")), + ); + + let mut classify_steps = hashbrown::HashMap::new(); + classify_steps.insert( + "normalize".to_string(), + Step { + id: "normalize".to_string(), + name: "Normalize".to_string(), + kind: StepKind::SubRule { + ref_name: "normalize_score".to_string(), + bindings: vec![("raw_score".to_string(), Expr::field("score"))], + outputs: vec![("score_for_tier".to_string(), "normalized".to_string())], + next_step: "check".to_string(), + }, + }, + ); + classify_steps.insert( + "check".to_string(), + Step::decision("check", "Check") + .branch(Condition::from_string("$score_for_tier >= 90"), "gold") + .default("silver") + .build(), + ); + classify_steps.insert( + "gold".to_string(), + Step::action( + "gold", + "Gold", + vec![Action { + kind: ActionKind::SetVariable { + name: "tier".to_string(), + value: Expr::literal("gold"), + }, + description: String::new(), + }], + "done", + ), + ); + classify_steps.insert( + "silver".to_string(), + Step::action( + "silver", + "Silver", + vec![Action { + kind: ActionKind::SetVariable { + name: "tier".to_string(), + value: Expr::literal("silver"), + }, + description: String::new(), + }], + "done", + ), + ); + classify_steps.insert( + "done".to_string(), + Step::terminal("done", "Done", TerminalResult::new("OK")), + ); + + let mut ruleset = RuleSet::new("main", "classify"); + ruleset.config.enable_trace = true; + ruleset.add_sub_rule( + "normalize_score", + SubRuleGraph { + entry_step: "set_score".to_string(), + steps: normalize_steps, + }, + ); + ruleset.add_sub_rule( + "classify_score", + SubRuleGraph { + entry_step: "normalize".to_string(), + steps: classify_steps, + }, + ); + ruleset.add_step(Step { + id: "classify".to_string(), + name: "Classify".to_string(), + kind: StepKind::SubRule { + ref_name: "classify_score".to_string(), + bindings: vec![("score".to_string(), Expr::field("score"))], + outputs: vec![("tier".to_string(), "tier".to_string())], + next_step: "end".to_string(), + }, + }); + ruleset.add_step(Step::terminal( + "end", + "End", + TerminalResult::new("DONE").with_output("tier", Expr::field("$tier")), + )); + + ruleset.validate().unwrap(); + let executor = RuleExecutor::with_trace(TraceConfig::minimal()); + let input: Value = serde_json::from_str(r#"{"score": 95}"#).unwrap(); + let result = executor.execute(&ruleset, input).unwrap(); + + assert_eq!(result.output.get_path("tier"), Some(&Value::string("gold"))); + let trace = result.trace.unwrap(); + let top_frames = trace.steps[0].sub_rule_frames.as_ref().unwrap(); + assert_eq!(top_frames[0].step_id, "normalize"); + assert!(top_frames[0].sub_rule_frames.is_some()); + } + #[test] fn test_sub_rule_validation_cycle() { use crate::rule::step::SubRuleGraph; From 00f6f4baa25ba1d16b1a2f1aa43b6772707187db Mon Sep 17 00:00:00 2001 From: Pama-Lee Date: Sat, 25 Apr 2026 21:48:42 +0800 Subject: [PATCH 4/4] fix(flow): allow multiple nodes to connect to the same input handle - Replace existing outgoing exec edge when reconnecting an action/sub_rule node to prevent stale edges accumulating; findLinearExecutionEdge uses Array.find so the first (old) edge would silently win over the new one - Enlarge input handle hit area on all node types (Action, Decision, Terminal, SubRule) from 10x10 to 20x20 px via padding, making it easier to land a second incoming connection on an already-connected handle --- .../src/components/flow/OrdoFlowEditor.vue | 88 ++++++- .../src/components/flow/nodes/ActionNode.vue | 3 +- .../components/flow/nodes/DecisionNode.vue | 3 +- .../src/components/flow/nodes/SubRuleNode.vue | 216 ++++++++++++++++++ .../components/flow/nodes/TerminalNode.vue | 3 +- 5 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 ordo-editor/packages/vue/src/components/flow/nodes/SubRuleNode.vue diff --git a/ordo-editor/packages/vue/src/components/flow/OrdoFlowEditor.vue b/ordo-editor/packages/vue/src/components/flow/OrdoFlowEditor.vue index 35bd9ddd..215aee28 100644 --- a/ordo-editor/packages/vue/src/components/flow/OrdoFlowEditor.vue +++ b/ordo-editor/packages/vue/src/components/flow/OrdoFlowEditor.vue @@ -11,7 +11,14 @@ import { MiniMap } from '@vue-flow/minimap'; import type { RuleSet, Step } from '@ordo-engine/editor-core'; import { Step as StepFactory, generateId } from '@ordo-engine/editor-core'; -import { DecisionNode, ActionNode, TerminalNode, GroupNode, type StepTraceInfo } from './nodes'; +import { + DecisionNode, + ActionNode, + TerminalNode, + SubRuleNode, + GroupNode, + type StepTraceInfo, +} from './nodes'; import { OrdoEdge } from './edges'; import OrdoFlowToolbar from './OrdoFlowToolbar.vue'; import OrdoFlowPropertyPanel from './OrdoFlowPropertyPanel.vue'; @@ -32,7 +39,7 @@ import { } from './utils/layout'; import { useI18n, LOCALE_KEY, type Lang } from '../../locale'; import type { FieldSuggestion } from '../base/OrdoExpressionInput.vue'; -type NodeCreationType = 'decision' | 'action' | 'terminal'; +type NodeCreationType = 'decision' | 'action' | 'terminal' | 'sub_rule'; /** Execution trace data for overlay */ export interface ExecutionTraceData { @@ -78,6 +85,7 @@ const props = withDefaults(defineProps(), { const emit = defineEmits<{ 'update:modelValue': [value: RuleSet]; change: [value: RuleSet]; + 'open-sub-rule': [name: string]; }>(); const FLOW_EDGE_STYLE_KEY = '_flowEdgeStyle'; @@ -135,6 +143,7 @@ const nodeTypes: Record = { decision: markRaw(DecisionNode), action: markRaw(ActionNode), terminal: markRaw(TerminalNode), + sub_rule: markRaw(SubRuleNode), group: markRaw(GroupNode), }; @@ -208,6 +217,8 @@ const nodeDragPreviewLabel = computed(() => { return t('step.action'); case 'terminal': return t('step.terminal'); + case 'sub_rule': + return t('step.subRule'); } }); @@ -221,6 +232,8 @@ const nodeDragPreviewTypeLabel = computed(() => { return t('step.typeAction'); case 'terminal': return t('step.typeTerminal'); + case 'sub_rule': + return t('step.typeSubRule'); } }); @@ -285,7 +298,8 @@ function syncToRuleset() { edges.value, buildFlowConfig(), props.modelValue.startStepId, - currentGroupNodes + currentGroupNodes, + props.modelValue.subRules ); emit('update:modelValue', newRuleset); emit('change', newRuleset); @@ -610,6 +624,13 @@ function onPaneClick() { hideContextMenu(); } +function onNodeDblClick(event: any) { + const step = event.node?.data?.step; + if (step?.type === 'sub_rule' && step.refName) { + emit('open-sub-rule', step.refName); + } +} + // Handle right-click on pane function onPaneContextMenu(event: MouseEvent) { if (isCanvasReadOnly.value) return; @@ -701,7 +722,25 @@ onConnect((params) => { targetHandle: params.targetHandle || undefined, renderStyle: edgeStyle.value, }); - edges.value.push(newEdge); + + // For action/sub_rule nodes, an output handle can only have one outgoing exec edge. + // Replace any existing outgoing exec edge from the same source+handle to avoid + // findLinearExecutionEdge picking up the stale edge and silently discarding the new one. + const isLinearExecEdge = + !newEdge.data?.branchId && !newEdge.data?.isDefault && newEdge.data?.edgeType === 'exec'; + const filtered = isLinearExecEdge + ? edges.value.filter( + (e) => + !( + e.source === newEdge.source && + e.data?.edgeType === 'exec' && + !e.data?.branchId && + !e.data?.isDefault + ) + ) + : edges.value; + + edges.value = [...filtered, newEdge]; syncToRuleset(); }); @@ -857,6 +896,18 @@ function createStep(type: NodeCreationType, id: string): Step { name: t('step.terminal'), code: 'RESULT', }); + case 'sub_rule': + const firstSubRuleName = Object.keys(props.modelValue.subRules ?? {})[0] ?? ''; + return StepFactory.subRule({ + id, + name: t('step.subRule'), + refName: firstSubRuleName, + assetRef: { + scope: 'project', + name: firstSubRuleName, + }, + nextStepId: '', + }); } } @@ -896,7 +947,7 @@ function addNode(type: NodeCreationType) { } function isNodeCreationType(value: string): value is NodeCreationType { - return value === 'decision' || value === 'action' || value === 'terminal'; + return value === 'decision' || value === 'action' || value === 'terminal' || value === 'sub_rule'; } function clearNodeDragPreview() { @@ -1114,6 +1165,14 @@ function duplicateSelectedNode() { name: `${originalStep.name} (copy)`, }); break; + case 'sub_rule': + newStep = StepFactory.subRule({ + ...originalStep, + id: newId, + name: `${originalStep.name} (copy)`, + nextStepId: '', + }); + break; default: hideContextMenu(); return; @@ -1232,7 +1291,14 @@ function setAsStart(nodeId: string) { }, })); - const newRuleset = flowToRuleset(nodes.value, edges.value, buildFlowConfig(), nodeId); + const newRuleset = flowToRuleset( + nodes.value, + edges.value, + buildFlowConfig(), + nodeId, + undefined, + props.modelValue.subRules + ); emit('update:modelValue', newRuleset); emit('change', newRuleset); } @@ -1415,6 +1481,7 @@ onMounted(() => { :multi-selection-key-code="['Meta', 'Control']" class="flow-canvas" @node-click="onNodeClick" + @node-double-click="onNodeDblClick" @pane-click="onPaneClick" @selection-change="onSelectionChange" @node-context-menu="onNodeContextMenu" @@ -1581,6 +1648,7 @@ onMounted(() => { v-if="selectedStepNode && !isCanvasReadOnly" :node="selectedStepNode" :available-steps="modelValue.steps" + :available-sub-rules="modelValue.subRules ?? {}" :suggestions="suggestions" :disabled="disabled" @update="updateNode" @@ -1694,6 +1762,10 @@ onMounted(() => { border-color: var(--ordo-node-terminal, #388a34); } +.node-drag-preview.type-sub_rule { + border-color: var(--ordo-node-sub-rule, #5b708a); +} + .node-drag-preview-header { display: flex; align-items: center; @@ -1732,6 +1804,10 @@ onMounted(() => { color: var(--ordo-node-terminal, #388a34); } +.node-drag-preview.type-sub_rule .node-drag-preview-icon { + color: var(--ordo-node-sub-rule, #5b708a); +} + /* Vue Flow overrides */ :deep(.vue-flow__minimap) { background: var(--ordo-bg-panel); diff --git a/ordo-editor/packages/vue/src/components/flow/nodes/ActionNode.vue b/ordo-editor/packages/vue/src/components/flow/nodes/ActionNode.vue index ac1c738f..a2e14481 100644 --- a/ordo-editor/packages/vue/src/components/flow/nodes/ActionNode.vue +++ b/ordo-editor/packages/vue/src/components/flow/nodes/ActionNode.vue @@ -347,9 +347,10 @@ function formatValue(assignment: VariableAssignment): string { /* Input pin positioning (in header) */ .node-header .pin-input { position: absolute; - left: -5px; + left: -10px; top: 50%; transform: translateY(-50%); + padding: 5px; } /* Output pin positioning (in rows) */ diff --git a/ordo-editor/packages/vue/src/components/flow/nodes/DecisionNode.vue b/ordo-editor/packages/vue/src/components/flow/nodes/DecisionNode.vue index 62a0fb11..c7d00054 100644 --- a/ordo-editor/packages/vue/src/components/flow/nodes/DecisionNode.vue +++ b/ordo-editor/packages/vue/src/components/flow/nodes/DecisionNode.vue @@ -329,9 +329,10 @@ function getBranchTooltip(branch: { condition?: unknown }): string { /* Input pin positioning (in header) */ .node-header .pin-input { position: absolute; - left: -5px; + left: -10px; top: 50%; transform: translateY(-50%); + padding: 5px; } /* Output pin positioning (in rows) */ diff --git a/ordo-editor/packages/vue/src/components/flow/nodes/SubRuleNode.vue b/ordo-editor/packages/vue/src/components/flow/nodes/SubRuleNode.vue new file mode 100644 index 00000000..8cc91282 --- /dev/null +++ b/ordo-editor/packages/vue/src/components/flow/nodes/SubRuleNode.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/ordo-editor/packages/vue/src/components/flow/nodes/TerminalNode.vue b/ordo-editor/packages/vue/src/components/flow/nodes/TerminalNode.vue index bbdcbccb..834e5c87 100644 --- a/ordo-editor/packages/vue/src/components/flow/nodes/TerminalNode.vue +++ b/ordo-editor/packages/vue/src/components/flow/nodes/TerminalNode.vue @@ -285,9 +285,10 @@ function formatOutputValue(output: OutputField): string { /* Input pin positioning (in header) */ .node-header .pin-input { position: absolute; - left: -5px; + left: -10px; top: 50%; transform: translateY(-50%); + padding: 5px; } /* Data pins in output rows */