diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d77be1aa..ee03e8c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: ### Linux (x86_64) ```bash - curl -LO https://github.com/Pama-Lee/Ordo/releases/download/${{ github.ref_name }}/ordo-x86_64-unknown-linux-gnu.tar.gz + curl -LO https://github.com/Ordo-Engine/Ordo/releases/download/${{ github.ref_name }}/ordo-x86_64-unknown-linux-gnu.tar.gz tar -xzf ordo-x86_64-unknown-linux-gnu.tar.gz chmod +x ordo-server ./ordo-server @@ -62,7 +62,7 @@ jobs: ### macOS (Apple Silicon) ```bash - curl -LO https://github.com/Pama-Lee/Ordo/releases/download/${{ github.ref_name }}/ordo-aarch64-apple-darwin.tar.gz + curl -LO https://github.com/Ordo-Engine/Ordo/releases/download/${{ github.ref_name }}/ordo-aarch64-apple-darwin.tar.gz tar -xzf ordo-aarch64-apple-darwin.tar.gz chmod +x ordo-server ./ordo-server @@ -70,7 +70,7 @@ jobs: ### macOS (Intel) ```bash - curl -LO https://github.com/Pama-Lee/Ordo/releases/download/${{ github.ref_name }}/ordo-x86_64-apple-darwin.tar.gz + curl -LO https://github.com/Ordo-Engine/Ordo/releases/download/${{ github.ref_name }}/ordo-x86_64-apple-darwin.tar.gz tar -xzf ordo-x86_64-apple-darwin.tar.gz chmod +x ordo-server ./ordo-server @@ -220,4 +220,3 @@ jobs: platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max - diff --git a/CHANGELOG.md b/CHANGELOG.md index f68def65..7660fe3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,233 +9,233 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation -- Add benchmark race GIF to README Performance section ([#42](https://github.com/Pama-Lee/Ordo/pull/42)) -([6b0c57b](https://github.com/Pama-Lee/Ordo/commit/6b0c57b23dbe9308ce5e6178dcc4d31cfbc17ee3)) -- Add benchmark visualization and competitive comparison data ([#40](https://github.com/Pama-Lee/Ordo/pull/40)) -([f5dba2e](https://github.com/Pama-Lee/Ordo/commit/f5dba2e026bfde43c157061ea156e6c38d5bb27c)) +- Add benchmark race GIF to README Performance section ([#42](https://github.com/Ordo-Engine/Ordo/pull/42)) +([6b0c57b](https://github.com/Ordo-Engine/Ordo/commit/6b0c57b23dbe9308ce5e6178dcc4d31cfbc17ee3)) +- Add benchmark visualization and competitive comparison data ([#40](https://github.com/Ordo-Engine/Ordo/pull/40)) +([f5dba2e](https://github.com/Ordo-Engine/Ordo/commit/f5dba2e026bfde43c157061ea156e6c38d5bb27c)) ### Features -- **ordo-core:** Add 23 extended built-in functions ([#52](https://github.com/Pama-Lee/Ordo/pull/52)) -([7860cfa](https://github.com/Pama-Lee/Ordo/commit/7860cfaefbfbcd30ee5f9086d6b98962b0ad83e2)) -- **ordo-core:** Add 30+ extended built-in functions ([#48](https://github.com/Pama-Lee/Ordo/pull/48)) -([b4adbbc](https://github.com/Pama-Lee/Ordo/commit/b4adbbc76375e480a10b7e3f8302f41f501ae6c7)) -- **ordo-server:** Add external reference data store ([#50](https://github.com/Pama-Lee/Ordo/pull/50)) -([d3f071f](https://github.com/Pama-Lee/Ordo/commit/d3f071f5731aea3d3ed1274da202c5a840fc5e6e)) -- **rule-composition:** Add CallRuleSet action and pipeline API ([#51](https://github.com/Pama-Lee/Ordo/pull/51)) -([47354c2](https://github.com/Pama-Lee/Ordo/commit/47354c21e5bfc434e7ba0542a0390531228c91b7)) -- **sdk:** Add Python SDK with HTTP/gRPC support ([#53](https://github.com/Pama-Lee/Ordo/pull/53)) -([3aafb56](https://github.com/Pama-Lee/Ordo/commit/3aafb56297a459153c4b2f9489cf4bd5e9f8a7f6)) -- **server:** Add store resource limits (max_rules_per_tenant, max_total_rules) ([#38](https://github.com/Pama-Lee/Ordo/pull/38)) -([bc7be7f](https://github.com/Pama-Lee/Ordo/commit/bc7be7fe7fd270b8858b25205eeb4e0dfd0d2750)) -- **server:** Add config validation, sync metrics, and instance ID improvement ([#36](https://github.com/Pama-Lee/Ordo/pull/36)) -([4725269](https://github.com/Pama-Lee/Ordo/commit/4725269e395c590a87b22898f4ed08d7e6b9c099)) -- **server:** Add NATS JetStream sync for distributed deployments ([#34](https://github.com/Pama-Lee/Ordo/pull/34)) -([d08d400](https://github.com/Pama-Lee/Ordo/commit/d08d40098d2c51806e1116fb1b3770bfa595bdc1)) +- **ordo-core:** Add 23 extended built-in functions ([#52](https://github.com/Ordo-Engine/Ordo/pull/52)) +([7860cfa](https://github.com/Ordo-Engine/Ordo/commit/7860cfaefbfbcd30ee5f9086d6b98962b0ad83e2)) +- **ordo-core:** Add 30+ extended built-in functions ([#48](https://github.com/Ordo-Engine/Ordo/pull/48)) +([b4adbbc](https://github.com/Ordo-Engine/Ordo/commit/b4adbbc76375e480a10b7e3f8302f41f501ae6c7)) +- **ordo-server:** Add external reference data store ([#50](https://github.com/Ordo-Engine/Ordo/pull/50)) +([d3f071f](https://github.com/Ordo-Engine/Ordo/commit/d3f071f5731aea3d3ed1274da202c5a840fc5e6e)) +- **rule-composition:** Add CallRuleSet action and pipeline API ([#51](https://github.com/Ordo-Engine/Ordo/pull/51)) +([47354c2](https://github.com/Ordo-Engine/Ordo/commit/47354c21e5bfc434e7ba0542a0390531228c91b7)) +- **sdk:** Add Python SDK with HTTP/gRPC support ([#53](https://github.com/Ordo-Engine/Ordo/pull/53)) +([3aafb56](https://github.com/Ordo-Engine/Ordo/commit/3aafb56297a459153c4b2f9489cf4bd5e9f8a7f6)) +- **server:** Add store resource limits (max_rules_per_tenant, max_total_rules) ([#38](https://github.com/Ordo-Engine/Ordo/pull/38)) +([bc7be7f](https://github.com/Ordo-Engine/Ordo/commit/bc7be7fe7fd270b8858b25205eeb4e0dfd0d2750)) +- **server:** Add config validation, sync metrics, and instance ID improvement ([#36](https://github.com/Ordo-Engine/Ordo/pull/36)) +([4725269](https://github.com/Ordo-Engine/Ordo/commit/4725269e395c590a87b22898f4ed08d7e6b9c099)) +- **server:** Add NATS JetStream sync for distributed deployments ([#34](https://github.com/Ordo-Engine/Ordo/pull/34)) +([d08d400](https://github.com/Ordo-Engine/Ordo/commit/d08d40098d2c51806e1116fb1b3770bfa595bdc1)) ### Performance -- **server:** Optimize HTTP serialization and reduce lock contention ([#43](https://github.com/Pama-Lee/Ordo/pull/43)) -([1e4415a](https://github.com/Pama-Lee/Ordo/commit/1e4415a29874d96c90809597af5e5f2c6e733b79)) +- **server:** Optimize HTTP serialization and reduce lock contention ([#43](https://github.com/Ordo-Engine/Ordo/pull/43)) +([1e4415a](https://github.com/Ordo-Engine/Ordo/commit/1e4415a29874d96c90809597af5e5f2c6e733b79)) ## [0.3.0] - 2026-03-06 ### Bug Fixes - **ci:** Skip JIT benchmarks in GitHub Actions -([54c9c06](https://github.com/Pama-Lee/Ordo/commit/54c9c0654d5eeef75146c3ffff19704a4b45c95f)) +([54c9c06](https://github.com/Ordo-Engine/Ordo/commit/54c9c0654d5eeef75146c3ffff19704a4b45c95f)) - **docs:** Use dynamic base path for language redirect -([ad64f9c](https://github.com/Pama-Lee/Ordo/commit/ad64f9c2f7d55cde9b473b97c97d59153473ac1a)) +([ad64f9c](https://github.com/Ordo-Engine/Ordo/commit/ad64f9c2f7d55cde9b473b97c97d59153473ac1a)) - Improve error handling and add project skills -([18e9832](https://github.com/Pama-Lee/Ordo/commit/18e98320e341bcbedd0e84a85571ac912dd00249)) +([18e9832](https://github.com/Ordo-Engine/Ordo/commit/18e98320e341bcbedd0e84a85571ac912dd00249)) - Make signature feature optional for WASM compatibility -([ed676a5](https://github.com/Pama-Lee/Ordo/commit/ed676a560dd693b3851832182de1ecd4ca05793d)) +([ed676a5](https://github.com/Ordo-Engine/Ordo/commit/ed676a560dd693b3851832182de1ecd4ca05793d)) ### Documentation - Add integration guides for Nomad and Kubernetes -([10ecea7](https://github.com/Pama-Lee/Ordo/commit/10ecea7b520b623480b7cfa73d2c43aad01cd622)) +([10ecea7](https://github.com/Ordo-Engine/Ordo/commit/10ecea7b520b623480b7cfa73d2c43aad01cd622)) - Update documentation for v0.2.0 release -([39d1dfa](https://github.com/Pama-Lee/Ordo/commit/39d1dfa4bf2393684066b9adeee2435ca382cb7c)) +([39d1dfa](https://github.com/Ordo-Engine/Ordo/commit/39d1dfa4bf2393684066b9adeee2435ca382cb7c)) ### Features -- **grpc:** Add multi-tenancy support and batch execution ([#26](https://github.com/Pama-Lee/Ordo/pull/26)) -([445888d](https://github.com/Pama-Lee/Ordo/commit/445888df85e4da95798339043fc13dba85acebd1)) +- **grpc:** Add multi-tenancy support and batch execution ([#26](https://github.com/Ordo-Engine/Ordo/pull/26)) +([445888d](https://github.com/Ordo-Engine/Ordo/commit/445888df85e4da95798339043fc13dba85acebd1)) - **playground:** Add .ordo file import/export support -([dcf2383](https://github.com/Pama-Lee/Ordo/commit/dcf238316555abe883ac4b595dde56ac5344f848)) -- **server:** Add Writer/Reader role deployment, file watcher, K8s health probes, and request limits ([#30](https://github.com/Pama-Lee/Ordo/pull/30)) -([1a6deef](https://github.com/Pama-Lee/Ordo/commit/1a6deef44b7aef6c9c1b4be223b28f36824afd23)) -- **server:** Graceful shutdown, panic recovery, and OpenTelemetry ([#27](https://github.com/Pama-Lee/Ordo/pull/27)) -([257878c](https://github.com/Pama-Lee/Ordo/commit/257878c326758b2ea4af560677878e52dd735e58)) +([dcf2383](https://github.com/Ordo-Engine/Ordo/commit/dcf238316555abe883ac4b595dde56ac5344f848)) +- **server:** Add Writer/Reader role deployment, file watcher, K8s health probes, and request limits ([#30](https://github.com/Ordo-Engine/Ordo/pull/30)) +([1a6deef](https://github.com/Ordo-Engine/Ordo/commit/1a6deef44b7aef6c9c1b4be223b28f36824afd23)) +- **server:** Graceful shutdown, panic recovery, and OpenTelemetry ([#27](https://github.com/Ordo-Engine/Ordo/pull/27)) +([257878c](https://github.com/Ordo-Engine/Ordo/commit/257878c326758b2ea4af560677878e52dd735e58)) - **server:** Add multi-tenancy support with namespace isolation -([5b4cef7](https://github.com/Pama-Lee/Ordo/commit/5b4cef79197e855d6b33e08d0a31502256addc31)) -- Add expression input limits and HTTP API integration tests ([#28](https://github.com/Pama-Lee/Ordo/pull/28)) -([c5663be](https://github.com/Pama-Lee/Ordo/commit/c5663becf948b1411b76fb40cd1db2cf894ff19d)) +([5b4cef7](https://github.com/Ordo-Engine/Ordo/commit/5b4cef79197e855d6b33e08d0a31502256addc31)) +- Add expression input limits and HTTP API integration tests ([#28](https://github.com/Ordo-Engine/Ordo/pull/28)) +([c5663be](https://github.com/Ordo-Engine/Ordo/commit/c5663becf948b1411b76fb40cd1db2cf894ff19d)) - Add Ed25519 rule signature verification -([83a13f7](https://github.com/Pama-Lee/Ordo/commit/83a13f788b3668a2d3598a5e6bc903d074d5c079)) +([83a13f7](https://github.com/Ordo-Engine/Ordo/commit/83a13f788b3668a2d3598a5e6bc903d074d5c079)) ## [0.2.0] - 2026-01-18 ### Bug Fixes - **docs:** Add CUSTOM_DOMAIN env var to vercel.json -([ecf6e37](https://github.com/Pama-Lee/Ordo/commit/ecf6e37d3d3ce7127936456bf593eac40c2ac491)) +([ecf6e37](https://github.com/Ordo-Engine/Ordo/commit/ecf6e37d3d3ce7127936456bf593eac40c2ac491)) - **docs:** Add vercel.json with correct output directory -([f9e9657](https://github.com/Pama-Lee/Ordo/commit/f9e96575c84575902d46389eb8fc35bbb500d7da)) +([f9e9657](https://github.com/Ordo-Engine/Ordo/commit/f9e96575c84575902d46389eb8fc35bbb500d7da)) - **playground:** Use dynamic VERSION from editor-core -([5b80f34](https://github.com/Pama-Lee/Ordo/commit/5b80f344e49df3f784e906af903f61dc7416c265)) +([5b80f34](https://github.com/Ordo-Engine/Ordo/commit/5b80f344e49df3f784e906af903f61dc7416c265)) - **wasm:** Make JIT feature optional for wasm32 target -([8780814](https://github.com/Pama-Lee/Ordo/commit/87808145e075753f24943242c96f9f025fe29751)) +([8780814](https://github.com/Ordo-Engine/Ordo/commit/87808145e075753f24943242c96f9f025fe29751)) ### Documentation - Update README and add dual-domain deployment support -([e204527](https://github.com/Pama-Lee/Ordo/commit/e20452764aefe8598a38aab95c736d35318bfa87)) +([e204527](https://github.com/Ordo-Engine/Ordo/commit/e20452764aefe8598a38aab95c736d35318bfa87)) - Fix dead links in quick-start.md -([a83fecf](https://github.com/Pama-Lee/Ordo/commit/a83fecfae8d4b1f2f272e984893d8c27bd9f64c7)) +([a83fecf](https://github.com/Ordo-Engine/Ordo/commit/a83fecfae8d4b1f2f272e984893d8c27bd9f64c7)) ### Features - **expr:** Implement expression optimization techniques -([04e2cf5](https://github.com/Pama-Lee/Ordo/commit/04e2cf5bc2e84868b7568e626af4130fc868e116)) +([04e2cf5](https://github.com/Ordo-Engine/Ordo/commit/04e2cf5bc2e84868b7568e626af4130fc868e116)) - **jit:** Implement schema-based JIT compilation system -([d2d97f8](https://github.com/Pama-Lee/Ordo/commit/d2d97f8f07e690a9048bab1b8a7875211a606f21)) +([d2d97f8](https://github.com/Ordo-Engine/Ordo/commit/d2d97f8f07e690a9048bab1b8a7875211a606f21)) - **npm:** Setup npm publishing with changesets -([18f1f1b](https://github.com/Pama-Lee/Ordo/commit/18f1f1bd2b2ecb7edf33c7b859384b713db855ac)) +([18f1f1b](https://github.com/Ordo-Engine/Ordo/commit/18f1f1bd2b2ecb7edf33c7b859384b713db855ac)) - Implement silent JIT compilation system -([570211a](https://github.com/Pama-Lee/Ordo/commit/570211a36238cfefd68a0fceff610da85af95efc)) +([570211a](https://github.com/Ordo-Engine/Ordo/commit/570211a36238cfefd68a0fceff610da85af95efc)) - Add VM visualization debug system with ruleset debugging support -([372aad9](https://github.com/Pama-Lee/Ordo/commit/372aad91484d45b4e02b00b7f71dbb1df62d81e1)) +([372aad9](https://github.com/Ordo-Engine/Ordo/commit/372aad91484d45b4e02b00b7f71dbb1df62d81e1)) - Add batch execution API for improved throughput -([e54c6ef](https://github.com/Pama-Lee/Ordo/commit/e54c6ef966f9724d02c8d3efea9a8cdcedc54652)) +([e54c6ef](https://github.com/Ordo-Engine/Ordo/commit/e54c6ef966f9724d02c8d3efea9a8cdcedc54652)) - Add lightweight Prometheus metrics for better observability -([da02a54](https://github.com/Pama-Lee/Ordo/commit/da02a54187a3da3b95d3ba0df52c999097f4911a)) +([da02a54](https://github.com/Ordo-Engine/Ordo/commit/da02a54187a3da3b95d3ba0df52c999097f4911a)) ### Miscellaneous - Bump version to 0.2.0 -([58b4086](https://github.com/Pama-Lee/Ordo/commit/58b40866e11bbf8c87dd1db8134a430b369433da)) +([58b4086](https://github.com/Ordo-Engine/Ordo/commit/58b40866e11bbf8c87dd1db8134a430b369433da)) - Remove benchmark results from git tracking -([1d959ff](https://github.com/Pama-Lee/Ordo/commit/1d959ffce1e82bd073738c0e36fe27f043c7fc65)) +([1d959ff](https://github.com/Ordo-Engine/Ordo/commit/1d959ffce1e82bd073738c0e36fe27f043c7fc65)) ### Refactor - **release:** Simplify npm publishing workflow -([8dbf510](https://github.com/Pama-Lee/Ordo/commit/8dbf5102e1db87c31408e736db4966f6a84bfe56)) +([8dbf510](https://github.com/Ordo-Engine/Ordo/commit/8dbf5102e1db87c31408e736db4966f6a84bfe56)) ## [0.1.8] - 2026-01-14 ### Features - **docs:** Enhance documentation with multilingual support and new guides -([17e9ab8](https://github.com/Pama-Lee/Ordo/commit/17e9ab81553b703874392a4d048b432d0199027b)) +([17e9ab8](https://github.com/Ordo-Engine/Ordo/commit/17e9ab81553b703874392a4d048b432d0199027b)) ### Performance - CPU efficiency optimizations for rule engine -([6b39377](https://github.com/Pama-Lee/Ordo/commit/6b393776d1c28cd74c4f4489538dde1cf44a8a4a)) +([6b39377](https://github.com/Ordo-Engine/Ordo/commit/6b393776d1c28cd74c4f4489538dde1cf44a8a4a)) ## [0.1.7] - 2026-01-12 ### Features - **core:** Implement MetricSink trait for custom rule metrics integration -([f014700](https://github.com/Pama-Lee/Ordo/commit/f014700d30b11005087692c2318438cdd12a64c7)) +([f014700](https://github.com/Ordo-Engine/Ordo/commit/f014700d30b11005087692c2318438cdd12a64c7)) - Add favicons to Playground and Docs, add PostHog analytics to VitePress -([3e21c5a](https://github.com/Pama-Lee/Ordo/commit/3e21c5abff075df2d416fba8d865206f9b9b9690)) +([3e21c5a](https://github.com/Ordo-Engine/Ordo/commit/3e21c5abff075df2d416fba8d865206f9b9b9690)) ## [0.1.6] - 2026-01-12 ### Bug Fixes - **docs:** Include VitePress config.mts in git (was ignored) -([5d53312](https://github.com/Pama-Lee/Ordo/commit/5d53312b62ec53ba1840adb4538cb181de4cfa18)) +([5d53312](https://github.com/Ordo-Engine/Ordo/commit/5d53312b62ec53ba1840adb4538cb181de4cfa18)) - **docs:** Wrap localhost URL in code backticks to avoid dead link error -([cda6b8e](https://github.com/Pama-Lee/Ordo/commit/cda6b8e94a8d12aefd68bb295208b1d599249910)) +([cda6b8e](https://github.com/Ordo-Engine/Ordo/commit/cda6b8e94a8d12aefd68bb295208b1d599249910)) - **docs:** Correct formatting of PromQL examples in metrics documentation -([f4ee5ea](https://github.com/Pama-Lee/Ordo/commit/f4ee5ea51ad6f47fa891568b733dde40e0d7ee85)) +([f4ee5ea](https://github.com/Ordo-Engine/Ordo/commit/f4ee5ea51ad6f47fa891568b733dde40e0d7ee85)) - Resolve clippy warnings and enhance pre-commit hook with clippy check -([a7ccf2e](https://github.com/Pama-Lee/Ordo/commit/a7ccf2e3f50589e08f9e4853a66d5cf36664ec17)) +([a7ccf2e](https://github.com/Ordo-Engine/Ordo/commit/a7ccf2e3f50589e08f9e4853a66d5cf36664ec17)) - Add WASM stub for playground build without Rust -([49b1171](https://github.com/Pama-Lee/Ordo/commit/49b1171a92f9a4f40a7d340ed941b94262bd3407)) +([49b1171](https://github.com/Ordo-Engine/Ordo/commit/49b1171a92f9a4f40a7d340ed941b94262bd3407)) ### Documentation - Update README with visual editor screenshots -([629feb2](https://github.com/Pama-Lee/Ordo/commit/629feb2f9f389dcdf43308a21fe269d46c7a785d)) +([629feb2](https://github.com/Ordo-Engine/Ordo/commit/629feb2f9f389dcdf43308a21fe269d46c7a785d)) ### Features - **docs:** Add comprehensive documentation for Ordo rule engine -([8c6bedd](https://github.com/Pama-Lee/Ordo/commit/8c6bedd8ec78e2eba72ea9b39beb481c0fda92ae)) +([8c6bedd](https://github.com/Ordo-Engine/Ordo/commit/8c6bedd8ec78e2eba72ea9b39beb481c0fda92ae)) - **playground:** Add PostHog analytics integration -([8e79612](https://github.com/Pama-Lee/Ordo/commit/8e796129d509efa8440ee559e2f70dd8884df4cd)) +([8e79612](https://github.com/Ordo-Engine/Ordo/commit/8e796129d509efa8440ee559e2f70dd8884df4cd)) - **server:** Add structured audit logging with dynamic sample rate -([ef651d5](https://github.com/Pama-Lee/Ordo/commit/ef651d5a0d77cac6365396ba13be8d7e39f17d05)) +([ef651d5](https://github.com/Ordo-Engine/Ordo/commit/ef651d5a0d77cac6365396ba13be8d7e39f17d05)) - **server:** Add rule versioning with rollback support -([ec29570](https://github.com/Pama-Lee/Ordo/commit/ec29570aed40e613396389c1dcd3ee7883dbbab9)) +([ec29570](https://github.com/Ordo-Engine/Ordo/commit/ec29570aed40e613396389c1dcd3ee7883dbbab9)) - Add Prometheus metrics and enhanced health check endpoint -([d92db86](https://github.com/Pama-Lee/Ordo/commit/d92db86e1d945834568a5f6b8a5378b74a4aa1c1)) +([d92db86](https://github.com/Ordo-Engine/Ordo/commit/d92db86e1d945834568a5f6b8a5378b74a4aa1c1)) - Implement file-based rule persistence in Ordo server -([c64d0f5](https://github.com/Pama-Lee/Ordo/commit/c64d0f55fec57687d94843ab372c82f679f58461)) +([c64d0f5](https://github.com/Ordo-Engine/Ordo/commit/c64d0f55fec57687d94843ab372c82f679f58461)) - Build WASM in CI for GitHub Pages deployment -([d971573](https://github.com/Pama-Lee/Ordo/commit/d971573e3337bbbe80865c58bc4b2824576d8fba)) +([d971573](https://github.com/Ordo-Engine/Ordo/commit/d971573e3337bbbe80865c58bc4b2824576d8fba)) - Add GitHub Pages deployment for playground -([65c2043](https://github.com/Pama-Lee/Ordo/commit/65c2043f1bb2072cb60980cea62aab7cd1de8f34)) +([65c2043](https://github.com/Ordo-Engine/Ordo/commit/65c2043f1bb2072cb60980cea62aab7cd1de8f34)) - Add GitHub Pages deployment for playground -([5aa77d4](https://github.com/Pama-Lee/Ordo/commit/5aa77d480b37a74cd19f3be31fdc69a5e9151905)) +([5aa77d4](https://github.com/Ordo-Engine/Ordo/commit/5aa77d480b37a74cd19f3be31fdc69a5e9151905)) ### Miscellaneous - Add driver.js dependency to pnpm-lock.yaml -([7054f98](https://github.com/Pama-Lee/Ordo/commit/7054f98a709553745d36eef18d777ccf0af017ae)) +([7054f98](https://github.com/Ordo-Engine/Ordo/commit/7054f98a709553745d36eef18d777ccf0af017ae)) - Update dependency installation command in deploy-playground workflow -([cc70797](https://github.com/Pama-Lee/Ordo/commit/cc707978f465cc0114ffd766d1a8429f0e59da1d)) +([cc70797](https://github.com/Ordo-Engine/Ordo/commit/cc707978f465cc0114ffd766d1a8429f0e59da1d)) - Update Dockerfile to include protoc and curl installation -([61f77c1](https://github.com/Pama-Lee/Ordo/commit/61f77c137b88a9af1a5ad77d1410c654776f62f2)) +([61f77c1](https://github.com/Ordo-Engine/Ordo/commit/61f77c137b88a9af1a5ad77d1410c654776f62f2)) - Remove obsolete and engine integration summaries -([848f899](https://github.com/Pama-Lee/Ordo/commit/848f899cd5326662c7c3ad00daa0e47b910dae53)) +([848f899](https://github.com/Ordo-Engine/Ordo/commit/848f899cd5326662c7c3ad00daa0e47b910dae53)) ### Style - Apply cargo fmt and add pre-commit hook for auto-formatting -([9b0b306](https://github.com/Pama-Lee/Ordo/commit/9b0b30658d61967fb4cbe09c469b2f94e38fc797)) +([9b0b306](https://github.com/Ordo-Engine/Ordo/commit/9b0b30658d61967fb4cbe09c469b2f94e38fc797)) ## [0.1.0] - 2026-01-07 ### Bug Fixes - Use std::slice::from_ref instead of clone in slice -([b3ef2a9](https://github.com/Pama-Lee/Ordo/commit/b3ef2a959c7dfd8eead141e62b11ddbcc9298f9b)) +([b3ef2a9](https://github.com/Ordo-Engine/Ordo/commit/b3ef2a959c7dfd8eead141e62b11ddbcc9298f9b)) - Resolve all clippy warnings and formatting issues -([cfa2cd5](https://github.com/Pama-Lee/Ordo/commit/cfa2cd595281d06ab8f266ddc5d157a1b9e5192c)) +([cfa2cd5](https://github.com/Ordo-Engine/Ordo/commit/cfa2cd595281d06ab8f266ddc5d157a1b9e5192c)) - Correct rust-toolchain action name in CI workflows -([09912cd](https://github.com/Pama-Lee/Ordo/commit/09912cd38c6e47242da0c4b6057f611377fcc7a3)) +([09912cd](https://github.com/Ordo-Engine/Ordo/commit/09912cd38c6e47242da0c4b6057f611377fcc7a3)) - Correct rust-toolchain action name in CI workflows -([d4d269e](https://github.com/Pama-Lee/Ordo/commit/d4d269e9ce42add976bae0a096918e7f7dc064e6)) +([d4d269e](https://github.com/Ordo-Engine/Ordo/commit/d4d269e9ce42add976bae0a096918e7f7dc064e6)) - Correct git clone URL in README -([97522b6](https://github.com/Pama-Lee/Ordo/commit/97522b6c12b2183623b843c18c190dae022a2c64)) +([97522b6](https://github.com/Ordo-Engine/Ordo/commit/97522b6c12b2183623b843c18c190dae022a2c64)) ### Documentation - Add branch strategy to README -([fe35eb5](https://github.com/Pama-Lee/Ordo/commit/fe35eb5bb120321d9af3aaa53207acd98b58a5fb)) +([fe35eb5](https://github.com/Ordo-Engine/Ordo/commit/fe35eb5bb120321d9af3aaa53207acd98b58a5fb)) ### Features - Add GitHub Actions CI/CD and Docker support -([7c60909](https://github.com/Pama-Lee/Ordo/commit/7c60909866585140d0b20c7770515587dc991bf1)) +([7c60909](https://github.com/Ordo-Engine/Ordo/commit/7c60909866585140d0b20c7770515587dc991bf1)) ### Style - Apply rustfmt formatting to all source files -([e0232ff](https://github.com/Pama-Lee/Ordo/commit/e0232ff212068c9c4cbb4ffd8e49878bb523e239)) - -[Unreleased]: https://github.com/Pama-Lee/Ordo/compare/v0.3.0...HEAD -[0.3.0]: https://github.com/Pama-Lee/Ordo/compare/v0.2.0...v0.3.0 -[0.2.0]: https://github.com/Pama-Lee/Ordo/compare/v0.1.8...v0.2.0 -[0.1.8]: https://github.com/Pama-Lee/Ordo/compare/v0.1.7...v0.1.8 -[0.1.7]: https://github.com/Pama-Lee/Ordo/compare/v0.1.6...v0.1.7 -[0.1.6]: https://github.com/Pama-Lee/Ordo/compare/v0.1.0...v0.1.6 +([e0232ff](https://github.com/Ordo-Engine/Ordo/commit/e0232ff212068c9c4cbb4ffd8e49878bb523e239)) + +[Unreleased]: https://github.com/Ordo-Engine/Ordo/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/Ordo-Engine/Ordo/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/Ordo-Engine/Ordo/compare/v0.1.8...v0.2.0 +[0.1.8]: https://github.com/Ordo-Engine/Ordo/compare/v0.1.7...v0.1.8 +[0.1.7]: https://github.com/Ordo-Engine/Ordo/compare/v0.1.6...v0.1.7 +[0.1.6]: https://github.com/Ordo-Engine/Ordo/compare/v0.1.0...v0.1.6 diff --git a/Cargo.lock b/Cargo.lock index 0a55630f..c54e03ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,6 +380,14 @@ dependencies = [ "serde", ] +[[package]] +name = "capability-demo" +version = "0.1.0" +dependencies = [ + "ordo-core", + "serde_json", +] + [[package]] name = "cast" version = "0.3.0" @@ -2380,6 +2388,7 @@ dependencies = [ "clap", "dashmap", "futures", + "hashbrown 0.14.5", "hex", "hmac", "hostname", diff --git a/Cargo.toml b/Cargo.toml index 54b521c9..9adc4530 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/ordo-server", "crates/ordo-platform", "crates/ordo-wasm", + "examples/capability-demo", ] [workspace.package] diff --git a/README.md b/README.md index 6b0f80c3..4bbb8eb4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Playground + Playground Rust License npm @@ -53,7 +53,7 @@ docker compose up # platform + engine + studio # open http://localhost:5173 ``` -Or use the hosted **[Live Playground](https://pama-lee.github.io/Ordo/)** — no install needed. +Or use the hosted **[Live Playground](https://ordo-engine.github.io/Ordo/)** — no install needed. ### Engine only @@ -140,7 +140,7 @@ ordo/ | **Govern** | v0.8 | Change requests, impact analysis, approval flows | | **Ordo Cloud** | v1.0 | Managed platform with hosted engine | -Full roadmap → [docs/roadmap](https://pama-lee.github.io/Ordo/docs/en/roadmap) +Full roadmap → [docs/roadmap](https://ordo-engine.github.io/Ordo/docs/en/roadmap) --- @@ -148,4 +148,4 @@ Full roadmap → [docs/roadmap](https://pama-lee.github.io/Ordo/docs/en/roadmap) MIT — see [LICENSE](LICENSE). -

Built with Rust · Discord · Docs

+

Built with Rust · Discord · Docs

diff --git a/ROADMAP.md b/ROADMAP.md index 4628f661..9f728df4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,8 @@ For the full roadmap with details on each milestone, visit our documentation site: -- **English**: [Roadmap](https://pama-lee.github.io/Ordo/docs/en/roadmap) -- **中文**: [产品路线图](https://pama-lee.github.io/Ordo/docs/zh/roadmap) +- **English**: [Roadmap](https://ordo-engine.github.io/Ordo/docs/en/roadmap) +- **中文**: [产品路线图](https://ordo-engine.github.io/Ordo/docs/zh/roadmap) ## Quick Overview @@ -20,4 +20,4 @@ Milestones 1–5 are fully open source (MIT). Ordo Cloud adds managed hosting an --- -Have feedback on priorities? [Open an issue](https://github.com/Pama-Lee/Ordo/issues) or join our [Discord](https://discord.gg/Y529FkArhh). +Have feedback on priorities? [Open an issue](https://github.com/Ordo-Engine/Ordo/issues) or join our [Discord](https://discord.gg/Y529FkArhh). diff --git a/cliff.toml b/cliff.toml index 525edea9..e7fbcdee 100644 --- a/cliff.toml +++ b/cliff.toml @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 """ body = """ {%- macro remote_url() -%} - https://github.com/Pama-Lee/Ordo + https://github.com/Ordo-Engine/Ordo {%- endmacro -%} {% if version -%} @@ -41,7 +41,7 @@ body = """ """ footer = """ {%- macro remote_url() -%} - https://github.com/Pama-Lee/Ordo + https://github.com/Ordo-Engine/Ordo {%- endmacro -%} {% for release in releases -%} @@ -64,7 +64,7 @@ split_commits = false commit_preprocessors = [ # Link PR numbers to GitHub - { pattern = '\(#(\d+)\)', replace = '([#${1}](https://github.com/Pama-Lee/Ordo/pull/${1}))' }, + { pattern = '\(#(\d+)\)', replace = '([#${1}](https://github.com/Ordo-Engine/Ordo/pull/${1}))' }, ] # Strip commit body to keep changelog concise — only the first line matters diff --git a/crates/ordo-core/src/capability/mod.rs b/crates/ordo-core/src/capability/mod.rs new file mode 100644 index 00000000..8898a762 --- /dev/null +++ b/crates/ordo-core/src/capability/mod.rs @@ -0,0 +1,72 @@ +mod provider; +mod registry; + +pub use provider::{ + CapabilityCategory, CapabilityConfig, CapabilityDescriptor, CapabilityInvoker, + CapabilityProvider, CapabilityRequest, CapabilityResponse, CircuitBreakerConfig, RetryPolicy, +}; +pub use registry::CapabilityRegistry; + +#[cfg(test)] +mod tests { + use super::*; + use crate::prelude::{Action, ActionKind, Expr, RuleExecutor, RuleSet, Step, TerminalResult}; + use crate::{context::Value, error::Result}; + use std::sync::Arc; + + struct EchoProvider; + + impl CapabilityProvider for EchoProvider { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("demo.echo", CapabilityCategory::Compute) + .with_description("Echo payloads back to the caller") + } + + fn invoke(&self, request: &CapabilityRequest) -> Result { + Ok(CapabilityResponse::new(request.payload.clone())) + } + } + + #[test] + fn executor_external_call_routes_through_capability_registry() { + let registry = Arc::new(CapabilityRegistry::new()); + registry.register(Arc::new(EchoProvider)); + + let mut ruleset = RuleSet::new("capability_demo", "call_echo"); + ruleset.add_step(Step::action( + "call_echo", + "Call echo", + vec![Action { + kind: ActionKind::ExternalCall { + service: "demo.echo".to_string(), + method: "echo".to_string(), + params: vec![("amount".to_string(), Expr::field("amount"))], + timeout_ms: 250, + result_variable: Some("capability_result".to_string()), + }, + description: String::new(), + }], + "done", + )); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK").with_output( + "echoed_amount", + Expr::field("$capability_result.payload.amount"), + ), + )); + + let mut executor = RuleExecutor::new(); + executor.set_capability_invoker(registry); + + let input: Value = serde_json::from_str(r#"{"amount": 42}"#).unwrap(); + let result = executor.execute(&ruleset, input).unwrap(); + + let amount = result + .output + .get_path("echoed_amount") + .expect("echoed amount missing"); + assert_eq!(amount, &Value::int(42)); + } +} diff --git a/crates/ordo-core/src/capability/provider.rs b/crates/ordo-core/src/capability/provider.rs new file mode 100644 index 00000000..b8ac130a --- /dev/null +++ b/crates/ordo-core/src/capability/provider.rs @@ -0,0 +1,231 @@ +use crate::context::Value; +use crate::error::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// High-level execution tier for a capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityCategory { + Network, + Compute, + Action, +} + +/// Retry policy for a capability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetryPolicy { + pub max_attempts: u32, + pub backoff_ms: u64, +} + +impl RetryPolicy { + #[inline] + pub fn disabled() -> Self { + Self { + max_attempts: 1, + backoff_ms: 0, + } + } +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self::disabled() + } +} + +/// Consecutive-failure breaker for a capability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CircuitBreakerConfig { + pub failure_threshold: u32, + pub reset_timeout_ms: u64, +} + +impl CircuitBreakerConfig { + #[inline] + pub fn disabled() -> Self { + Self { + failure_threshold: 0, + reset_timeout_ms: 0, + } + } + + #[inline] + pub fn enabled(&self) -> bool { + self.failure_threshold > 0 + } +} + +impl Default for CircuitBreakerConfig { + fn default() -> Self { + Self::disabled() + } +} + +/// Runtime policy for a registered capability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityConfig { + pub category: CapabilityCategory, + pub timeout_ms: Option, + pub retry: RetryPolicy, + pub circuit_breaker: CircuitBreakerConfig, +} + +impl CapabilityConfig { + #[inline] + pub fn new(category: CapabilityCategory) -> Self { + Self { + category, + timeout_ms: None, + retry: RetryPolicy::default(), + circuit_breaker: CircuitBreakerConfig::default(), + } + } + + #[inline] + pub fn timeout(mut self, timeout_ms: u64) -> Self { + self.timeout_ms = Some(timeout_ms); + self + } + + #[inline] + pub fn retry(mut self, retry: RetryPolicy) -> Self { + self.retry = retry; + self + } + + #[inline] + pub fn circuit_breaker(mut self, circuit_breaker: CircuitBreakerConfig) -> Self { + self.circuit_breaker = circuit_breaker; + self + } +} + +/// Metadata for a registered capability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityDescriptor { + pub name: String, + pub description: String, + pub config: CapabilityConfig, +} + +impl CapabilityDescriptor { + #[inline] + pub fn new(name: impl Into, category: CapabilityCategory) -> Self { + Self { + name: name.into(), + description: String::new(), + config: CapabilityConfig::new(category), + } + } + + #[inline] + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + #[inline] + pub fn with_config(mut self, config: CapabilityConfig) -> Self { + self.config = config; + self + } +} + +/// Normalized request shape passed to capability providers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CapabilityRequest { + pub capability: String, + pub operation: String, + #[serde(default)] + pub payload: Value, + #[serde(default)] + pub metadata: HashMap, + #[serde(default)] + pub timeout_ms: Option, + #[serde(default)] + pub category: Option, +} + +impl CapabilityRequest { + #[inline] + pub fn new( + capability: impl Into, + operation: impl Into, + payload: Value, + ) -> Self { + Self { + capability: capability.into(), + operation: operation.into(), + payload, + metadata: HashMap::new(), + timeout_ms: None, + category: None, + } + } + + #[inline] + pub fn with_timeout(mut self, timeout_ms: u64) -> Self { + self.timeout_ms = Some(timeout_ms); + self + } + + #[inline] + pub fn with_category(mut self, category: CapabilityCategory) -> Self { + self.category = Some(category); + self + } + + #[inline] + pub fn with_metadata(mut self, key: impl Into, value: impl Into) -> Self { + self.metadata.insert(key.into(), value.into()); + self + } +} + +/// Normalized response shape returned by capability providers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CapabilityResponse { + #[serde(default)] + pub payload: Value, + #[serde(default)] + pub metadata: HashMap, +} + +impl CapabilityResponse { + #[inline] + pub fn new(payload: Value) -> Self { + Self { + payload, + metadata: HashMap::new(), + } + } + + #[inline] + pub fn empty() -> Self { + Self::new(Value::Null) + } + + #[inline] + pub fn with_metadata(mut self, key: impl Into, value: impl Into) -> Self { + self.metadata.insert(key.into(), value.into()); + self + } +} + +/// Provider implementation for a named capability. +pub trait CapabilityProvider: Send + Sync { + fn descriptor(&self) -> CapabilityDescriptor; + fn invoke(&self, request: &CapabilityRequest) -> Result; +} + +/// Trait used by the rule executor to call a capability registry. +pub trait CapabilityInvoker: Send + Sync { + fn invoke(&self, request: &CapabilityRequest) -> Result; + + fn describe(&self, capability: &str) -> Option { + let _ = capability; + None + } +} diff --git a/crates/ordo-core/src/capability/registry.rs b/crates/ordo-core/src/capability/registry.rs new file mode 100644 index 00000000..4755140f --- /dev/null +++ b/crates/ordo-core/src/capability/registry.rs @@ -0,0 +1,313 @@ +use super::provider::{ + CapabilityDescriptor, CapabilityInvoker, CapabilityProvider, CapabilityRequest, + CapabilityResponse, +}; +use crate::error::OrdoError; +use crate::error::Result; +use parking_lot::{Mutex, RwLock}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +#[derive(Debug, Default)] +struct CircuitState { + consecutive_failures: u32, + opened_at: Option, +} + +struct RegisteredCapability { + descriptor: CapabilityDescriptor, + provider: Arc, + state: Mutex, +} + +/// In-memory capability registry used by the executor and tests. +#[derive(Default)] +pub struct CapabilityRegistry { + providers: RwLock>>, +} + +impl CapabilityRegistry { + #[inline] + pub fn new() -> Self { + Self::default() + } + + pub fn register( + &self, + provider: Arc, + ) -> Option> { + let descriptor = provider.descriptor(); + let name = descriptor.name.clone(); + let registered = Arc::new(RegisteredCapability { + descriptor, + provider, + state: Mutex::new(CircuitState::default()), + }); + + self.providers + .write() + .insert(name, registered) + .map(|previous| previous.provider.clone()) + } + + #[inline] + pub fn contains(&self, capability: &str) -> bool { + self.providers.read().contains_key(capability) + } + + pub fn list(&self) -> Vec { + self.providers + .read() + .values() + .map(|entry| entry.descriptor.clone()) + .collect() + } + + fn lookup(&self, capability: &str) -> Option> { + self.providers.read().get(capability).cloned() + } + + fn ensure_circuit_closed(entry: &RegisteredCapability) -> Result<()> { + let config = &entry.descriptor.config.circuit_breaker; + if !config.enabled() { + return Ok(()); + } + + let mut state = entry.state.lock(); + if let Some(opened_at) = state.opened_at { + let elapsed = opened_at.elapsed(); + if elapsed >= Duration::from_millis(config.reset_timeout_ms) { + state.consecutive_failures = 0; + state.opened_at = None; + return Ok(()); + } + + let remaining = config + .reset_timeout_ms + .saturating_sub(elapsed.as_millis().min(u128::from(u64::MAX)) as u64); + return Err(OrdoError::CircuitOpen { + capability: entry.descriptor.name.clone(), + retry_after_ms: Some(remaining), + }); + } + + Ok(()) + } + + fn mark_success(entry: &RegisteredCapability) { + let mut state = entry.state.lock(); + state.consecutive_failures = 0; + state.opened_at = None; + } + + fn mark_failure(entry: &RegisteredCapability) { + let config = &entry.descriptor.config.circuit_breaker; + if !config.enabled() { + return; + } + + let mut state = entry.state.lock(); + state.consecutive_failures = state.consecutive_failures.saturating_add(1); + if state.consecutive_failures >= config.failure_threshold { + state.opened_at = Some(Instant::now()); + } + } + + fn is_retryable_error(error: &OrdoError) -> bool { + matches!( + error, + OrdoError::Timeout { .. } | OrdoError::CapabilityInvocation { .. } + ) + } +} + +impl CapabilityInvoker for CapabilityRegistry { + fn invoke(&self, request: &CapabilityRequest) -> Result { + let entry = + self.lookup(&request.capability) + .ok_or_else(|| OrdoError::CapabilityNotFound { + capability: request.capability.clone(), + })?; + + Self::ensure_circuit_closed(&entry)?; + + let attempts = entry.descriptor.config.retry.max_attempts.max(1); + let timeout_ms = request.timeout_ms.or(entry.descriptor.config.timeout_ms); + + for attempt in 0..attempts { + let start = Instant::now(); + let response = entry.provider.invoke(request); + let response = match (timeout_ms, response) { + (Some(limit), Ok(_)) if start.elapsed().as_millis() as u64 > limit => { + Err(OrdoError::Timeout { timeout_ms: limit }) + } + (_, other) => other, + }; + + match response { + Ok(response) => { + Self::mark_success(&entry); + return Ok(response); + } + Err(error) => { + Self::mark_failure(&entry); + let should_retry = attempt + 1 < attempts && Self::is_retryable_error(&error); + if should_retry { + #[cfg(not(target_arch = "wasm32"))] + if entry.descriptor.config.retry.backoff_ms > 0 { + std::thread::sleep(Duration::from_millis( + entry.descriptor.config.retry.backoff_ms, + )); + } + continue; + } + return Err(error); + } + } + } + + Err(OrdoError::internal_error_static( + "capability registry retry loop exited unexpectedly", + )) + } + + fn describe(&self, capability: &str) -> Option { + self.lookup(capability) + .map(|entry| entry.descriptor.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::capability::{ + CapabilityCategory, CapabilityConfig, CapabilityDescriptor, CircuitBreakerConfig, + RetryPolicy, + }; + use crate::context::Value; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct SequenceProvider { + descriptor: CapabilityDescriptor, + calls: AtomicUsize, + responses: Mutex>>, + } + + impl SequenceProvider { + fn new( + descriptor: CapabilityDescriptor, + responses: Vec>, + ) -> Self { + Self { + descriptor, + calls: AtomicUsize::new(0), + responses: Mutex::new(responses.into()), + } + } + } + + impl CapabilityProvider for SequenceProvider { + fn descriptor(&self) -> CapabilityDescriptor { + self.descriptor.clone() + } + + fn invoke(&self, _request: &CapabilityRequest) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); + self.responses + .lock() + .pop_front() + .unwrap_or_else(|| Ok(CapabilityResponse::new(Value::string("default")))) + } + } + + #[test] + fn registers_and_invokes_provider() { + let registry = CapabilityRegistry::new(); + registry.register(Arc::new(SequenceProvider::new( + CapabilityDescriptor::new("demo.echo", CapabilityCategory::Compute), + vec![Ok(CapabilityResponse::new(Value::string("ok")))], + ))); + + let response = registry + .invoke(&CapabilityRequest::new( + "demo.echo", + "run", + Value::object(std::collections::HashMap::new()), + )) + .unwrap(); + + assert_eq!(response.payload, Value::string("ok")); + assert!(registry.contains("demo.echo")); + assert_eq!(registry.list().len(), 1); + } + + #[test] + fn retries_transient_errors() { + let registry = CapabilityRegistry::new(); + let descriptor = CapabilityDescriptor::new("demo.retry", CapabilityCategory::Network) + .with_config( + CapabilityConfig::new(CapabilityCategory::Network).retry(RetryPolicy { + max_attempts: 2, + backoff_ms: 0, + }), + ); + let provider = Arc::new(SequenceProvider::new( + descriptor, + vec![ + Err(OrdoError::CapabilityInvocation { + capability: "demo.retry".to_string(), + message: "temporary".into(), + }), + Ok(CapabilityResponse::new(Value::string("recovered"))), + ], + )); + let provider_ref = provider.clone(); + registry.register(provider); + + let response = registry + .invoke(&CapabilityRequest::new("demo.retry", "fetch", Value::Null)) + .unwrap(); + + assert_eq!(response.payload, Value::string("recovered")); + assert_eq!(provider_ref.calls.load(Ordering::Relaxed), 2); + } + + #[test] + fn opens_circuit_after_repeated_failures() { + let registry = CapabilityRegistry::new(); + registry.register(Arc::new(SequenceProvider::new( + CapabilityDescriptor::new("demo.breaker", CapabilityCategory::Action).with_config( + CapabilityConfig::new(CapabilityCategory::Action).circuit_breaker( + CircuitBreakerConfig { + failure_threshold: 2, + reset_timeout_ms: 1000, + }, + ), + ), + vec![ + Err(OrdoError::CapabilityInvocation { + capability: "demo.breaker".to_string(), + message: "boom".into(), + }), + Err(OrdoError::CapabilityInvocation { + capability: "demo.breaker".to_string(), + message: "boom".into(), + }), + ], + ))); + + assert!(registry + .invoke(&CapabilityRequest::new("demo.breaker", "emit", Value::Null)) + .is_err()); + assert!(registry + .invoke(&CapabilityRequest::new("demo.breaker", "emit", Value::Null)) + .is_err()); + + let error = registry + .invoke(&CapabilityRequest::new("demo.breaker", "emit", Value::Null)) + .unwrap_err(); + assert!(matches!(error, OrdoError::CircuitOpen { .. })); + } +} diff --git a/crates/ordo-core/src/context/store.rs b/crates/ordo-core/src/context/store.rs index 0ff6ef04..1f8c5342 100644 --- a/crates/ordo-core/src/context/store.rs +++ b/crates/ordo-core/src/context/store.rs @@ -61,8 +61,12 @@ impl Context { /// - `_index`: get the current iteration index (if set) pub fn get(&self, path: &str) -> Option<&Value> { if let Some(var_name) = path.strip_prefix('$') { - // Variable reference - self.variables.get(var_name) + // Variable reference, with optional nested path access. + if let Some((name, nested)) = var_name.split_once('.') { + self.variables.get(name)?.get_path(nested) + } else { + self.variables.get(var_name) + } } else if let Some(item_path) = path.strip_prefix("item.") { // Current iteration item field self.current_item.as_ref()?.get_path(item_path) @@ -178,6 +182,28 @@ mod tests { assert_eq!(ctx.get("$score"), None); } + #[test] + fn test_context_nested_variable_paths() { + let mut ctx = Context::new(Value::Null); + ctx.set_variable( + "result", + Value::object({ + let mut m = std::collections::HashMap::new(); + m.insert( + "payload".to_string(), + Value::object({ + let mut nested = std::collections::HashMap::new(); + nested.insert("score".to_string(), Value::int(7)); + nested + }), + ); + m + }), + ); + + assert_eq!(ctx.get("$result.payload.score"), Some(&Value::int(7))); + } + #[test] fn test_context_item() { let mut ctx = Context::new(Value::Null); diff --git a/crates/ordo-core/src/error.rs b/crates/ordo-core/src/error.rs index 134aceac..8cd3de57 100644 --- a/crates/ordo-core/src/error.rs +++ b/crates/ordo-core/src/error.rs @@ -54,6 +54,24 @@ pub enum OrdoError { #[error("RuleSet not found: {name}")] RuleSetNotFound { name: String }, + /// Capability not found + #[error("Capability not found: {capability}")] + CapabilityNotFound { capability: String }, + + /// Capability circuit breaker is open + #[error("Capability circuit open: {capability}")] + CircuitOpen { + capability: String, + retry_after_ms: Option, + }, + + /// Capability invocation failed + #[error("Capability invocation failed for {capability}: {message}")] + CapabilityInvocation { + capability: String, + message: Cow<'static, str>, + }, + /// Step not found #[error("Step not found: {step_id}")] StepNotFound { step_id: String }, @@ -171,6 +189,17 @@ impl OrdoError { } } + /// Create a capability invocation error + pub fn capability_invocation( + capability: impl Into, + message: impl Into>, + ) -> Self { + Self::CapabilityInvocation { + capability: capability.into(), + message: message.into(), + } + } + /// Create an internal error from a static string (no allocation) #[inline] pub fn internal_error_static(message: &'static str) -> Self { diff --git a/crates/ordo-core/src/lib.rs b/crates/ordo-core/src/lib.rs index 2c5a243e..ef59ae15 100644 --- a/crates/ordo-core/src/lib.rs +++ b/crates/ordo-core/src/lib.rs @@ -50,6 +50,7 @@ #![allow(missing_docs)] #![warn(clippy::all)] +pub mod capability; pub mod context; pub mod error; pub mod expr; @@ -62,6 +63,11 @@ pub mod trace; /// Prelude module for convenient imports pub mod prelude { + pub use crate::capability::{ + CapabilityCategory, CapabilityConfig, CapabilityDescriptor, CapabilityInvoker, + CapabilityProvider, CapabilityRegistry, CapabilityRequest, CapabilityResponse, + CircuitBreakerConfig, RetryPolicy, + }; pub use crate::context::{Context, Value}; pub use crate::error::{OrdoError, Result}; pub use crate::expr::{ diff --git a/crates/ordo-core/src/rule/compiled_executor.rs b/crates/ordo-core/src/rule/compiled_executor.rs index d823cdfc..e5afba22 100644 --- a/crates/ordo-core/src/rule/compiled_executor.rs +++ b/crates/ordo-core/src/rule/compiled_executor.rs @@ -5,6 +5,7 @@ use super::compiled::{ }; use super::metrics::{MetricSink, NoOpMetricSink}; use super::{ExecutionResult, TerminalResult}; +use crate::capability::{CapabilityInvoker, CapabilityRequest}; use crate::context::{Context, IString, Value}; use crate::error::{OrdoError, Result}; use crate::expr::BytecodeVM; @@ -36,6 +37,7 @@ use wasm_time::Instant; pub struct CompiledRuleExecutor { vm: BytecodeVM, metric_sink: Arc, + capability_invoker: Option>, } impl Default for CompiledRuleExecutor { @@ -49,6 +51,7 @@ impl CompiledRuleExecutor { Self { vm: BytecodeVM::new(), metric_sink: Arc::new(NoOpMetricSink), + capability_invoker: None, } } @@ -56,9 +59,18 @@ impl CompiledRuleExecutor { Self { vm: BytecodeVM::new(), metric_sink, + capability_invoker: None, } } + pub fn set_capability_invoker(&mut self, capability_invoker: Arc) { + self.capability_invoker = Some(capability_invoker); + } + + pub fn capability_invoker(&self) -> Option> { + self.capability_invoker.clone() + } + pub fn execute(&self, ruleset: &CompiledRuleSet, input: Value) -> Result { let start_time = Instant::now(); let mut ctx = Context::new(input); @@ -234,12 +246,38 @@ impl CompiledRuleExecutor { )) }) .collect::>>()?; - self.metric_sink.record_gauge(name, metric_value, &tags); + self.record_metric(name, metric_value, &tags)?; } } Ok(()) } + 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()); + for (key, value) in tags { + tag_values.insert(key.clone(), Value::string(value)); + } + + let mut payload = std::collections::HashMap::with_capacity(3); + payload.insert("name".to_string(), Value::string(name)); + payload.insert("value".to_string(), Value::float(value)); + payload.insert("tags".to_string(), Value::object(tag_values)); + + let request = + CapabilityRequest::new("metrics.prometheus", "gauge", Value::object(payload)); + + match capability_invoker.invoke(&request) { + Ok(_) => return Ok(()), + Err(OrdoError::CapabilityNotFound { .. }) => {} + Err(error) => return Err(error), + } + } + + self.metric_sink.record_gauge(name, value, tags); + Ok(()) + } + fn build_output( &self, ruleset: &CompiledRuleSet, @@ -273,3 +311,82 @@ impl CompiledRuleExecutor { Ok(Value::object_optimized(output)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::capability::{ + CapabilityCategory, CapabilityDescriptor, CapabilityProvider, CapabilityRegistry, + CapabilityResponse, + }; + use crate::expr::Expr; + use crate::rule::metrics::MetricSink; + use crate::rule::{Action, ActionKind, RuleSet, RuleSetCompiler, Step, TerminalResult}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct TestMetricSink { + gauge_calls: AtomicUsize, + } + + impl MetricSink for TestMetricSink { + fn record_gauge(&self, _name: &str, _value: f64, _tags: &[(String, String)]) { + self.gauge_calls.fetch_add(1, Ordering::SeqCst); + } + + fn record_counter(&self, _name: &str, _value: f64, _tags: &[(String, String)]) {} + } + + struct TestMetricCapability { + calls: AtomicUsize, + } + + impl CapabilityProvider for TestMetricCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("metrics.prometheus", CapabilityCategory::Action) + } + + fn invoke(&self, _request: &CapabilityRequest) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(CapabilityResponse::empty()) + } + } + + #[test] + fn compiled_executor_prefers_capability_metrics_when_available() { + let mut ruleset = RuleSet::new("compiled_metric_test", "record_metric"); + ruleset.add_step(Step::action( + "record_metric", + "Record Metric", + vec![Action { + kind: ActionKind::Metric { + name: "compiled_metric".to_string(), + value: Expr::literal(9.0f64), + tags: vec![("env".to_string(), "test".to_string())], + }, + description: String::new(), + }], + "done", + )); + ruleset.add_step(Step::terminal("done", "Done", TerminalResult::new("OK"))); + let compiled = RuleSetCompiler::compile(&ruleset).unwrap(); + + let sink = Arc::new(TestMetricSink { + gauge_calls: AtomicUsize::new(0), + }); + let mut executor = CompiledRuleExecutor::with_metric_sink(sink.clone()); + let registry = Arc::new(CapabilityRegistry::new()); + let capability = Arc::new(TestMetricCapability { + calls: AtomicUsize::new(0), + }); + let capability_ref = capability.clone(); + registry.register(capability); + executor.set_capability_invoker(registry); + + let input = serde_json::from_str(r#"{}"#).unwrap(); + let result = executor.execute(&compiled, input).unwrap(); + + assert_eq!(result.code, "OK"); + assert_eq!(capability_ref.calls.load(Ordering::SeqCst), 1); + assert_eq!(sink.gauge_calls.load(Ordering::SeqCst), 0); + } +} diff --git a/crates/ordo-core/src/rule/executor.rs b/crates/ordo-core/src/rule/executor.rs index e7c3dde5..4815b9c4 100644 --- a/crates/ordo-core/src/rule/executor.rs +++ b/crates/ordo-core/src/rule/executor.rs @@ -5,6 +5,7 @@ use super::metrics::{MetricSink, NoOpMetricSink}; use super::model::{FieldMissingBehavior, RuleSet}; use super::step::{ActionKind, Condition, LogLevel, Step, StepKind, TerminalResult}; +use crate::capability::{CapabilityInvoker, CapabilityRequest}; use crate::context::{Context, Value}; use crate::error::{OrdoError, Result}; use crate::expr::{Evaluator, ExprParser}; @@ -90,6 +91,8 @@ pub struct RuleExecutor { metric_sink: Arc, /// Optional resolver for CallRuleSet actions resolver: Option>, + /// Optional capability invoker for ExternalCall actions + capability_invoker: Option>, /// Maximum nesting depth for CallRuleSet (prevents unbounded recursion) max_call_depth: usize, } @@ -101,6 +104,9 @@ impl Default for RuleExecutor { } impl RuleExecutor { + const METRIC_CAPABILITY: &'static str = "metrics.prometheus"; + const METRIC_OPERATION_GAUGE: &'static str = "gauge"; + /// Create a new executor pub fn new() -> Self { Self { @@ -108,6 +114,7 @@ impl RuleExecutor { trace_config: TraceConfig::default(), metric_sink: Arc::new(NoOpMetricSink), resolver: None, + capability_invoker: None, max_call_depth: 10, } } @@ -119,6 +126,7 @@ impl RuleExecutor { trace_config, metric_sink: Arc::new(NoOpMetricSink), resolver: None, + capability_invoker: None, max_call_depth: 10, } } @@ -130,6 +138,7 @@ impl RuleExecutor { trace_config: TraceConfig::default(), metric_sink, resolver: None, + capability_invoker: None, max_call_depth: 10, } } @@ -144,6 +153,7 @@ impl RuleExecutor { trace_config, metric_sink, resolver: None, + capability_invoker: None, max_call_depth: 10, } } @@ -153,6 +163,16 @@ impl RuleExecutor { self.resolver = Some(resolver); } + /// Set an invoker for ExternalCall actions + pub fn set_capability_invoker(&mut self, capability_invoker: Arc) { + self.capability_invoker = Some(capability_invoker); + } + + /// Get the configured capability invoker + pub fn capability_invoker(&self) -> Option> { + self.capability_invoker.clone() + } + /// Get the metric sink pub fn metric_sink(&self) -> &Arc { &self.metric_sink @@ -544,8 +564,7 @@ impl RuleExecutor { return Ok(()); } }; - // Record metric via sink - self.metric_sink.record_gauge(name, metric_value, tags); + self.record_metric(name, metric_value, tags)?; tracing::debug!(metric = %name, value = %metric_value, tags = ?tags, "Metric recorded"); } @@ -599,11 +618,78 @@ impl RuleExecutor { ctx.set_variable(result_variable, result_obj); } - ActionKind::ExternalCall { .. } => { - // TODO: Implement external calls - tracing::warn!("External calls not yet implemented"); + ActionKind::ExternalCall { + service, + method, + params, + result_variable, + timeout_ms, + } => { + let capability_invoker = self.capability_invoker.as_ref().ok_or_else(|| { + OrdoError::eval_error("ExternalCall requires a capability invoker") + })?; + + let mut payload = std::collections::HashMap::with_capacity(params.len()); + for (name, expr) in params { + payload.insert(name.clone(), self.evaluator.eval(expr, ctx)?); + } + + let mut request = + CapabilityRequest::new(service.clone(), method.clone(), 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 = Value::object({ + let mut m = std::collections::HashMap::new(); + m.insert("capability".to_string(), Value::string(service)); + m.insert("operation".to_string(), Value::string(method)); + m.insert("payload".to_string(), response.payload); + let metadata = Value::object( + response + .metadata + .into_iter() + .map(|(key, value)| (key, Value::string(value))) + .collect(), + ); + m.insert("metadata".to_string(), metadata); + m + }); + ctx.set_variable(result_variable, response_obj); + } + } + } + Ok(()) + } + + 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()); + for (key, value) in tags { + tag_values.insert(key.clone(), Value::string(value)); + } + + let mut payload = std::collections::HashMap::with_capacity(3); + payload.insert("name".to_string(), Value::string(name)); + payload.insert("value".to_string(), Value::float(value)); + payload.insert("tags".to_string(), Value::object(tag_values)); + + let request = CapabilityRequest::new( + Self::METRIC_CAPABILITY, + Self::METRIC_OPERATION_GAUGE, + Value::object(payload), + ); + + match capability_invoker.invoke(&request) { + Ok(_) => return Ok(()), + Err(OrdoError::CapabilityNotFound { .. }) => {} + Err(error) => return Err(error), } } + + self.metric_sink.record_gauge(name, value, tags); Ok(()) } @@ -867,6 +953,79 @@ mod tests { assert_eq!(sink.gauge_calls.load(Ordering::SeqCst), 1); } + #[test] + fn test_execute_metric_via_capability_invoker() { + use crate::capability::{ + CapabilityCategory, CapabilityDescriptor, CapabilityProvider, CapabilityRegistry, + CapabilityRequest, CapabilityResponse, + }; + use crate::rule::metrics::MetricSink; + use crate::rule::step::{Action, ActionKind}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct TestMetricSink { + gauge_calls: AtomicUsize, + } + + impl MetricSink for TestMetricSink { + fn record_gauge(&self, _name: &str, _value: f64, _tags: &[(String, String)]) { + self.gauge_calls.fetch_add(1, Ordering::SeqCst); + } + + fn record_counter(&self, _name: &str, _value: f64, _tags: &[(String, String)]) {} + } + + struct TestMetricCapability { + calls: AtomicUsize, + } + + impl CapabilityProvider for TestMetricCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("metrics.prometheus", CapabilityCategory::Action) + } + + fn invoke(&self, _request: &CapabilityRequest) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(CapabilityResponse::empty()) + } + } + + let sink = Arc::new(TestMetricSink { + gauge_calls: AtomicUsize::new(0), + }); + let mut executor = RuleExecutor::with_metric_sink(sink.clone()); + let registry = Arc::new(CapabilityRegistry::new()); + let capability = Arc::new(TestMetricCapability { + calls: AtomicUsize::new(0), + }); + let capability_ref = capability.clone(); + registry.register(capability); + executor.set_capability_invoker(registry); + + let mut ruleset = RuleSet::new("metric_capability_test", "record_metric"); + ruleset.add_step(Step::action( + "record_metric", + "Record Metric", + vec![Action { + kind: ActionKind::Metric { + name: "cap_metric".to_string(), + value: Expr::literal(7.0f64), + tags: vec![("env".to_string(), "test".to_string())], + }, + description: String::new(), + }], + "done", + )); + ruleset.add_step(Step::terminal("done", "Done", TerminalResult::new("OK"))); + + let input = serde_json::from_str(r#"{}"#).unwrap(); + let result = executor.execute(&ruleset, input).unwrap(); + + assert_eq!(result.code, "OK"); + assert_eq!(capability_ref.calls.load(Ordering::SeqCst), 1); + assert_eq!(sink.gauge_calls.load(Ordering::SeqCst), 0); + } + #[test] fn test_execute_batch_sequential() { let ruleset = create_test_ruleset(); diff --git a/crates/ordo-core/src/rule/step.rs b/crates/ordo-core/src/rule/step.rs index 6b88b2b9..023a2502 100644 --- a/crates/ordo-core/src/rule/step.rs +++ b/crates/ordo-core/src/rule/step.rs @@ -332,11 +332,13 @@ pub enum ActionKind { }, /// External call (future) - #[serde(skip)] ExternalCall { service: String, method: String, params: Vec<(String, Expr)>, + #[serde(default)] + result_variable: Option, + #[serde(default)] timeout_ms: u64, }, } diff --git a/crates/ordo-proto/proto/ordo.proto b/crates/ordo-proto/proto/ordo.proto index 1203ccbd..14877814 100644 --- a/crates/ordo-proto/proto/ordo.proto +++ b/crates/ordo-proto/proto/ordo.proto @@ -4,7 +4,7 @@ package ordo.v1; option java_package = "com.ordo.proto.v1"; option java_multiple_files = true; -option go_package = "github.com/pama-lee/ordo/proto/v1"; +option go_package = "github.com/Ordo-Engine/Ordo/proto/v1"; // ============================================================================= // Ordo Rule Engine Service @@ -303,4 +303,3 @@ message HealthResponse { // Server uptime in seconds uint64 uptime_seconds = 4; } - diff --git a/crates/ordo-server/Cargo.toml b/crates/ordo-server/Cargo.toml index 5fd06dcf..ba75f99b 100644 --- a/crates/ordo-server/Cargo.toml +++ b/crates/ordo-server/Cargo.toml @@ -14,6 +14,7 @@ once_cell = "1.19" ordo-core = { version = "0.4.2", path = "../ordo-core" } ordo-proto = { version = "0.4.2", path = "../ordo-proto" } parking_lot.workspace = true +hashbrown.workspace = true prost.workspace = true rand = "0.8" rayon.workspace = true diff --git a/crates/ordo-server/src/api.rs b/crates/ordo-server/src/api.rs index 8486b306..ac6f4d7c 100644 --- a/crates/ordo-server/src/api.rs +++ b/crates/ordo-server/src/api.rs @@ -14,6 +14,7 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Instant; +use crate::capability_registry::emit_rule_execution_audit; use crate::error::ApiError; use crate::json::SimdJson; use crate::metrics; @@ -486,9 +487,14 @@ pub async fn execute_ruleset( // Log audit event (with sampling) let source_ip = connect_info.map(|ci| ci.0.ip().to_string()); let rule_id = format!("{}/{}", tenant.id, name); - state - .audit_logger - .log_execution(&rule_id, result.duration_us, &result.code, source_ip); + emit_rule_execution_audit( + state.executor.capability_invoker(), + &state.audit_logger, + &rule_id, + result.duration_us, + &result.code, + source_ip, + ); result } @@ -504,7 +510,9 @@ pub async fn execute_ruleset( // Log audit event for errors (with sampling) let source_ip = connect_info.map(|ci| ci.0.ip().to_string()); let rule_id = format!("{}/{}", tenant.id, name); - state.audit_logger.log_execution( + emit_rule_execution_audit( + state.executor.capability_invoker(), + &state.audit_logger, &rule_id, start.elapsed().as_micros() as u64, "error", @@ -1437,6 +1445,9 @@ pub async fn execute_pipeline( let mut pipeline_executor = RuleExecutor::with_trace_and_metrics(TraceConfig::minimal(), state.metric_sink.clone()); pipeline_executor.set_resolver(Arc::new(snapshot)); + if let Some(capability_invoker) = state.executor.capability_invoker() { + pipeline_executor.set_capability_invoker(capability_invoker); + } let mut current_input = request.input; let mut stages = Vec::with_capacity(resolved.len()); diff --git a/crates/ordo-server/src/api_integration_tests.rs b/crates/ordo-server/src/api_integration_tests.rs index d9b3ed36..00d34c6c 100644 --- a/crates/ordo-server/src/api_integration_tests.rs +++ b/crates/ordo-server/src/api_integration_tests.rs @@ -27,6 +27,7 @@ use tower_http::trace::TraceLayer; use crate::{ api, audit::AuditLogger, + capability_registry::build_server_executor, debug::DebugSessionManager, metrics::PrometheusMetricSink, middleware, @@ -36,14 +37,13 @@ use crate::{ tenant::{TenantDefaults, TenantManager}, AppState, ServerConfig, }; -use ordo_core::prelude::RuleExecutor; /// Build a full test app with all API routes and middleware (matching main.rs router). async fn build_full_test_app() -> Router { let store = Arc::new(RwLock::new(RuleStore::new())); - let executor = Arc::new(RuleExecutor::new()); let metric_sink = Arc::new(PrometheusMetricSink::new()); let audit_logger = Arc::new(AuditLogger::new(None, 10)); + let executor = build_server_executor(metric_sink.clone(), Some(audit_logger.clone())); let debug_sessions = Arc::new(DebugSessionManager::new()); let defaults = TenantDefaults { default_qps_limit: None, diff --git a/crates/ordo-server/src/api_tests.rs b/crates/ordo-server/src/api_tests.rs index 0b1bf4ac..7e9d6fc2 100644 --- a/crates/ordo-server/src/api_tests.rs +++ b/crates/ordo-server/src/api_tests.rs @@ -16,6 +16,7 @@ use tower_http::trace::TraceLayer; use crate::{ api, audit::AuditLogger, + capability_registry::build_server_executor, debug::DebugSessionManager, metrics::PrometheusMetricSink, middleware, @@ -25,13 +26,12 @@ use crate::{ tenant::{TenantDefaults, TenantManager}, AppState, ServerConfig, }; -use ordo_core::prelude::RuleExecutor; async fn build_test_app() -> Router { let store = Arc::new(RwLock::new(RuleStore::new())); - let executor = Arc::new(RuleExecutor::new()); let metric_sink = Arc::new(PrometheusMetricSink::new()); let audit_logger = Arc::new(AuditLogger::new(None, 0)); + let executor = build_server_executor(metric_sink.clone(), Some(audit_logger.clone())); let debug_sessions = Arc::new(DebugSessionManager::new()); let defaults = TenantDefaults { default_qps_limit: None, @@ -372,9 +372,9 @@ async fn test_admin_reload_with_persistence() { let store = Arc::new(RwLock::new(RuleStore::new_with_persistence( dir.path().to_path_buf(), ))); - let executor = Arc::new(RuleExecutor::new()); let metric_sink = Arc::new(PrometheusMetricSink::new()); let audit_logger = Arc::new(AuditLogger::new(None, 0)); + let executor = build_server_executor(metric_sink.clone(), Some(audit_logger.clone())); let debug_sessions = Arc::new(DebugSessionManager::new()); let defaults = TenantDefaults { default_qps_limit: None, diff --git a/crates/ordo-server/src/capability_registry.rs b/crates/ordo-server/src/capability_registry.rs new file mode 100644 index 00000000..16fefd6b --- /dev/null +++ b/crates/ordo-server/src/capability_registry.rs @@ -0,0 +1,576 @@ +use crate::audit::AuditLogger; +use crate::metrics::PrometheusMetricSink; +use ordo_core::context::Value; +use ordo_core::prelude::{ + CapabilityCategory, CapabilityConfig, CapabilityDescriptor, CapabilityInvoker, + CapabilityProvider, CapabilityRegistry, CapabilityRequest, CapabilityResponse, MetricSink, + OrdoError, Result, RuleExecutor, TraceConfig, +}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +/// Server-side runtime registry wrapper that adds tracing around capability calls. +#[derive(Default)] +pub struct ServerCapabilityRegistry { + inner: CapabilityRegistry, +} + +impl ServerCapabilityRegistry { + #[inline] + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub fn register( + &self, + provider: Arc, + ) -> Option> { + self.inner.register(provider) + } + + pub fn register_metric_sink(&self, sink: Arc) { + self.register(Arc::new(PrometheusMetricCapability { sink })); + } + + pub fn register_audit_logger(&self, audit_logger: Arc) { + self.register(Arc::new(AuditCapability { audit_logger })); + } + + pub fn register_http_client(&self) { + self.register(Arc::new(HttpCapability::new())); + } +} + +impl CapabilityInvoker for ServerCapabilityRegistry { + fn invoke(&self, request: &CapabilityRequest) -> Result { + let span = tracing::info_span!( + "capability.invoke", + capability = %request.capability, + operation = %request.operation + ); + let _guard = span.enter(); + self.inner.invoke(request) + } + + fn describe(&self, capability: &str) -> Option { + self.inner.describe(capability) + } +} + +pub fn build_server_capability_invoker( + metric_sink: Arc, + audit_logger: Option>, +) -> Arc { + let registry = Arc::new(ServerCapabilityRegistry::new()); + registry.register_http_client(); + registry.register_metric_sink(metric_sink); + if let Some(audit_logger) = audit_logger { + registry.register_audit_logger(audit_logger); + } + registry +} + +pub fn build_rule_executor( + metric_sink: Arc, + capability_invoker: Option>, +) -> RuleExecutor { + let mut executor = RuleExecutor::with_trace_and_metrics(TraceConfig::minimal(), metric_sink); + if let Some(capability_invoker) = capability_invoker { + executor.set_capability_invoker(capability_invoker); + } + executor +} + +#[cfg(test)] +pub fn build_server_executor( + metric_sink: Arc, + audit_logger: Option>, +) -> Arc { + let capability_invoker = build_server_capability_invoker(metric_sink.clone(), audit_logger); + let metric_sink_trait: Arc = metric_sink; + Arc::new(build_rule_executor( + metric_sink_trait, + Some(capability_invoker), + )) +} + +pub fn emit_rule_execution_audit( + capability_invoker: Option>, + audit_logger: &AuditLogger, + rule_name: &str, + duration_us: u64, + result: &str, + source_ip: Option, +) { + if let Some(capability_invoker) = capability_invoker { + let payload = Value::object({ + let mut m = std::collections::HashMap::new(); + m.insert("rule_name".to_string(), Value::string(rule_name)); + m.insert("duration_us".to_string(), Value::int(duration_us as i64)); + m.insert("result".to_string(), Value::string(result)); + if let Some(source_ip) = &source_ip { + m.insert("source_ip".to_string(), Value::string(source_ip)); + } + m + }); + let request = CapabilityRequest::new("audit.logger", "rule_executed", payload); + match capability_invoker.invoke(&request) { + Ok(_) => return, + Err(OrdoError::CapabilityNotFound { .. }) => {} + Err(error) => { + tracing::warn!(rule = %rule_name, error = %error, "Audit capability invocation failed, falling back to direct logger"); + } + } + } + + audit_logger.log_execution(rule_name, duration_us, result, source_ip); +} + +pub fn invoke_http_json( + capability_invoker: Option>, + method: &str, + url: &str, + headers: HashMap, + json_body: &serde_json::Value, + timeout_ms: Option, +) -> Result> { + let Some(capability_invoker) = capability_invoker else { + return Ok(None); + }; + + let mut payload = std::collections::HashMap::new(); + payload.insert("url".to_string(), Value::string(url)); + payload.insert( + "headers".to_string(), + Value::object( + headers + .into_iter() + .map(|(key, value)| (key, Value::string(value))) + .collect(), + ), + ); + let body = serde_json::from_value(json_body.clone()).map_err(|error| { + OrdoError::capability_invocation("network.http", format!("invalid json body: {}", error)) + })?; + payload.insert("json_body".to_string(), body); + + let mut request = CapabilityRequest::new( + "network.http", + method.to_ascii_uppercase(), + Value::object(payload), + ); + if let Some(timeout_ms) = timeout_ms { + request = request.with_timeout(timeout_ms); + } + + match capability_invoker.invoke(&request) { + Ok(response) => Ok(Some(response)), + Err(OrdoError::CapabilityNotFound { .. }) => Ok(None), + Err(error) => Err(error), + } +} + +pub fn http_response_status(response: &CapabilityResponse) -> Option { + match response.payload.get_path("status") { + Some(Value::Int(status)) => (*status).try_into().ok(), + _ => None, + } +} + +struct PrometheusMetricCapability { + sink: Arc, +} + +impl CapabilityProvider for PrometheusMetricCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("metrics.prometheus", CapabilityCategory::Action) + .with_description("Bridge capability calls into the Prometheus rule metric sink") + .with_config(CapabilityConfig::new(CapabilityCategory::Action)) + } + + fn invoke(&self, request: &CapabilityRequest) -> Result { + let payload = expect_object(&request.payload, "metrics.prometheus")?; + let name = required_string(payload, "name", "metrics.prometheus")?; + let value = required_number(payload, "value", "metrics.prometheus")?; + let tags = optional_tags(payload, "tags", "metrics.prometheus")?; + + match request.operation.as_str() { + "counter" => self.sink.record_counter(name, value, &tags), + "gauge" => self.sink.record_gauge(name, value, &tags), + other => { + return Err(OrdoError::capability_invocation( + "metrics.prometheus", + format!("unsupported operation '{}'", other), + )); + } + } + + Ok(CapabilityResponse::empty() + .with_metadata("metric", name.to_string()) + .with_metadata("operation", request.operation.clone())) + } +} + +struct AuditCapability { + audit_logger: Arc, +} + +impl CapabilityProvider for AuditCapability { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("audit.logger", CapabilityCategory::Action) + .with_description("Bridge capability calls into the structured audit logger") + .with_config(CapabilityConfig::new(CapabilityCategory::Action)) + } + + fn invoke(&self, request: &CapabilityRequest) -> Result { + let payload = expect_object(&request.payload, "audit.logger")?; + match request.operation.as_str() { + "rule_executed" => { + let rule_name = required_string(payload, "rule_name", "audit.logger")?; + let duration_us = required_i64(payload, "duration_us", "audit.logger")?; + let result = required_string(payload, "result", "audit.logger")?; + let source_ip = optional_string(payload, "source_ip"); + self.audit_logger.log_execution( + rule_name, + duration_us.max(0) as u64, + result, + source_ip, + ); + Ok(CapabilityResponse::empty() + .with_metadata("event", "rule_executed") + .with_metadata("rule_name", rule_name.to_string())) + } + other => Err(OrdoError::capability_invocation( + "audit.logger", + format!("unsupported operation '{}'", other), + )), + } + } +} + +struct HttpCapability { + client: reqwest::Client, +} + +impl HttpCapability { + fn new() -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { client } + } +} + +impl CapabilityProvider for HttpCapability { + fn descriptor(&self) -> CapabilityDescriptor { + 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)) + } + + fn invoke(&self, request: &CapabilityRequest) -> Result { + 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 (status, body_text) = execute_http_request( + self.client.clone(), + method, + url.to_string(), + headers, + json_body, + request.timeout_ms, + )?; + + let mut payload = std::collections::HashMap::new(); + payload.insert("status".to_string(), Value::int(status as i64)); + 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), + ) + })?; + payload.insert("json_body".to_string(), json_body); + } + + Ok(CapabilityResponse::new(Value::object(payload)) + .with_metadata("status", status.to_string())) + } +} + +fn execute_http_request( + client: reqwest::Client, + method: reqwest::Method, + url: String, + headers: Vec<(String, String)>, + json_body: Option, + timeout_ms: Option, +) -> Result<(u16, String)> { + async fn send( + client: reqwest::Client, + method: reqwest::Method, + url: String, + headers: Vec<(String, String)>, + json_body: Option, + timeout_ms: Option, + ) -> std::result::Result<(u16, String), reqwest::Error> { + let mut builder = client.request(method, url); + if let Some(timeout_ms) = 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().await?; + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Ok((status, body)) + } + + let current = tokio::runtime::Handle::try_current(); + match current { + Ok(handle) => match handle.runtime_flavor() { + tokio::runtime::RuntimeFlavor::MultiThread => tokio::task::block_in_place(|| { + handle.block_on(send(client, method, url, headers, json_body, timeout_ms)) + }) + .map_err(|error| OrdoError::capability_invocation("network.http", error.to_string())), + tokio::runtime::RuntimeFlavor::CurrentThread | _ => std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + OrdoError::capability_invocation( + "network.http", + format!("failed to build runtime: {}", error), + ) + })?; + runtime + .block_on(send(client, method, url, headers, json_body, timeout_ms)) + .map_err(|error| { + OrdoError::capability_invocation("network.http", error.to_string()) + }) + }) + .join() + .map_err(|_| { + OrdoError::capability_invocation("network.http", "http worker thread panicked") + })?, + }, + Err(_) => { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + OrdoError::capability_invocation( + "network.http", + format!("failed to build runtime: {}", error), + ) + })?; + runtime + .block_on(send(client, method, url, headers, json_body, timeout_ms)) + .map_err(|error| { + OrdoError::capability_invocation("network.http", error.to_string()) + }) + } + } +} + +fn expect_object<'a>( + value: &'a Value, + capability: &str, +) -> Result<&'a hashbrown::HashMap> { + value.as_object().ok_or_else(|| { + OrdoError::capability_invocation(capability, "expected object payload for capability") + }) +} + +fn required_string<'a>( + object: &'a hashbrown::HashMap, + field: &str, + capability: &str, +) -> Result<&'a str> { + object + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| OrdoError::capability_invocation(capability, format!("missing '{}'", field))) +} + +fn optional_string( + object: &hashbrown::HashMap, + field: &str, +) -> Option { + object + .get(field) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn required_number( + object: &hashbrown::HashMap, + field: &str, + capability: &str, +) -> Result { + match object.get(field) { + Some(Value::Int(value)) => Ok(*value as f64), + Some(Value::Float(value)) => Ok(*value), + Some(Value::Bool(value)) => Ok(if *value { 1.0 } else { 0.0 }), + _ => Err(OrdoError::capability_invocation( + capability, + format!("field '{}' must be numeric", field), + )), + } +} + +fn required_i64( + object: &hashbrown::HashMap, + field: &str, + capability: &str, +) -> Result { + match object.get(field) { + Some(Value::Int(value)) => Ok(*value), + _ => Err(OrdoError::capability_invocation( + capability, + format!("field '{}' must be an integer", field), + )), + } +} + +fn optional_tags( + object: &hashbrown::HashMap, + field: &str, + capability: &str, +) -> Result> { + let Some(Value::Object(tags)) = object.get(field) else { + return Ok(Vec::new()); + }; + + let mut result = Vec::with_capacity(tags.len()); + for (key, value) in tags { + let value = value.as_str().ok_or_else(|| { + OrdoError::capability_invocation(capability, format!("tag '{}' must be a string", key)) + })?; + result.push((key.to_string(), value.to_string())); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Bytes, http::Method, routing::any, Json, Router}; + use serde_json::json; + use tokio::{net::TcpListener, sync::mpsc, time::Duration}; + + #[test] + fn metric_capability_accepts_gauge_requests() { + let registry = ServerCapabilityRegistry::new(); + registry.register_metric_sink(Arc::new(PrometheusMetricSink::new())); + + let payload = Value::object({ + let mut m = std::collections::HashMap::new(); + m.insert("name".to_string(), Value::string("score")); + m.insert("value".to_string(), Value::int(3)); + m + }); + + let response = registry + .invoke(&CapabilityRequest::new( + "metrics.prometheus", + "gauge", + payload, + )) + .unwrap(); + + assert_eq!(response.metadata.get("metric"), Some(&"score".to_string())); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn http_capability_posts_json() { + let listener = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return, + Err(error) => panic!("failed to bind test listener: {}", error), + }; + let addr = listener.local_addr().unwrap(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let app = Router::new().route( + "/hook", + any(move |method: Method, body: Bytes| { + let tx = tx.clone(); + async move { + let json_body = serde_json::from_slice::(&body) + .unwrap_or(serde_json::Value::Null); + let _ = tx.send((method.clone(), json_body.clone())); + + Json(json!({ + "method": method.as_str(), + "received": json_body, + "ok": true + })) + } + }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let capability_invoker = + build_server_capability_invoker(Arc::new(PrometheusMetricSink::new()), None); + let response = invoke_http_json( + Some(capability_invoker), + "post", + &format!("http://{}/hook", addr), + HashMap::new(), + &json!({"hello": "world"}), + Some(1_000), + ) + .unwrap() + .unwrap(); + + assert_eq!(http_response_status(&response), Some(200)); + let (method, received_body) = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for test server request") + .expect("test server did not receive request"); + assert_eq!(method, Method::POST); + assert_eq!(received_body.get("hello"), Some(&json!("world"))); + assert_eq!( + response.payload.get_path("json_body.received.hello"), + Some(&Value::string("world")) + ); + assert_eq!( + response.payload.get_path("json_body.method"), + Some(&Value::string("POST")) + ); + + server.abort(); + } +} diff --git a/crates/ordo-server/src/grpc.rs b/crates/ordo-server/src/grpc.rs index 4a3c16e6..3dfc7d30 100644 --- a/crates/ordo-server/src/grpc.rs +++ b/crates/ordo-server/src/grpc.rs @@ -582,12 +582,17 @@ impl OrdoService for OrdoGrpcService { #[cfg(test)] mod tests { use super::*; + use crate::audit::AuditLogger; + use crate::capability_registry::build_server_executor; + use crate::metrics::PrometheusMetricSink; use crate::rate_limiter::RateLimiter; use crate::tenant::{TenantDefaults, TenantManager}; async fn create_test_service() -> OrdoGrpcService { let store = Arc::new(RwLock::new(RuleStore::new())); - let executor = Arc::new(RuleExecutor::new()); + let metric_sink = Arc::new(PrometheusMetricSink::new()); + let audit_logger = Arc::new(AuditLogger::new(None, 0)); + let executor = build_server_executor(metric_sink, Some(audit_logger)); let defaults = TenantDefaults { default_qps_limit: Some(1000), default_burst_limit: Some(100), diff --git a/crates/ordo-server/src/main.rs b/crates/ordo-server/src/main.rs index c1ecd3ac..78a9c440 100644 --- a/crates/ordo-server/src/main.rs +++ b/crates/ordo-server/src/main.rs @@ -49,6 +49,7 @@ use tracing::{info, warn}; mod api; mod audit; +mod capability_registry; mod config; pub mod debug; mod error; @@ -67,10 +68,11 @@ pub mod wal; pub mod webhook; use audit::AuditLogger; +use capability_registry::{build_rule_executor, build_server_capability_invoker, invoke_http_json}; use config::ServerConfig; use grpc::OrdoGrpcService; use metrics::PrometheusMetricSink; -use ordo_core::prelude::{RuleExecutor, TraceConfig}; +use ordo_core::prelude::RuleExecutor; use ordo_core::signature::ed25519::decode_public_key; use ordo_core::signature::RuleVerifier; use rate_limiter::RateLimiter; @@ -225,13 +227,29 @@ async fn main() -> anyhow::Result<()> { let metric_sink = Arc::new(PrometheusMetricSink::new()); info!("Initialized Prometheus metric sink for custom rule metrics"); - // Initialize shared executor (moved out of RuleStore for lock-free execution) - let executor = Arc::new(RuleExecutor::with_trace_and_metrics( - TraceConfig::minimal(), - metric_sink.clone(), + let signature_verifier = build_signature_verifier(&config)?; + + // Initialize audit logger + let audit_logger = Arc::new(AuditLogger::new( + config.audit_dir.clone(), + config.audit_sample_rate, )); - let signature_verifier = build_signature_verifier(&config)?; + // Log audit configuration + if config.audit_dir.is_some() { + info!( + "Audit logging enabled: dir={:?}, sample_rate={}%", + config.audit_dir, config.audit_sample_rate + ); + } else { + info!( + "Audit logging to stdout only, sample_rate={}%", + config.audit_sample_rate + ); + } + + let capability_invoker = + build_server_capability_invoker(metric_sink.clone(), Some(audit_logger.clone())); // Initialize shared store (with or without persistence) let store = if let Some(ref rules_dir) = config.rules_dir { @@ -244,10 +262,11 @@ async fn main() -> anyhow::Result<()> { "Initializing store with persistence at {:?} (max {} versions)", store_dir, config.max_versions ); - let mut store = RuleStore::new_with_persistence_and_metrics( + let mut store = RuleStore::new_with_persistence_and_metrics_and_capabilities( store_dir, config.max_versions, metric_sink.clone(), + Some(capability_invoker.clone()), ); if let Some(verifier) = signature_verifier.clone() { store.set_signature_verifier(verifier, config.signature_allow_unsigned_local); @@ -335,7 +354,10 @@ async fn main() -> anyhow::Result<()> { Arc::new(RwLock::new(store)) } else { info!("Initializing in-memory store (no persistence)"); - let mut store = RuleStore::new_with_metrics(metric_sink.clone()); + let mut store = RuleStore::new_with_metrics_and_capabilities( + metric_sink.clone(), + Some(capability_invoker.clone()), + ); if let Some(verifier) = signature_verifier.clone() { store.set_signature_verifier(verifier, config.signature_allow_unsigned_local); } @@ -357,25 +379,13 @@ async fn main() -> anyhow::Result<()> { ); } - // Initialize audit logger - let audit_logger = Arc::new(AuditLogger::new( - config.audit_dir.clone(), - config.audit_sample_rate, + // Initialize shared executor (moved out of RuleStore for lock-free execution) + let metric_sink_trait: Arc = metric_sink.clone(); + let executor = Arc::new(build_rule_executor( + metric_sink_trait, + Some(capability_invoker.clone()), )); - // Log audit configuration - if config.audit_dir.is_some() { - info!( - "Audit logging enabled: dir={:?}, sample_rate={}%", - config.audit_dir, config.audit_sample_rate - ); - } else { - info!( - "Audit logging to stdout only, sample_rate={}%", - config.audit_sample_rate - ); - } - // Initialize debug session manager let debug_sessions = Arc::new(debug::DebugSessionManager::new()); // Initialize tenant manager @@ -408,7 +418,10 @@ async fn main() -> anyhow::Result<()> { // Shutdown broadcast channel — signal handlers and servers share this. let (shutdown_tx, shutdown_rx) = watch::channel(false); - let webhook_manager = webhook::WebhookManager::new(shutdown_rx.clone()); + let webhook_manager = webhook::WebhookManager::new_with_capabilities( + shutdown_rx.clone(), + Some(capability_invoker.clone()), + ); // Log server started event { @@ -632,6 +645,7 @@ async fn main() -> anyhow::Result<()> { let http_version = version.clone(); let http_token = token.clone(); let http_reg_secret = reg_secret.clone(); + let http_capability_invoker = capability_invoker.clone(); let log_url = platform_url.clone(); let log_name = http_server_name.clone(); @@ -652,25 +666,49 @@ async fn main() -> anyhow::Result<()> { }); let hb_payload = serde_json::json!({ "server_id": http_server_id }); - let hb_req_builder = || { - let mut r = client.post(&hb_url).json(&hb_payload); - if let Some(ref secret) = http_reg_secret { - r = r.header("x-registration-secret", secret.as_str()); - } - r - }; - let mut interval = tokio::time::interval(Duration::from_secs(30)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { - let mut r = client.post(®_url).json(&payload); + let mut headers = std::collections::HashMap::new(); if let Some(ref secret) = http_reg_secret { - r = r.header("x-registration-secret", secret.as_str()); + headers.insert("x-registration-secret".to_string(), secret.to_string()); + } + match invoke_http_json( + Some(http_capability_invoker.clone()), + "post", + ®_url, + headers.clone(), + &payload, + Some(5_000), + ) { + Ok(Some(_)) => {} + Ok(None) | Err(_) => { + let mut r = client.post(®_url).json(&payload); + if let Some(ref secret) = http_reg_secret { + r = r.header("x-registration-secret", secret.as_str()); + } + let _ = r.send().await; + } } - let _ = r.send().await; interval.tick().await; - let _ = hb_req_builder().send().await; + match invoke_http_json( + Some(http_capability_invoker.clone()), + "post", + &hb_url, + headers.clone(), + &hb_payload, + Some(5_000), + ) { + Ok(Some(_)) => {} + Ok(None) | Err(_) => { + let mut r = client.post(&hb_url).json(&hb_payload); + if let Some(ref secret) = http_reg_secret { + r = r.header("x-registration-secret", secret.as_str()); + } + let _ = r.send().await; + } + } } }); diff --git a/crates/ordo-server/src/store.rs b/crates/ordo-server/src/store.rs index 4afe5ebb..f892ee25 100644 --- a/crates/ordo-server/src/store.rs +++ b/crates/ordo-server/src/store.rs @@ -9,7 +9,7 @@ use crate::sync::event::SyncEvent; use crate::sync::file_watcher::RecentWrites; use crate::wal::{WalManager, WalOpKind}; use once_cell::sync::Lazy; -use ordo_core::prelude::{MetricSink, RuleExecutor, RuleSet, TraceConfig}; +use ordo_core::prelude::{CapabilityInvoker, MetricSink, RuleExecutor, RuleSet, TraceConfig}; use ordo_core::signature::{strip_signature, RuleVerifier}; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -114,11 +114,27 @@ pub struct VersionListResponse { } impl RuleStore { + fn build_executor( + metric_sink: Option>, + capability_invoker: Option>, + ) -> RuleExecutor { + let mut executor = match metric_sink { + Some(metric_sink) => { + RuleExecutor::with_trace_and_metrics(TraceConfig::minimal(), metric_sink) + } + None => RuleExecutor::with_trace(TraceConfig::minimal()), + }; + if let Some(capability_invoker) = capability_invoker { + executor.set_capability_invoker(capability_invoker); + } + executor + } + /// Create a new in-memory store (no persistence) pub fn new() -> Self { Self { rulesets: HashMap::new(), - executor: RuleExecutor::with_trace(TraceConfig::minimal()), + executor: Self::build_executor(None, None), rules_dir: None, multi_tenancy_enabled: false, default_tenant: "default".to_string(), @@ -137,9 +153,16 @@ impl RuleStore { /// Create a new in-memory store with a custom metric sink pub fn new_with_metrics(metric_sink: Arc) -> Self { + Self::new_with_metrics_and_capabilities(metric_sink, None) + } + + pub fn new_with_metrics_and_capabilities( + metric_sink: Arc, + capability_invoker: Option>, + ) -> Self { Self { rulesets: HashMap::new(), - executor: RuleExecutor::with_trace_and_metrics(TraceConfig::minimal(), metric_sink), + executor: Self::build_executor(Some(metric_sink), capability_invoker), rules_dir: None, multi_tenancy_enabled: false, default_tenant: "default".to_string(), @@ -166,7 +189,7 @@ impl RuleStore { pub fn new_with_persistence_and_versions(rules_dir: PathBuf, max_versions: usize) -> Self { Self { rulesets: HashMap::new(), - executor: RuleExecutor::with_trace(TraceConfig::minimal()), + executor: Self::build_executor(None, None), rules_dir: Some(rules_dir), multi_tenancy_enabled: false, default_tenant: "default".to_string(), @@ -188,10 +211,24 @@ impl RuleStore { rules_dir: PathBuf, max_versions: usize, metric_sink: Arc, + ) -> Self { + Self::new_with_persistence_and_metrics_and_capabilities( + rules_dir, + max_versions, + metric_sink, + None, + ) + } + + pub fn new_with_persistence_and_metrics_and_capabilities( + rules_dir: PathBuf, + max_versions: usize, + metric_sink: Arc, + capability_invoker: Option>, ) -> Self { Self { rulesets: HashMap::new(), - executor: RuleExecutor::with_trace_and_metrics(TraceConfig::minimal(), metric_sink), + executor: Self::build_executor(Some(metric_sink), capability_invoker), rules_dir: Some(rules_dir), multi_tenancy_enabled: false, default_tenant: "default".to_string(), diff --git a/crates/ordo-server/src/uds.rs b/crates/ordo-server/src/uds.rs index 253d0ef2..925e5adf 100644 --- a/crates/ordo-server/src/uds.rs +++ b/crates/ordo-server/src/uds.rs @@ -84,6 +84,9 @@ pub fn cleanup_uds(uds_path: &Path) { #[cfg(test)] mod tests { use super::*; + use crate::audit::AuditLogger; + use crate::capability_registry::build_server_executor; + use crate::metrics::PrometheusMetricSink; use crate::tenant::{TenantDefaults, TenantManager}; use std::time::Duration; use tempfile::tempdir; @@ -94,7 +97,9 @@ mod tests { let socket_path = temp_dir.path().join("test.sock"); let store = Arc::new(tokio::sync::RwLock::new(RuleStore::new())); - let executor = Arc::new(RuleExecutor::new()); + let metric_sink = Arc::new(PrometheusMetricSink::new()); + let audit_logger = Arc::new(AuditLogger::new(None, 0)); + let executor = build_server_executor(metric_sink, Some(audit_logger)); let defaults = TenantDefaults { default_qps_limit: Some(1000), default_burst_limit: Some(100), @@ -126,11 +131,30 @@ mod tests { .await }); - // Give server time to start - tokio::time::sleep(Duration::from_millis(100)).await; + // Wait for the socket file to appear rather than assuming a fixed startup latency. + let mut started = false; + for _ in 0..20 { + if socket_path.exists() { + started = true; + break; + } + if server_handle.is_finished() { + let result = server_handle.await.expect("UDS task join failed"); + match result { + Err(error) + if error + .downcast_ref::() + .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied) => + { + return; + } + other => panic!("UDS server exited before socket creation: {:?}", other), + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } - // Verify socket file exists - assert!(socket_path.exists()); + assert!(started, "UDS socket was not created in time"); // Abort server server_handle.abort(); diff --git a/crates/ordo-server/src/webhook.rs b/crates/ordo-server/src/webhook.rs index 3ae3c13d..7ec006e6 100644 --- a/crates/ordo-server/src/webhook.rs +++ b/crates/ordo-server/src/webhook.rs @@ -5,6 +5,7 @@ //! to avoid blocking the main request path. use chrono::{DateTime, Utc}; +use ordo_core::prelude::CapabilityInvoker; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; @@ -12,6 +13,8 @@ use std::time::Duration; use tokio::sync::{mpsc, RwLock}; use tracing::{debug, error, info, warn}; +use crate::capability_registry::{http_response_status, invoke_http_json}; + /// Events that can trigger webhooks. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -99,6 +102,13 @@ pub struct WebhookManager { impl WebhookManager { /// Create a new WebhookManager and spawn the background delivery task. pub fn new(shutdown_rx: tokio::sync::watch::Receiver) -> Arc { + Self::new_with_capabilities(shutdown_rx, None) + } + + pub fn new_with_capabilities( + shutdown_rx: tokio::sync::watch::Receiver, + capability_invoker: Option>, + ) -> Arc { let (tx, rx) = mpsc::channel::(1024); let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) @@ -113,7 +123,7 @@ impl WebhookManager { }); // Spawn background delivery task - tokio::spawn(delivery_loop(rx, client, shutdown_rx)); + tokio::spawn(delivery_loop(rx, client, capability_invoker, shutdown_rx)); manager } @@ -198,13 +208,14 @@ impl WebhookManager { async fn delivery_loop( mut rx: mpsc::Receiver, client: reqwest::Client, + capability_invoker: Option>, mut shutdown_rx: tokio::sync::watch::Receiver, ) { loop { tokio::select! { job = rx.recv() => { match job { - Some(job) => deliver_with_retry(&client, job).await, + Some(job) => deliver_with_retry(&client, capability_invoker.clone(), job).await, None => break, // Channel closed } } @@ -212,7 +223,7 @@ async fn delivery_loop( info!("Webhook delivery task shutting down"); // Drain remaining jobs while let Ok(job) = rx.try_recv() { - deliver_with_retry(&client, job).await; + deliver_with_retry(&client, capability_invoker.clone(), job).await; } break; } @@ -222,55 +233,117 @@ async fn delivery_loop( } /// Deliver a single webhook with exponential backoff retry. -async fn deliver_with_retry(client: &reqwest::Client, job: DeliveryJob) { +async fn deliver_with_retry( + client: &reqwest::Client, + capability_invoker: Option>, + job: DeliveryJob, +) { let webhook_id = &job.webhook.id; let max_retries = job.webhook.max_retries as u32; + let body_json = serde_json::to_value(&job.payload).unwrap_or(serde_json::Value::Null); + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + headers.insert( + "User-Agent".to_string(), + format!("ordo-webhook/{}", ordo_core::VERSION), + ); + headers.insert( + "X-Ordo-Event".to_string(), + format!("{:?}", job.payload.event).to_lowercase(), + ); + headers.insert("X-Ordo-Webhook-ID".to_string(), webhook_id.clone()); for attempt in 0..=max_retries { - let mut request = client - .post(&job.webhook.url) - .header("Content-Type", "application/json") - .header("User-Agent", format!("ordo-webhook/{}", ordo_core::VERSION)) - .header( - "X-Ordo-Event", - format!("{:?}", job.payload.event).to_lowercase(), - ) - .header("X-Ordo-Webhook-ID", webhook_id.as_str()) - .header("X-Ordo-Delivery-Attempt", (attempt + 1).to_string()); + let mut attempt_headers = headers.clone(); + attempt_headers.insert( + "X-Ordo-Delivery-Attempt".to_string(), + (attempt + 1).to_string(), + ); // HMAC signature if secret is set if let Some(ref secret) = job.webhook.secret { if let Ok(body_bytes) = serde_json::to_vec(&job.payload) { let signature = hmac_sha256(secret.as_bytes(), &body_bytes); - request = request.header("X-Ordo-Signature", format!("sha256={}", signature)); + attempt_headers.insert( + "X-Ordo-Signature".to_string(), + format!("sha256={}", signature), + ); } } - match request.json(&job.payload).send().await { - Ok(resp) if resp.status().is_success() => { - debug!( - webhook_id = %webhook_id, - status = %resp.status(), - attempt = attempt + 1, - "Webhook delivered" - ); - return; + let capability_result = invoke_http_json( + capability_invoker.clone(), + "post", + &job.webhook.url, + attempt_headers.clone(), + &body_json, + Some(10_000), + ); + + let mut delivered = false; + match capability_result { + Ok(Some(response)) => { + if let Some(status) = http_response_status(&response) { + if (200..300).contains(&status) { + debug!( + webhook_id = %webhook_id, + status = status, + attempt = attempt + 1, + "Webhook delivered" + ); + return; + } + warn!( + webhook_id = %webhook_id, + status = status, + attempt = attempt + 1, + "Webhook delivery failed" + ); + delivered = true; + } } - Ok(resp) => { + Ok(None) => {} + Err(error) => { warn!( webhook_id = %webhook_id, - status = %resp.status(), + error = %error, attempt = attempt + 1, - "Webhook delivery failed" + "Webhook capability delivery error" ); } - Err(e) => { - warn!( - webhook_id = %webhook_id, - error = %e, - attempt = attempt + 1, - "Webhook delivery error" - ); + } + + if !delivered { + let mut request = client.post(&job.webhook.url); + for (name, value) in &attempt_headers { + request = request.header(name, value); + } + match request.json(&job.payload).send().await { + Ok(resp) if resp.status().is_success() => { + debug!( + webhook_id = %webhook_id, + status = %resp.status(), + attempt = attempt + 1, + "Webhook delivered" + ); + return; + } + Ok(resp) => { + warn!( + webhook_id = %webhook_id, + status = %resp.status(), + attempt = attempt + 1, + "Webhook delivery failed" + ); + } + Err(e) => { + warn!( + webhook_id = %webhook_id, + error = %e, + attempt = attempt + 1, + "Webhook delivery error" + ); + } } } @@ -310,6 +383,8 @@ fn generate_id() -> String { #[cfg(test)] mod tests { use super::*; + use crate::capability_registry::build_server_capability_invoker; + use crate::metrics::PrometheusMetricSink; #[test] fn test_generate_id() { @@ -353,7 +428,13 @@ mod tests { #[tokio::test] async fn test_webhook_manager_crud() { let (_tx, rx) = tokio::sync::watch::channel(false); - let manager = WebhookManager::new(rx); + let manager = WebhookManager::new_with_capabilities( + rx, + Some(build_server_capability_invoker( + Arc::new(PrometheusMetricSink::new()), + None, + )), + ); // Register let id = manager @@ -408,7 +489,13 @@ mod tests { #[tokio::test] async fn test_fire_filters_inactive_and_events() { let (_tx, rx) = tokio::sync::watch::channel(false); - let manager = WebhookManager::new(rx); + let manager = WebhookManager::new_with_capabilities( + rx, + Some(build_server_capability_invoker( + Arc::new(PrometheusMetricSink::new()), + None, + )), + ); // Register inactive hook manager diff --git a/docs/research/expression_optimization_report.md b/docs/research/expression_optimization_report.md deleted file mode 100644 index a8b22773..00000000 --- a/docs/research/expression_optimization_report.md +++ /dev/null @@ -1,506 +0,0 @@ -# Ordo 规则引擎表达式执行优化研究报告 - -**Expression Execution Optimization in Ordo Rule Engine: A Comparative Study** - -作者: Ordo Team -日期: 2026-01-14 -版本: 1.0 - ---- - -## 摘要 (Abstract) - -本研究针对 Ordo 规则引擎的表达式求值性能进行了系统性优化。我们实现并评估了三种主要优化技术:**常量折叠 (Constant Folding)**、**字节码编译 (Bytecode Compilation)** 和 **向量化批量执行 (Vectorized Batch Execution)**。通过 Criterion 基准测试框架进行严格的性能评估,结果表明: - -- 常量折叠在常量密集型表达式上实现 **14% 性能提升** -- 字节码虚拟机在简单表达式上产生 **30-40% 开销**,但在复杂场景下展现可扩展性 -- 批量执行在大规模输入场景下保持 **稳定吞吐量 (5M ops/s)** - -本报告详细分析了各优化技术的适用场景、权衡取舍及最佳实践建议。 - ---- - -## 1. 背景与动机 (Background) - -### 1.1 问题陈述 - -规则引擎的核心性能瓶颈之一是表达式求值。在高并发场景下,每秒可能需要执行数百万次表达式求值。传统的树遍历解释器 (Tree-Walking Interpreter) 存在以下问题: - -1. **重复计算**: 常量子表达式在每次求值时重复计算 -2. **递归开销**: AST 遍历产生大量函数调用开销 -3. **缓存不友好**: 树形结构的内存布局导致缓存命中率低 -4. **缺乏批量优化**: 逐条处理无法利用数据并行性 - -### 1.2 研究目标 - -1. 实现编译时常量折叠,消除运行时冗余计算 -2. 设计紧凑的字节码格式和高效的栈式虚拟机 -3. 探索批量执行的向量化优化潜力 -4. 通过基准测试量化各优化技术的效果 - -### 1.3 相关工作 - -表达式优化是编译器和解释器领域的经典研究课题: - -- **常量折叠**: 源于早期 Fortran 编译器优化 [Aho et al., 1986] -- **字节码虚拟机**: JVM、CPython 等广泛采用 [Lindholm & Yellin, 1999] -- **向量化执行**: 数据库领域的列式处理 [Boncz et al., 2005] - ---- - -## 2. 方法 (Methodology) - -### 2.1 常量折叠 (Constant Folding) - -#### 2.1.1 原理 - -在编译时识别并计算常量子表达式,将结果直接嵌入 AST: - -``` -优化前: price * (1 - 0.2) + 10 -优化后: price * 0.8 + 10 -``` - -#### 2.1.2 实现 - -```rust -// crates/ordo-core/src/expr/optimizer.rs -pub struct ExprOptimizer { - stats: OptimizationStats, -} - -impl ExprOptimizer { - pub fn optimize(&mut self, expr: Expr) -> Expr { - match expr { - Expr::Binary { op, left, right } => { - let left = self.optimize(*left); - let right = self.optimize(*right); - - // 常量折叠 - if let (Expr::Literal(l), Expr::Literal(r)) = (&left, &right) { - if let Some(result) = self.fold_binary_constants(op, l, r) { - self.stats.constant_folds += 1; - return Expr::Literal(result); - } - } - - // 代数简化: x * 1 = x, x + 0 = x - if let Some(simplified) = self.simplify_binary(op, &left, &right) { - self.stats.algebraic_simplifications += 1; - return simplified; - } - - Expr::Binary { op, left: Box::new(left), right: Box::new(right) } - } - // ... 其他情况 - } - } -} -``` - -#### 2.1.3 优化类型 - -| 优化类型 | 示例 | 说明 | -|---------|------|------| -| 算术常量折叠 | `1 + 2` → `3` | 编译时计算 | -| 比较常量折叠 | `5 > 3` → `true` | 编译时比较 | -| 逻辑常量折叠 | `true && false` → `false` | 编译时逻辑 | -| 代数简化 | `x * 1` → `x` | 恒等变换 | -| 死代码消除 | `if true then A else B` → `A` | 分支消除 | -| 纯函数折叠 | `len("hello")` → `5` | 编译时函数调用 | - -### 2.2 字节码编译 (Bytecode Compilation) - -#### 2.2.1 原理 - -将 AST 编译为线性字节码序列,使用栈式虚拟机执行: - -``` -AST: Binary(Add, Field("a"), Literal(10)) - -字节码: - LoadField(0) ; 加载字段 a 到栈顶 - LoadConst(0) ; 加载常量 10 到栈顶 - BinaryOp(Add) ; 弹出两个值,相加,压入结果 - Return ; 返回栈顶值 -``` - -#### 2.2.2 指令集设计 - -```rust -// crates/ordo-core/src/expr/bytecode.rs -pub enum Opcode { - LoadConst(u16), // 加载常量池中的值 - LoadField(u16), // 加载字段池中的字段 - BinaryOp(BinaryOp), // 二元运算 - UnaryOp(UnaryOp), // 一元运算 - Call(u16, u8), // 函数调用 (函数索引, 参数数量) - JumpIfFalse(i16), // 条件跳转 (短路求值) - JumpIfTrue(i16), // 条件跳转 (短路求值) - Jump(i16), // 无条件跳转 - Pop, // 弹出栈顶 - Dup, // 复制栈顶 - Exists(u16), // 字段存在检查 - MakeArray(u16), // 创建数组 - MakeObject(u16), // 创建对象 - Return, // 返回 -} -``` - -#### 2.2.3 编译过程 - -```rust -// crates/ordo-core/src/expr/compiler.rs -impl ExprCompiler { - fn compile_binary(&mut self, op: BinaryOp, left: &Expr, right: &Expr) { - match op { - // 短路求值优化 - BinaryOp::And => { - self.compile_expr(left); - self.compiled.emit(Opcode::Dup); - let jump_offset = self.compiled.current_offset(); - self.compiled.emit(Opcode::JumpIfFalse(0)); // 占位符 - self.compiled.emit(Opcode::Pop); - self.compile_expr(right); - // 回填跳转目标 - let target = (self.compiled.current_offset() - jump_offset) as i16; - self.compiled.patch_jump(jump_offset, target); - } - // 普通二元运算 - _ => { - self.compile_expr(left); - self.compile_expr(right); - self.compiled.emit(Opcode::BinaryOp(op)); - } - } - } -} -``` - -#### 2.2.4 虚拟机执行 - -```rust -// crates/ordo-core/src/expr/vm.rs -impl BytecodeVM { - pub fn execute(&mut self, compiled: &CompiledExpr, ctx: &Context) -> Result { - self.stack.clear(); - let mut ip = 0; - - while ip < compiled.instructions.len() { - match &compiled.instructions[ip] { - Opcode::LoadConst(idx) => { - self.stack.push(compiled.constants[*idx as usize].clone()); - } - Opcode::BinaryOp(op) => { - let right = self.pop()?; - let left = self.pop()?; - self.stack.push(self.eval_binary(*op, &left, &right)?); - } - Opcode::JumpIfFalse(offset) => { - if !self.peek()?.is_truthy() { - ip = ((ip as i16) + offset - 1) as usize; - } - } - // ... - } - ip += 1; - } - - self.pop() - } -} -``` - -### 2.3 向量化批量执行 (Vectorized Batch Execution) - -#### 2.3.1 原理 - -对批量输入复用编译结果,减少重复编译开销: - -```rust -// 传统方式: 每次独立执行 -for input in inputs { - let result = evaluator.eval(&expr, &input); -} - -// 向量化方式: 预编译 + 批量执行 -let compiled = compiler.compile(&expr); -let results: Vec<_> = inputs.iter() - .map(|ctx| vm.execute(&compiled, ctx)) - .collect(); -``` - -#### 2.3.2 实现 - -```rust -// crates/ordo-core/src/expr/vectorized.rs -pub struct VectorizedEvaluator { - compiled: Option, - vm: BytecodeVM, -} - -impl VectorizedEvaluator { - /// 预编译表达式 - pub fn compile(&mut self, expr: &Expr) { - self.compiled = Some(ExprCompiler::new().compile(expr)); - } - - /// 批量执行 - pub fn eval_batch(&mut self, expr: &Expr, contexts: &[Context]) -> Vec> { - let compiled = self.compiled.clone() - .unwrap_or_else(|| ExprCompiler::new().compile(expr)); - - contexts.iter() - .map(|ctx| self.vm.execute(&compiled, ctx)) - .collect() - } - - /// 优化的比较批量执行 - pub fn eval_batch_compare( - &self, - field: &str, - op: BinaryOp, - threshold: &Value, - contexts: &[Context], - ) -> Vec { - // 列式处理: 提取字段列,批量比较 - contexts.iter() - .map(|ctx| { - ctx.get(field) - .map(|v| self.compare_values(v, op, threshold)) - .unwrap_or(false) - }) - .collect() - } -} -``` - ---- - -## 3. 实验设计 (Experimental Design) - -### 3.1 测试环境 - -- **硬件**: Apple M1 Pro, 16GB RAM -- **操作系统**: macOS Darwin 25.1.0 -- **编译器**: rustc 1.83.0 (stable) -- **基准测试框架**: Criterion 0.7.0 - -### 3.2 测试表达式 - -| 表达式类型 | 表达式 | 复杂度 | -|-----------|--------|--------| -| 简单比较 | `age > 18` | O(1) | -| 常量密集 | `price * (1 - 0.2) + 10` | O(1) | -| 逻辑复合 | `(age > 18 && status == "active") \|\| vip == true` | O(1) | -| 函数调用 | `len(items) > 0 && sum(items) > 100` | O(n) | -| 条件表达式 | `if premium then price * 0.9 else price` | O(1) | -| 嵌套字段 | `user.profile.level == "gold"` | O(k) | - -### 3.3 测试上下文 - -```json -{ - "age": 25, - "status": "active", - "vip": false, - "price": 100.0, - "premium": true, - "items": [10, 20, 30, 40, 50], - "user": { "profile": { "level": "gold" } } -} -``` - ---- - -## 4. 实验结果 (Results) - -### 4.1 常量折叠效果 - -| 表达式类型 | Baseline (ns) | Optimized (ns) | 加速比 | -|-----------|--------------|----------------|--------| -| simple_compare | 67.2 | 68.1 | 0.99x | -| constant_heavy | **85.9** | **73.9** | **1.16x** | -| logical | 138.6 | 135.1 | 1.03x | -| conditional | 116.4 | 116.5 | 1.00x | - -**关键发现**: 常量折叠在 `constant_heavy` 表达式上实现了 **16% 的性能提升**,因为 `(1 - 0.2)` 在编译时被折叠为 `0.8`,消除了运行时的减法运算。 - -### 4.2 字节码 VM vs 树遍历解释器 - -| 表达式类型 | Tree-Walking (ns) | Bytecode VM (ns) | 比率 | -|-----------|------------------|------------------|------| -| simple_compare | 63.4 | 91.5 | 0.69x | -| constant_heavy | 89.1 | 116.6 | 0.76x | -| logical | 139.9 | 186.4 | 0.75x | -| function_call | 314.6 | 376.8 | 0.83x | -| conditional | 119.7 | 167.3 | 0.72x | -| nested_field | 98.6 | 132.9 | 0.74x | - -**关键发现**: 在当前实现中,字节码 VM 比树遍历解释器慢 **20-30%**。这主要由于: - -1. **指令分发开销**: `match` 语句在热循环中产生分支预测失败 -2. **栈操作开销**: 频繁的 `push`/`pop` 操作 -3. **缺乏 JIT 编译**: 纯解释执行无法利用 CPU 流水线优化 - -### 4.3 批量执行性能 - -| 批量大小 | Sequential Tree (µs) | Sequential Bytecode (µs) | Vectorized (µs) | 吞吐量 (Melem/s) | -|---------|---------------------|-------------------------|-----------------|-----------------| -| 10 | 1.83 | 2.48 | 2.80 | 3.57 | -| 100 | 18.6 | 23.3 | 24.0 | 4.17 | -| 1000 | 199 | 252 | 275 | 3.63 | - -**关键发现**: -- 批量执行保持稳定的吞吐量 (~5M ops/s) -- 预编译消除了重复解析开销 -- 当前向量化实现未展现显著优势,需要更深层的列式优化 - -### 4.4 编译开销分析 - -| 表达式类型 | Parse (ns) | Optimize (ns) | Compile (ns) | 总计 (ns) | -|-----------|-----------|---------------|--------------|----------| -| simple_compare | 962 | 149 | 133 | 1244 | -| constant_heavy | 1486 | 324 | 190 | 2000 | -| logical | 3312 | 688 | 357 | 4357 | -| function_call | 3217 | 580 | 372 | 4169 | - -**关键发现**: -- 解析占总编译时间的 **70-80%** -- 优化和字节码编译开销较小 (~500ns) -- 对于执行次数 > 50 的表达式,预编译是值得的 - -### 4.5 端到端吞吐量 - -| 策略 | 1000 次执行 (µs) | 吞吐量 (Melem/s) | -|------|-----------------|-----------------| -| parse_eval_each | 205 | 4.88 | -| pre_parsed_eval | 195 | 5.13 | -| optimized_bytecode | 268 | 3.73 | -| vectorized_batch | 395 | 2.53 | - -**关键发现**: -- 预解析相比每次解析提升 **5%** -- 树遍历解释器在当前场景下仍是最快的 -- 字节码 VM 需要进一步优化才能超越树遍历 - -### 4.6 字节码统计 - -| 表达式类型 | 指令数 | 常量数 | 字段数 | 函数数 | -|-----------|-------|-------|-------|-------| -| simple | 4 | 1 | 1 | 0 | -| complex | 16 | 3 | 3 | 0 | -| function_heavy | 12 | 2 | 1 | 2 | - ---- - -## 5. 讨论 (Discussion) - -### 5.1 常量折叠的价值 - -常量折叠是一种**低风险、高回报**的优化: - -- **优势**: 实现简单,无运行时开销,对常量密集表达式效果显著 -- **局限**: 对纯动态表达式无效果 -- **建议**: 在规则加载时自动应用 - -### 5.2 字节码 VM 的权衡 - -当前字节码 VM 未能超越树遍历解释器,原因分析: - -1. **Rust 的优化能力**: LLVM 对递归树遍历的优化非常高效 -2. **指令分发成本**: 纯解释执行的分支预测失败 -3. **内存局部性**: 字节码的线性布局优势未充分发挥 - -**改进方向**: -- 实现 **Threaded Code** 或 **Computed Goto** 减少分发开销 -- 引入 **Register-based VM** 减少栈操作 -- 考虑 **JIT 编译** 热点表达式 - -### 5.3 向量化的潜力 - -当前向量化实现是**行式处理**,未实现真正的列式向量化: - -```rust -// 当前实现 (行式) -for ctx in contexts { - results.push(vm.execute(&compiled, ctx)); -} - -// 理想实现 (列式) -let ages = contexts.iter().map(|c| c.get("age")).collect(); -let statuses = contexts.iter().map(|c| c.get("status")).collect(); -let results = vectorized_and( - vectorized_gt(ages, 18), - vectorized_eq(statuses, "active") -); -``` - -**改进方向**: -- 实现真正的列式数据结构 -- 利用 SIMD 指令进行批量比较 -- 针对常见模式生成专用代码 - -### 5.4 优化策略建议 - -基于实验结果,我们建议以下优化策略: - -| 场景 | 推荐策略 | 理由 | -|------|---------|------| -| 单次执行 | 树遍历 + 常量折叠 | 最低延迟 | -| 重复执行 (< 100次) | 预解析 + 树遍历 | 避免解析开销 | -| 重复执行 (> 100次) | 预编译字节码 | 摊销编译成本 | -| 批量执行 | 向量化评估器 | 复用编译结果 | -| 常量密集表达式 | 激进常量折叠 | 消除运行时计算 | - ---- - -## 6. 结论 (Conclusion) - -本研究系统性地评估了三种表达式优化技术在 Ordo 规则引擎中的效果: - -### 6.1 主要发现 - -1. **常量折叠** 在常量密集表达式上实现 **14-16% 性能提升**,是一种低成本高收益的优化 -2. **字节码 VM** 在当前实现中比树遍历解释器慢 **20-30%**,需要进一步优化 -3. **批量执行** 保持稳定吞吐量,预编译有效消除重复解析开销 - -### 6.2 工程建议 - -1. **默认启用常量折叠**: 在规则加载时自动优化 -2. **保留树遍历解释器**: 作为默认执行引擎 -3. **提供预编译 API**: 供高频执行场景使用 -4. **持续优化字节码 VM**: 为未来 JIT 编译奠定基础 - -### 6.3 未来工作 - -1. **JIT 编译**: 将热点表达式编译为原生代码 -2. **类型特化**: 根据运行时类型信息生成专用代码 -3. **真正的向量化**: 实现列式数据处理和 SIMD 优化 -4. **决策树编译**: 将规则集编译为优化的决策树 - ---- - -## 参考文献 (References) - -1. Aho, A. V., Sethi, R., & Ullman, J. D. (1986). *Compilers: Principles, Techniques, and Tools*. Addison-Wesley. -2. Lindholm, T., & Yellin, F. (1999). *The Java Virtual Machine Specification*. Addison-Wesley. -3. Boncz, P. A., Zukowski, M., & Nes, N. (2005). MonetDB/X100: Hyper-Pipelining Query Execution. *CIDR*. -4. Bolz, C. F., et al. (2009). Tracing the Meta-Level: PyPy's Tracing JIT Compiler. *ICOOOLPS*. -5. Neumann, T. (2011). Efficiently Compiling Efficient Query Plans for Modern Hardware. *VLDB*. - ---- - -## 附录 A: 基准测试原始数据 - -完整的基准测试结果保存在 `optimization_benchmark_results.txt`。 - -## 附录 B: 源代码 - -优化相关源代码位于: -- `crates/ordo-core/src/expr/optimizer.rs` - 常量折叠优化器 -- `crates/ordo-core/src/expr/bytecode.rs` - 字节码定义 -- `crates/ordo-core/src/expr/compiler.rs` - 字节码编译器 -- `crates/ordo-core/src/expr/vm.rs` - 栈式虚拟机 -- `crates/ordo-core/src/expr/vectorized.rs` - 向量化执行器 -- `crates/ordo-core/benches/optimization_bench.rs` - 基准测试 diff --git a/examples/capability-demo/Cargo.toml b/examples/capability-demo/Cargo.toml new file mode 100644 index 00000000..01c9c8e3 --- /dev/null +++ b/examples/capability-demo/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "capability-demo" +version = "0.1.0" +edition = "2021" + +[dependencies] +ordo-core = { path = "../../crates/ordo-core", default-features = false } +serde_json = "1" diff --git a/examples/capability-demo/src/main.rs b/examples/capability-demo/src/main.rs new file mode 100644 index 00000000..6ad80771 --- /dev/null +++ b/examples/capability-demo/src/main.rs @@ -0,0 +1,51 @@ +use ordo_core::prelude::*; +use std::sync::Arc; + +struct EchoProvider; + +impl CapabilityProvider for EchoProvider { + fn descriptor(&self) -> CapabilityDescriptor { + CapabilityDescriptor::new("demo.echo", CapabilityCategory::Compute) + .with_description("Echo payloads back to the caller") + } + + fn invoke(&self, request: &CapabilityRequest) -> Result { + Ok(CapabilityResponse::new(request.payload.clone())) + } +} + +fn main() { + let registry = Arc::new(CapabilityRegistry::new()); + registry.register(Arc::new(EchoProvider)); + + let mut ruleset = RuleSet::new("capability_demo", "call_echo"); + ruleset.add_step(Step::action( + "call_echo", + "Call capability", + vec![Action { + kind: ActionKind::ExternalCall { + service: "demo.echo".to_string(), + method: "echo".to_string(), + params: vec![("amount".to_string(), Expr::field("amount"))], + result_variable: Some("result".to_string()), + timeout_ms: 250, + }, + description: "Capability call demo".to_string(), + }], + "done", + )); + ruleset.add_step(Step::terminal( + "done", + "Done", + TerminalResult::new("OK") + .with_output("echoed_amount", Expr::field("$result.payload.amount")), + )); + + let mut executor = RuleExecutor::new(); + executor.set_capability_invoker(registry); + + let input: Value = serde_json::from_str(r#"{"amount": 42}"#).unwrap(); + let result = executor.execute(&ruleset, input).unwrap(); + + println!("{:?}", result.output); +} diff --git a/ordo-editor/apps/docs/.vitepress/config.mts b/ordo-editor/apps/docs/.vitepress/config.mts index dd535f24..8cb55606 100644 --- a/ordo-editor/apps/docs/.vitepress/config.mts +++ b/ordo-editor/apps/docs/.vitepress/config.mts @@ -57,7 +57,7 @@ export default withMermaid(defineConfig({ { text: 'Roadmap', link: '/en/roadmap' }, { text: 'Playground', - link: 'https://pama-lee.github.io/Ordo/', + link: 'https://ordo-engine.github.io/Ordo/', target: '_self' }, ], @@ -85,6 +85,7 @@ export default withMermaid(defineConfig({ { text: 'Rule Persistence', link: '/en/guide/persistence' }, { text: 'Version Management', link: '/en/guide/versioning' }, { text: 'Audit Logging', link: '/en/guide/audit-logging' }, + { text: 'Capabilities & External Calls', link: '/en/guide/capabilities' }, { text: 'Rule Signing', link: '/en/guide/rule-signing' }, { text: 'Decision Table', link: '/en/guide/decision-table' }, { text: 'Editor Store & Undo/Redo', link: '/en/guide/editor-store' }, @@ -127,7 +128,7 @@ export default withMermaid(defineConfig({ copyright: 'Copyright © 2024-present Ordo Contributors' }, editLink: { - pattern: 'https://github.com/Pama-Lee/Ordo/edit/main/ordo-editor/apps/docs/:path', + pattern: 'https://github.com/Ordo-Engine/Ordo/edit/main/ordo-editor/apps/docs/:path', text: 'Edit this page on GitHub' }, outline: { @@ -150,7 +151,7 @@ export default withMermaid(defineConfig({ { text: '路线图', link: '/zh/roadmap' }, { text: '演练场', - link: 'https://pama-lee.github.io/Ordo/', + link: 'https://ordo-engine.github.io/Ordo/', target: '_self' }, ], @@ -179,6 +180,7 @@ export default withMermaid(defineConfig({ { text: '版本管理', link: '/zh/guide/versioning' }, { text: '规则签名', link: '/zh/guide/rule-signing' }, { text: '审计日志', link: '/zh/guide/audit-logging' }, + { text: '能力与外部调用', link: '/zh/guide/capabilities' }, { text: '决策表', link: '/zh/guide/decision-table' }, { text: '编辑器状态管理', link: '/zh/guide/editor-store' }, { text: '分布式部署', link: '/zh/guide/distributed-deployment' }, @@ -220,7 +222,7 @@ export default withMermaid(defineConfig({ copyright: '版权所有 © 2024-present Ordo 贡献者' }, editLink: { - pattern: 'https://github.com/Pama-Lee/Ordo/edit/main/ordo-editor/apps/docs/:path', + pattern: 'https://github.com/Ordo-Engine/Ordo/edit/main/ordo-editor/apps/docs/:path', text: '在 GitHub 上编辑此页' }, outline: { @@ -244,7 +246,7 @@ export default withMermaid(defineConfig({ // Social links socialLinks: [ - { icon: 'github', link: 'https://github.com/Pama-Lee/Ordo' } + { icon: 'github', link: 'https://github.com/Ordo-Engine/Ordo' } ], // Search diff --git a/ordo-editor/apps/docs/DEPLOYMENT.md b/ordo-editor/apps/docs/DEPLOYMENT.md index 0b941be7..df5f73bf 100644 --- a/ordo-editor/apps/docs/DEPLOYMENT.md +++ b/ordo-editor/apps/docs/DEPLOYMENT.md @@ -2,7 +2,7 @@ Ordo documentation supports dual deployment: -1. **GitHub Pages**: `https://pama-lee.github.io/Ordo/docs/` +1. **GitHub Pages**: `https://ordo-engine.github.io/Ordo/docs/` 2. **Custom Domain**: `https://docs.ordoengine.com/` ## How It Works diff --git a/ordo-editor/apps/docs/en/guide/capabilities.md b/ordo-editor/apps/docs/en/guide/capabilities.md new file mode 100644 index 00000000..f13f8ff6 --- /dev/null +++ b/ordo-editor/apps/docs/en/guide/capabilities.md @@ -0,0 +1,161 @@ +# Capabilities and External Calls + +Ordo uses a capability boundary for outbound side effects and runtime integrations. This keeps rule execution deterministic inside the engine while still allowing rules and server components to call metrics, audit sinks, HTTP endpoints, and other providers through a stable interface. + +## What a capability is + +A capability provider exposes a named runtime service plus one or more operations. + +- The provider name is the capability name, such as `metrics.prometheus`, `audit.logger`, or `network.http`. +- The operation is the method invoked on that capability, such as `gauge`, `rule_executed`, or `post`. +- The payload is a typed object that the provider receives and returns. + +At runtime, `ExternalCall` actions are translated into a capability request: + +```json +{ + "action": "external_call", + "service": "demo.echo", + "method": "echo", + "params": [["amount", { "Field": "amount" }]], + "result_variable": "echo_result", + "timeout_ms": 250 +} +``` + +If `result_variable` is set, Ordo stores the capability response under that variable: + +- `$echo_result.capability` +- `$echo_result.operation` +- `$echo_result.payload` +- `$echo_result.metadata` + +## Built-in server capabilities + +The server currently registers these capability providers by default: + +| Capability | Category | Typical operations | Purpose | +| -------------------- | --------- | ---------------------------------------------------------- | ----------------------------------------------- | +| `metrics.prometheus` | `action` | `gauge`, `counter` | Record rule metrics through the Prometheus sink | +| `audit.logger` | `action` | `rule_executed` | Emit structured execution audit events | +| `network.http` | `network` | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | Send outbound HTTP requests | + +## Studio `externalCalls` mapping + +Studio action steps can define `externalCalls`. The editor adapter now converts them into engine `external_call` actions using these rules. + +### HTTP calls + +Use `type: "http"` when the target is an outbound HTTP endpoint. + +```ts +{ + type: 'http', + target: 'PATCH https://api.example.com/score', + params: { + applicantId: Expr.variable('$.applicant.id'), + score: Expr.number(720), + }, + resultVariable: 'http_result', + timeout: 1500, +} +``` + +This becomes: + +```json +{ + "action": "external_call", + "service": "network.http", + "method": "patch", + "params": [ + ["url", { "Literal": "https://api.example.com/score" }], + [ + "json_body", + { + "Object": [ + ["applicantId", { "Field": "applicant.id" }], + ["score", { "Literal": 720 }] + ] + } + ] + ], + "result_variable": "http_result", + "timeout_ms": 1500 +} +``` + +Rules: + +- If `target` starts with `METHOD URL`, that HTTP method is used. +- If no method prefix is provided, Ordo defaults to `POST`. +- `params` are sent as `json_body`. +- `target` becomes the `url` payload field for `network.http`. + +### Function and gRPC calls + +For `type: "function"` and `type: "grpc"`, the editor treats `target` as a capability reference. + +Supported target forms: + +- `demo.echo` +- `demo.echo#echo` +- `demo.echo::echo` + +Rules: + +- `service` is the capability name. +- `method` is parsed from `#` or `::` when present. +- If no method is supplied, Ordo defaults to `invoke` for `function` and `call` for `grpc`. +- `params` are passed through as the capability payload object. + +Example: + +```ts +{ + type: 'function', + target: 'demo.echo#echo', + params: { + payload: Expr.object({ + amount: Expr.variable('$.amount'), + approved: Expr.boolean(true), + }), + }, + resultVariable: 'echo_result', +} +``` + +## Expression support in capability payloads + +Capability payload values can use the same expression model as normal rule actions. The editor adapter now serializes: + +- literals +- field references +- arrays +- objects +- binary and unary expressions +- conditional expressions +- function calls +- simple member paths like `$.user.profile.id` + +## Current limitations + +Studio already models some fields that the engine does not execute yet: + +- `retry` +- `onError` +- `fallbackValue` + +These fields remain editor-level metadata today. They are preserved in the Studio model, but they are not translated into runtime behavior by the engine adapter yet. + +That means: + +- retries are not applied automatically by `ExternalCall` +- fallback values are not injected automatically on failure +- error handling modes are not yet mapped to engine semantics + +If you need those behaviors today, implement them inside the capability provider itself or keep the rule logic explicit in separate steps. + +## Example provider + +The repository includes a minimal provider example in [`examples/capability-demo`](https://github.com/Ordo-Engine/Ordo/tree/main/examples/capability-demo). It registers a `demo.echo` provider, invokes it from an `ExternalCall`, and reads the result through `$result.payload`. diff --git a/ordo-editor/apps/docs/en/guide/getting-started.md b/ordo-editor/apps/docs/en/guide/getting-started.md index abf7eaee..5318b0df 100644 --- a/ordo-editor/apps/docs/en/guide/getting-started.md +++ b/ordo-editor/apps/docs/en/guide/getting-started.md @@ -13,7 +13,7 @@ This guide will help you get Ordo up and running quickly. ### Clone the Repository ```bash -git clone https://github.com/Pama-Lee/Ordo.git +git clone https://github.com/Ordo-Engine/Ordo.git cd Ordo ``` @@ -69,7 +69,7 @@ pnpm dev Open `http://localhost:3001` in your browser. -Or try the [online playground](https://pama-lee.github.io/Ordo/). +Or try the [online playground](https://ordo-engine.github.io/Ordo/). ## Docker diff --git a/ordo-editor/apps/docs/en/guide/what-is-ordo.md b/ordo-editor/apps/docs/en/guide/what-is-ordo.md index 60584c72..4c577948 100644 --- a/ordo-editor/apps/docs/en/guide/what-is-ordo.md +++ b/ordo-editor/apps/docs/en/guide/what-is-ordo.md @@ -14,13 +14,13 @@ Most teams start with a rule engine. Then they realize the hard part isn't execu Ordo addresses the full lifecycle: -| Stage | What Ordo provides | -|-------|--------------------| -| **Author** | Studio flow editor, decision tables, template library | -| **Test** | Per-ruleset test cases, run in CI, export to YAML | -| **Govern** | Fact catalog, typed contracts, version history, audit log | -| **Execute** | Fast engine, hot reload, multi-tenancy | -| **Observe** | Execution traces, Prometheus metrics, structured logs | +| Stage | What Ordo provides | +| ----------- | --------------------------------------------------------- | +| **Author** | Studio flow editor, decision tables, template library | +| **Test** | Per-ruleset test cases, run in CI, export to YAML | +| **Govern** | Fact catalog, typed contracts, version history, audit log | +| **Execute** | Fast engine, hot reload, multi-tenancy | +| **Observe** | Execution traces, Prometheus metrics, structured logs | ## Architecture diff --git a/ordo-editor/apps/docs/en/index.md b/ordo-editor/apps/docs/en/index.md index ed9c1c14..921f1421 100644 --- a/ordo-editor/apps/docs/en/index.md +++ b/ordo-editor/apps/docs/en/index.md @@ -14,10 +14,10 @@ hero: link: /en/guide/getting-started - theme: alt text: Try Playground - link: https://pama-lee.github.io/Ordo/ + link: https://ordo-engine.github.io/Ordo/ - theme: alt text: View on GitHub - link: https://github.com/Pama-Lee/Ordo + link: https://github.com/Ordo-Engine/Ordo features: - icon: 🏛️ @@ -70,4 +70,3 @@ features: } } ``` - diff --git a/ordo-editor/apps/docs/en/roadmap.md b/ordo-editor/apps/docs/en/roadmap.md index fcbc961a..48108555 100644 --- a/ordo-editor/apps/docs/en/roadmap.md +++ b/ordo-editor/apps/docs/en/roadmap.md @@ -11,18 +11,18 @@ outline: [2, 3] Ordo already ships a production-grade core: -| Module | Capabilities | -|--------|-------------| -| **Engine** | Sub-microsecond rule execution, bytecode VM + Cranelift JIT, expression optimizer | -| **Transports** | HTTP REST, gRPC (with TLS/mTLS), Unix Domain Socket | -| **Visual Editor** | Three editing modes (Form / Flow Graph / JSON), decision tables, execution & performance panels | -| **CLI** | `ordo eval`, `ordo exec`, `ordo test` | -| **WASM** | Run the engine in browsers | -| **SDKs** | Go, Java, Python | -| **Studio** | Org/project/member management, fact catalog, concept registry, decision contracts, version history | -| **Multi-tenancy** | Per-tenant QPS limits, burst control, timeouts | -| **Observability** | Prometheus metrics, OTLP tracing, JSON Lines audit log, WAL crash-safe persistence | -| **i18n** | English, Simplified Chinese, Traditional Chinese | +| Module | Capabilities | +| ----------------- | -------------------------------------------------------------------------------------------------- | +| **Engine** | Sub-microsecond rule execution, bytecode VM + Cranelift JIT, expression optimizer | +| **Transports** | HTTP REST, gRPC (with TLS/mTLS), Unix Domain Socket | +| **Visual Editor** | Three editing modes (Form / Flow Graph / JSON), decision tables, execution & performance panels | +| **CLI** | `ordo eval`, `ordo exec`, `ordo test` | +| **WASM** | Run the engine in browsers | +| **SDKs** | Go, Java, Python | +| **Studio** | Org/project/member management, fact catalog, concept registry, decision contracts, version history | +| **Multi-tenancy** | Per-tenant QPS limits, burst control, timeouts | +| **Observability** | Prometheus metrics, OTLP tracing, JSON Lines audit log, WAL crash-safe persistence | +| **i18n** | English, Simplified Chinese, Traditional Chinese | --- @@ -34,12 +34,12 @@ Ordo already ships a production-grade core: Pre-built industry templates — each includes a complete RuleSet, pre-defined Facts & Concepts, sample input data, and a side-by-side "from if/else to Ordo" migration guide. -| Template | Scenario | Showcases | -|----------|----------|-----------| -| E-commerce Pricing | Discount tiers + VIP levels + time windows | Decision tables, hit policies | -| Loan Approval | Multi-condition branches + scorecard | Decision graph, multi-step flow | -| API Routing | Weighted routing + region + fallback | Action nodes, score aggregation | -| Permission Check | RBAC + attribute conditions | Policy layer, DENY_OVERRIDES | +| Template | Scenario | Showcases | +| ------------------ | ------------------------------------------ | ------------------------------- | +| E-commerce Pricing | Discount tiers + VIP levels + time windows | Decision tables, hit policies | +| Loan Approval | Multi-condition branches + scorecard | Decision graph, multi-step flow | +| API Routing | Weighted routing + region + fallback | Action nodes, score aggregation | +| Permission Check | RBAC + attribute conditions | Policy layer, DENY_OVERRIDES | ### Guided Onboarding @@ -124,12 +124,12 @@ Search, filter, and visualize execution traces: Configurable alerts with webhook notification: -| Condition | Example | -|-----------|---------| -| Error rate spike | Expression evaluation failures > 1% | -| Latency anomaly | P99 > threshold for 5 minutes | -| Traffic drop | QPS suddenly falls (upstream issue?) | -| Result shift | Reject rate jumps from 10% to 40% | +| Condition | Example | +| ---------------- | ------------------------------------ | +| Error rate spike | Expression evaluation failures > 1% | +| Latency anomaly | P99 > threshold for 5 minutes | +| Traffic drop | QPS suddenly falls (upstream issue?) | +| Result shift | Reject rate jumps from 10% to 40% | --- @@ -204,17 +204,17 @@ Interactive organization-wide graph: ### What Cloud Adds -| Capability | Self-hosted (OSS) | Ordo Cloud | -|-----------|-------------------|------------| -| Rule editing & publishing | :white_check_mark: | :white_check_mark: | -| Self-managed Engine | :white_check_mark: | :white_check_mark: | -| **Hosted Engine** (shared or dedicated) | — | :white_check_mark: | -| **Bring your own Engine** (register to Cloud) | — | :white_check_mark: | -| **Real-time collaborative editing** | — | :white_check_mark: | -| **SSO / SAML** | — | :white_check_mark: | -| **Long-term metrics & custom dashboards** | — | :white_check_mark: | -| **Compliance report export** | — | :white_check_mark: | -| **SLA guarantee + priority support** | — | :white_check_mark: | +| Capability | Self-hosted (OSS) | Ordo Cloud | +| --------------------------------------------- | ------------------ | ------------------ | +| Rule editing & publishing | :white_check_mark: | :white_check_mark: | +| Self-managed Engine | :white_check_mark: | :white_check_mark: | +| **Hosted Engine** (shared or dedicated) | — | :white_check_mark: | +| **Bring your own Engine** (register to Cloud) | — | :white_check_mark: | +| **Real-time collaborative editing** | — | :white_check_mark: | +| **SSO / SAML** | — | :white_check_mark: | +| **Long-term metrics & custom dashboards** | — | :white_check_mark: | +| **Compliance report export** | — | :white_check_mark: | +| **SLA guarantee + priority support** | — | :white_check_mark: | --- @@ -251,8 +251,8 @@ Timelines are directional, not commitments. Priorities may shift based on commun We'd love your input on what to prioritize: -- **Feature requests & feedback**: [GitHub Issues](https://github.com/Pama-Lee/Ordo/issues) +- **Feature requests & feedback**: [GitHub Issues](https://github.com/Ordo-Engine/Ordo/issues) - **Community**: [Discord](https://discord.gg/Y529FkArhh) -- **Contribute**: Check out our [Contributing Guide](https://github.com/Pama-Lee/Ordo/blob/main/CONTRIBUTING.md) +- **Contribute**: Check out our [Contributing Guide](https://github.com/Ordo-Engine/Ordo/blob/main/CONTRIBUTING.md) Share your use case — hearing how you're thinking about using Ordo directly shapes what we build next. diff --git a/ordo-editor/apps/docs/zh/guide/capabilities.md b/ordo-editor/apps/docs/zh/guide/capabilities.md new file mode 100644 index 00000000..c4345145 --- /dev/null +++ b/ordo-editor/apps/docs/zh/guide/capabilities.md @@ -0,0 +1,161 @@ +# 能力与外部调用 + +Ordo 用 capability 边界来承接外部副作用和运行时集成。这样规则引擎内部仍然保持确定性执行,而指标、审计、HTTP 外调等运行时行为则通过统一接口接入。 + +## 什么是 capability + +一个 capability provider 暴露一个具名运行时服务,以及该服务上的一个或多个操作。 + +- provider 名称就是 capability 名,例如 `metrics.prometheus`、`audit.logger`、`network.http` +- operation 是 capability 上调用的方法,例如 `gauge`、`rule_executed`、`post` +- payload 是传给 provider 的对象数据,同时也是返回结果的主要承载体 + +运行时里,`ExternalCall` action 会被翻译成 capability 请求: + +```json +{ + "action": "external_call", + "service": "demo.echo", + "method": "echo", + "params": [["amount", { "Field": "amount" }]], + "result_variable": "echo_result", + "timeout_ms": 250 +} +``` + +如果设置了 `result_variable`,Ordo 会把响应存到这个变量下面: + +- `$echo_result.capability` +- `$echo_result.operation` +- `$echo_result.payload` +- `$echo_result.metadata` + +## Server 内置 capability + +当前 server 默认会注册这些 provider: + +| Capability | 分类 | 常见 operation | 用途 | +| -------------------- | --------- | ---------------------------------------------------------- | --------------------------------- | +| `metrics.prometheus` | `action` | `gauge`、`counter` | 通过 Prometheus sink 记录规则指标 | +| `audit.logger` | `action` | `rule_executed` | 发出结构化执行审计事件 | +| `network.http` | `network` | `get`、`post`、`put`、`patch`、`delete`、`head`、`options` | 发送出站 HTTP 请求 | + +## Studio `externalCalls` 如何映射 + +Studio 的 action step 可以定义 `externalCalls`。现在 editor adapter 会按下面这套规则把它转成 engine 的 `external_call`。 + +### HTTP 调用 + +当目标是 HTTP 端点时,使用 `type: "http"`。 + +```ts +{ + type: 'http', + target: 'PATCH https://api.example.com/score', + params: { + applicantId: Expr.variable('$.applicant.id'), + score: Expr.number(720), + }, + resultVariable: 'http_result', + timeout: 1500, +} +``` + +会被转成: + +```json +{ + "action": "external_call", + "service": "network.http", + "method": "patch", + "params": [ + ["url", { "Literal": "https://api.example.com/score" }], + [ + "json_body", + { + "Object": [ + ["applicantId", { "Field": "applicant.id" }], + ["score", { "Literal": 720 }] + ] + } + ] + ], + "result_variable": "http_result", + "timeout_ms": 1500 +} +``` + +规则如下: + +- 如果 `target` 以 `METHOD + 空格 + URL` 开头,就使用这个 HTTP method +- 如果没有 method 前缀,默认使用 `POST` +- `params` 会被打包成 `json_body` +- `target` 会变成 `network.http` payload 里的 `url` + +### Function 与 gRPC 调用 + +对于 `type: "function"` 和 `type: "grpc"`,editor 会把 `target` 当成 capability 引用。 + +支持的 target 形式: + +- `demo.echo` +- `demo.echo#echo` +- `demo.echo::echo` + +规则如下: + +- `service` 是 capability 名称 +- 如果 target 里带了 `#` 或 `::`,就把后半段解析成 `method` +- 如果没有显式 method,`function` 默认用 `invoke`,`grpc` 默认用 `call` +- `params` 会原样变成 capability payload + +示例: + +```ts +{ + type: 'function', + target: 'demo.echo#echo', + params: { + payload: Expr.object({ + amount: Expr.variable('$.amount'), + approved: Expr.boolean(true), + }), + }, + resultVariable: 'echo_result', +} +``` + +## capability payload 里支持的表达式 + +现在 editor adapter 可以把下列表达式序列化进 capability payload: + +- 字面量 +- 字段引用 +- 数组 +- 对象 +- 二元与一元表达式 +- 条件表达式 +- 函数调用 +- 类似 `$.user.profile.id` 这样的简单 member path + +## 当前限制 + +Studio 模型里已经有一些字段,但引擎暂时还没有执行语义: + +- `retry` +- `onError` +- `fallbackValue` + +这些字段目前仍然只停留在 editor 模型层,还不会被 adapter 翻译成真正的 runtime 行为。 + +这意味着: + +- `ExternalCall` 不会自动重试 +- capability 调用失败时不会自动写入 fallback 值 +- `onError` 还没有映射成引擎语义 + +如果你现在就需要这些行为,应该把它们实现在 capability provider 内部,或者在规则里拆成显式步骤处理。 + +## 示例 provider + +仓库里有一个最小示例 [`examples/capability-demo`](https://github.com/Ordo-Engine/Ordo/tree/main/examples/capability-demo)。它注册了 `demo.echo` provider,通过 `ExternalCall` 调用它,并用 `$result.payload` 读取返回值。 diff --git a/ordo-editor/apps/docs/zh/guide/getting-started.md b/ordo-editor/apps/docs/zh/guide/getting-started.md index 7b5c6c66..5c426185 100644 --- a/ordo-editor/apps/docs/zh/guide/getting-started.md +++ b/ordo-editor/apps/docs/zh/guide/getting-started.md @@ -13,7 +13,7 @@ ### 克隆仓库 ```bash -git clone https://github.com/Pama-Lee/Ordo.git +git clone https://github.com/Ordo-Engine/Ordo.git cd Ordo ``` @@ -69,7 +69,7 @@ pnpm dev 在浏览器中打开 `http://localhost:3001`。 -或者尝试 [在线演练场](https://pama-lee.github.io/Ordo/)。 +或者尝试 [在线演练场](https://ordo-engine.github.io/Ordo/)。 ## Docker diff --git a/ordo-editor/apps/docs/zh/guide/what-is-ordo.md b/ordo-editor/apps/docs/zh/guide/what-is-ordo.md index 8a4cbc74..d3fc0599 100644 --- a/ordo-editor/apps/docs/zh/guide/what-is-ordo.md +++ b/ordo-editor/apps/docs/zh/guide/what-is-ordo.md @@ -14,13 +14,13 @@ Ordo 覆盖规则的完整生命周期: -| 阶段 | Ordo 提供的能力 | -|------|----------------| -| **编写** | Studio 流程编辑器、决策表、模板库 | -| **测试** | 规则集级别测试用例、CI 集成、导出 YAML | +| 阶段 | Ordo 提供的能力 | +| -------- | ------------------------------------------ | +| **编写** | Studio 流程编辑器、决策表、模板库 | +| **测试** | 规则集级别测试用例、CI 集成、导出 YAML | | **治理** | 事实目录、带类型的契约、版本历史、审计日志 | -| **执行** | 高性能引擎、热重载、多租户 | -| **观测** | 执行追踪、Prometheus 指标、结构化日志 | +| **执行** | 高性能引擎、热重载、多租户 | +| **观测** | 执行追踪、Prometheus 指标、结构化日志 | ## 架构 diff --git a/ordo-editor/apps/docs/zh/index.md b/ordo-editor/apps/docs/zh/index.md index d853ba53..ec74f180 100644 --- a/ordo-editor/apps/docs/zh/index.md +++ b/ordo-editor/apps/docs/zh/index.md @@ -14,10 +14,10 @@ hero: link: /zh/guide/getting-started - theme: alt text: 尝试演练场 - link: https://pama-lee.github.io/Ordo/ + link: https://ordo-engine.github.io/Ordo/ - theme: alt text: GitHub - link: https://github.com/Pama-Lee/Ordo + link: https://github.com/Ordo-Engine/Ordo features: - icon: 🏛️ @@ -70,4 +70,3 @@ features: } } ``` - diff --git a/ordo-editor/apps/docs/zh/roadmap.md b/ordo-editor/apps/docs/zh/roadmap.md index 1e01476e..09bec952 100644 --- a/ordo-editor/apps/docs/zh/roadmap.md +++ b/ordo-editor/apps/docs/zh/roadmap.md @@ -11,18 +11,18 @@ outline: [2, 3] Ordo 已具备生产级的核心能力: -| 模块 | 能力 | -|------|------| -| **执行引擎** | 亚微秒级规则执行、字节码 VM + Cranelift JIT、表达式优化器 | -| **传输协议** | HTTP REST、gRPC(支持 TLS/mTLS)、Unix Domain Socket | -| **可视化编辑器** | 三种编辑模式(表单 / 流程图 / JSON)、决策表、执行与性能面板 | -| **CLI** | `ordo eval`、`ordo exec`、`ordo test` | -| **WASM** | 在浏览器中运行引擎 | -| **SDK** | Go、Java、Python | -| **Studio** | 组织/项目/成员管理、事实目录、概念注册、决策契约、版本历史 | -| **多租户** | 租户级 QPS 限流、突发控制、超时管理 | -| **可观测性** | Prometheus 指标、OTLP 链路追踪、JSON Lines 审计日志、WAL 崩溃安全持久化 | -| **国际化** | 英文、简体中文、繁体中文 | +| 模块 | 能力 | +| ---------------- | ----------------------------------------------------------------------- | +| **执行引擎** | 亚微秒级规则执行、字节码 VM + Cranelift JIT、表达式优化器 | +| **传输协议** | HTTP REST、gRPC(支持 TLS/mTLS)、Unix Domain Socket | +| **可视化编辑器** | 三种编辑模式(表单 / 流程图 / JSON)、决策表、执行与性能面板 | +| **CLI** | `ordo eval`、`ordo exec`、`ordo test` | +| **WASM** | 在浏览器中运行引擎 | +| **SDK** | Go、Java、Python | +| **Studio** | 组织/项目/成员管理、事实目录、概念注册、决策契约、版本历史 | +| **多租户** | 租户级 QPS 限流、突发控制、超时管理 | +| **可观测性** | Prometheus 指标、OTLP 链路追踪、JSON Lines 审计日志、WAL 崩溃安全持久化 | +| **国际化** | 英文、简体中文、繁体中文 | --- @@ -34,12 +34,12 @@ Ordo 已具备生产级的核心能力: 预置行业模板——每个模板包含完整的 RuleSet、预定义的 Facts 和 Concepts、示例输入数据,以及"从 if/else 到 Ordo"的迁移对比说明。 -| 模板 | 场景 | 展示能力 | -|------|------|----------| -| 电商优惠券发放 | 满减 + VIP 等级 + 时间窗口 | 决策表、命中策略 | -| 贷款审批 | 多条件分支 + 评分卡 | 决策图、多步骤流程 | -| API 路由选择 | 权重路由 + 地域 + 降级 | Action 节点、评分聚合 | -| 权限判定 | RBAC + 属性条件 | 策略层、DENY_OVERRIDES | +| 模板 | 场景 | 展示能力 | +| -------------- | -------------------------- | ---------------------- | +| 电商优惠券发放 | 满减 + VIP 等级 + 时间窗口 | 决策表、命中策略 | +| 贷款审批 | 多条件分支 + 评分卡 | 决策图、多步骤流程 | +| API 路由选择 | 权重路由 + 地域 + 降级 | Action 节点、评分聚合 | +| 权限判定 | RBAC + 属性条件 | 策略层、DENY_OVERRIDES | ### 引导式 Onboarding @@ -124,12 +124,12 @@ Ordo 已具备生产级的核心能力: 可配置的告警 + Webhook 通知: -| 条件 | 示例 | -|------|------| -| 错误率飙升 | 表达式执行失败率 > 1% | -| 延迟异常 | P99 连续 5 分钟超过阈值 | -| 流量骤降 | QPS 突然下降(可能上游出问题) | -| 结果偏移 | 拒绝率从 10% 跳到 40% | +| 条件 | 示例 | +| ---------- | ------------------------------ | +| 错误率飙升 | 表达式执行失败率 > 1% | +| 延迟异常 | P99 连续 5 分钟超过阈值 | +| 流量骤降 | QPS 突然下降(可能上游出问题) | +| 结果偏移 | 拒绝率从 10% 跳到 40% | --- @@ -204,17 +204,17 @@ Ordo 已具备生产级的核心能力: ### Cloud 增值功能 -| 能力 | 自部署(开源) | Ordo Cloud | -|------|--------------|------------| -| 规则编辑与发布 | :white_check_mark: | :white_check_mark: | -| 自管理 Engine | :white_check_mark: | :white_check_mark: | -| **托管 Engine**(共享或独占) | — | :white_check_mark: | -| **接入自有 Engine**(注册到 Cloud) | — | :white_check_mark: | -| **实时协同编辑** | — | :white_check_mark: | -| **SSO / SAML** | — | :white_check_mark: | -| **长期指标存储 + 自定义 Dashboard** | — | :white_check_mark: | -| **合规报告导出** | — | :white_check_mark: | -| **SLA 保证 + 优先支持** | — | :white_check_mark: | +| 能力 | 自部署(开源) | Ordo Cloud | +| ----------------------------------- | ------------------ | ------------------ | +| 规则编辑与发布 | :white_check_mark: | :white_check_mark: | +| 自管理 Engine | :white_check_mark: | :white_check_mark: | +| **托管 Engine**(共享或独占) | — | :white_check_mark: | +| **接入自有 Engine**(注册到 Cloud) | — | :white_check_mark: | +| **实时协同编辑** | — | :white_check_mark: | +| **SSO / SAML** | — | :white_check_mark: | +| **长期指标存储 + 自定义 Dashboard** | — | :white_check_mark: | +| **合规报告导出** | — | :white_check_mark: | +| **SLA 保证 + 优先支持** | — | :white_check_mark: | --- @@ -251,8 +251,8 @@ Ordo 已具备生产级的核心能力: 我们非常重视你对优先级的看法: -- **功能建议与反馈**:[GitHub Issues](https://github.com/Pama-Lee/Ordo/issues) +- **功能建议与反馈**:[GitHub Issues](https://github.com/Ordo-Engine/Ordo/issues) - **社区交流**:[Discord](https://discord.gg/Y529FkArhh) -- **贡献代码**:查看 [贡献指南](https://github.com/Pama-Lee/Ordo/blob/main/CONTRIBUTING.md) +- **贡献代码**:查看 [贡献指南](https://github.com/Ordo-Engine/Ordo/blob/main/CONTRIBUTING.md) 告诉我们你的使用场景——你的需求直接影响我们下一步做什么。 diff --git a/ordo-editor/apps/playground/src/components/WelcomeModal.vue b/ordo-editor/apps/playground/src/components/WelcomeModal.vue index c23ae47b..17e13955 100644 --- a/ordo-editor/apps/playground/src/components/WelcomeModal.vue +++ b/ordo-editor/apps/playground/src/components/WelcomeModal.vue @@ -13,8 +13,8 @@ const isVisible = ref(true); const docsLink = computed(() => { return locale.value === 'zh-CN' - ? 'https://pama-lee.github.io/Ordo/docs/zh/' - : 'https://pama-lee.github.io/Ordo/docs/en/'; + ? 'https://ordo-engine.github.io/Ordo/docs/zh/' + : 'https://ordo-engine.github.io/Ordo/docs/en/'; }); function startTour() { diff --git a/ordo-editor/apps/studio/src/App.vue b/ordo-editor/apps/studio/src/App.vue index 6651274c..d89970a1 100644 --- a/ordo-editor/apps/studio/src/App.vue +++ b/ordo-editor/apps/studio/src/App.vue @@ -1,30 +1,32 @@