From 4038aac9901a8dfbce3cefa0d0985313a388d13c Mon Sep 17 00:00:00 2001 From: Pama-Lee Date: Sun, 26 Apr 2026 01:56:07 +0800 Subject: [PATCH 01/13] feat: add managed sub-rule editor workflow --- .../migrations/0017_sub_rule_assets.sql | 53 ++ .../0018_drop_sub_rule_versions.sql | 7 + crates/ordo-platform/src/lib.rs | 1 + crates/ordo-platform/src/main.rs | 23 +- crates/ordo-platform/src/models.rs | 3 + crates/ordo-platform/src/models/sub_rules.rs | 74 ++ crates/ordo-platform/src/release.rs | 1 + crates/ordo-platform/src/release/requests.rs | 4 +- crates/ordo-platform/src/ruleset_draft.rs | 110 ++- crates/ordo-platform/src/store.rs | 6 +- crates/ordo-platform/src/store/sub_rules.rs | 231 +++++++ crates/ordo-platform/src/sub_rules.rs | 217 ++++++ crates/ordo-protocol/src/types/ruleset.rs | 4 + deploy/nomad/devcontainer-entrypoint.sh | 2 +- ordo-editor/apps/docs/.vitepress/config.mts | 72 +- ordo-editor/apps/docs/en/index.md | 76 ++- ordo-editor/apps/docs/zh/index.md | 74 +- .../apps/studio/src/api/platform-client.ts | 73 ++ ordo-editor/apps/studio/src/api/types.ts | 35 + .../src/components/layout/AppLayout.vue | 2 + .../src/components/layout/ProjectLayout.vue | 7 + .../apps/studio/src/i18n/locales/en.ts | 44 ++ .../apps/studio/src/i18n/locales/zh-CN.ts | 43 ++ .../apps/studio/src/i18n/locales/zh-TW.ts | 43 ++ ordo-editor/apps/studio/src/router/index.ts | 5 + ordo-editor/apps/studio/src/stores/project.ts | 57 +- .../apps/studio/src/styles/ordo-theme.css | 2 + .../studio/src/views/editor/EditorView.vue | 335 ++++++++- .../studio/src/views/project/SubRulesView.vue | 646 ++++++++++++++++++ .../core/src/engine/__tests__/adapter.test.ts | 201 ++++++ .../packages/core/src/engine/adapter.ts | 9 +- .../core/src/engine/reverse-adapter.ts | 4 + .../packages/core/src/model/ruleset.ts | 6 + ordo-editor/packages/core/src/model/step.ts | 14 +- .../packages/core/src/validator/index.ts | 214 ++++++ .../src/components/flow/OrdoFlowEditor.vue | 14 +- .../components/flow/OrdoFlowPropertyPanel.vue | 15 +- .../src/components/flow/OrdoFlowToolbar.vue | 18 +- .../vue/src/components/flow/nodes/index.ts | 1 + .../src/components/flow/utils/converter.ts | 49 +- .../vue/src/components/flow/utils/layout.ts | 5 + .../src/components/form/OrdoFormEditor.vue | 7 + .../vue/src/components/form/OrdoStepList.vue | 62 +- .../vue/src/components/icons/OrdoIcon.vue | 7 + .../src/components/step/OrdoStepEditor.vue | 26 + .../src/components/step/OrdoSubRuleEditor.vue | 596 ++++++++++++++++ .../packages/vue/src/components/step/index.ts | 2 + .../vue/src/components/step/subRuleAssets.ts | 12 + ordo-editor/packages/vue/src/index.ts | 4 + ordo-editor/packages/vue/src/locale/index.ts | 80 +++ ordo-editor/packages/vue/src/styles/theme.css | 2 + 51 files changed, 3521 insertions(+), 77 deletions(-) create mode 100644 crates/ordo-platform/migrations/0017_sub_rule_assets.sql create mode 100644 crates/ordo-platform/migrations/0018_drop_sub_rule_versions.sql create mode 100644 crates/ordo-platform/src/models/sub_rules.rs create mode 100644 crates/ordo-platform/src/store/sub_rules.rs create mode 100644 crates/ordo-platform/src/sub_rules.rs create mode 100644 ordo-editor/apps/studio/src/views/project/SubRulesView.vue create mode 100644 ordo-editor/packages/vue/src/components/step/OrdoSubRuleEditor.vue create mode 100644 ordo-editor/packages/vue/src/components/step/subRuleAssets.ts diff --git a/crates/ordo-platform/migrations/0017_sub_rule_assets.sql b/crates/ordo-platform/migrations/0017_sub_rule_assets.sql new file mode 100644 index 00000000..d774c04d --- /dev/null +++ b/crates/ordo-platform/migrations/0017_sub_rule_assets.sql @@ -0,0 +1,53 @@ +CREATE TABLE sub_rule_assets ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK (scope IN ('org', 'project')), + name TEXT NOT NULL, + display_name TEXT, + description TEXT, + draft JSONB NOT NULL, + input_schema JSONB NOT NULL DEFAULT '[]', + output_schema JSONB NOT NULL DEFAULT '[]', + draft_seq BIGINT NOT NULL DEFAULT 1, + draft_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + draft_updated_by TEXT REFERENCES users(id) ON DELETE SET NULL, + published_version TEXT, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + CHECK ( + (scope = 'org' AND project_id IS NULL) + OR (scope = 'project' AND project_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX sub_rule_assets_org_unique + ON sub_rule_assets(org_id, name) + WHERE scope = 'org'; + +CREATE UNIQUE INDEX sub_rule_assets_project_unique + ON sub_rule_assets(project_id, name) + WHERE scope = 'project'; + +CREATE INDEX sub_rule_assets_org_lookup + ON sub_rule_assets(org_id, scope, name); + +CREATE INDEX sub_rule_assets_project_lookup + ON sub_rule_assets(project_id, name); + +CREATE TABLE sub_rule_versions ( + id TEXT PRIMARY KEY, + asset_id TEXT NOT NULL REFERENCES sub_rule_assets(id) ON DELETE CASCADE, + version TEXT NOT NULL, + snapshot JSONB NOT NULL, + input_schema JSONB NOT NULL DEFAULT '[]', + output_schema JSONB NOT NULL DEFAULT '[]', + release_note TEXT, + published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_by TEXT REFERENCES users(id) ON DELETE SET NULL, + UNIQUE (asset_id, version) +); + +CREATE INDEX sub_rule_versions_asset_lookup + ON sub_rule_versions(asset_id, published_at DESC); diff --git a/crates/ordo-platform/migrations/0018_drop_sub_rule_versions.sql b/crates/ordo-platform/migrations/0018_drop_sub_rule_versions.sql new file mode 100644 index 00000000..b8d0f35e --- /dev/null +++ b/crates/ordo-platform/migrations/0018_drop_sub_rule_versions.sql @@ -0,0 +1,7 @@ +-- Sub-rules no longer have a separate publish/version system. +-- They are snapshotted inline when the parent ruleset is published. +DROP TABLE IF EXISTS sub_rule_versions; + +ALTER TABLE sub_rule_assets + DROP COLUMN IF EXISTS published_version, + DROP COLUMN IF EXISTS published_at; diff --git a/crates/ordo-platform/src/lib.rs b/crates/ordo-platform/src/lib.rs index 1d8797f4..0ff53a32 100644 --- a/crates/ordo-platform/src/lib.rs +++ b/crates/ordo-platform/src/lib.rs @@ -29,6 +29,7 @@ pub mod ruleset_history; pub mod server_registry; pub mod store; pub mod sub_org_member; +pub mod sub_rules; pub mod sync; pub mod template; pub mod templates_api; diff --git a/crates/ordo-platform/src/main.rs b/crates/ordo-platform/src/main.rs index ffc7b3c6..4b46369f 100644 --- a/crates/ordo-platform/src/main.rs +++ b/crates/ordo-platform/src/main.rs @@ -19,7 +19,7 @@ use ordo_platform::{ connect_platform_store, contract, environment, github, i18n, init_tracing, member, middleware::require_auth, notification, org, project, proxy, publish_existing_tenants, release, ruleset_draft, ruleset_history, server_registry, start_server_registry_maintenance, - sub_org_member, templates_api, testing, + sub_org_member, sub_rules, templates_api, testing, }; #[tokio::main] @@ -318,6 +318,27 @@ async fn main() -> anyhow::Result<()> { "/api/v1/orgs/:oid/releases/pending-for-me", get(notification::list_pending_approvals_for_me), ) + // Managed SubRule assets + .route( + "/api/v1/orgs/:oid/sub-rules", + get(sub_rules::list_org_sub_rules), + ) + .route( + "/api/v1/orgs/:oid/sub-rules/:name", + get(sub_rules::get_org_sub_rule) + .put(sub_rules::save_org_sub_rule) + .delete(sub_rules::delete_org_sub_rule), + ) + .route( + "/api/v1/orgs/:oid/projects/:pid/sub-rules", + get(sub_rules::list_project_sub_rules), + ) + .route( + "/api/v1/orgs/:oid/projects/:pid/sub-rules/:name", + get(sub_rules::get_project_sub_rule) + .put(sub_rules::save_project_sub_rule) + .delete(sub_rules::delete_project_sub_rule), + ) // Draft rulesets .route( "/api/v1/orgs/:oid/projects/:pid/rulesets", diff --git a/crates/ordo-platform/src/models.rs b/crates/ordo-platform/src/models.rs index e86900e9..d89595ba 100644 --- a/crates/ordo-platform/src/models.rs +++ b/crates/ordo-platform/src/models.rs @@ -18,6 +18,8 @@ mod rbac; mod release; #[path = "models/servers.rs"] mod servers; +#[path = "models/sub_rules.rs"] +mod sub_rules; pub use auth::*; pub use catalog::*; @@ -28,3 +30,4 @@ pub use notifications::*; pub use rbac::*; pub use release::*; pub use servers::*; +pub use sub_rules::*; diff --git a/crates/ordo-platform/src/models/sub_rules.rs b/crates/ordo-platform/src/models/sub_rules.rs new file mode 100644 index 00000000..d7c8805f --- /dev/null +++ b/crates/ordo-platform/src/models/sub_rules.rs @@ -0,0 +1,74 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SubRuleScope { + Org, + Project, +} + +impl std::fmt::Display for SubRuleScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SubRuleScope::Org => write!(f, "org"), + SubRuleScope::Project => write!(f, "project"), + } + } +} + +impl std::str::FromStr for SubRuleScope { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "org" => Ok(SubRuleScope::Org), + "project" => Ok(SubRuleScope::Project), + other => Err(format!("invalid sub-rule scope: {}", other)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubRuleAssetMeta { + pub id: String, + pub org_id: String, + pub project_id: Option, + pub scope: SubRuleScope, + pub name: String, + pub display_name: Option, + pub description: Option, + pub draft_seq: i64, + pub draft_updated_at: DateTime, + pub draft_updated_by: Option, + pub created_at: DateTime, + pub created_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubRuleAsset { + #[serde(flatten)] + pub meta: SubRuleAssetMeta, + pub draft: JsonValue, + #[serde(default)] + pub input_schema: JsonValue, + #[serde(default)] + pub output_schema: JsonValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SaveSubRuleAssetRequest { + pub name: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub description: Option, + pub draft: JsonValue, + #[serde(default)] + pub input_schema: JsonValue, + #[serde(default)] + pub output_schema: JsonValue, + #[serde(default)] + pub expected_seq: Option, +} diff --git a/crates/ordo-platform/src/release.rs b/crates/ordo-platform/src/release.rs index 9a8ee9c6..2682efe8 100644 --- a/crates/ordo-platform/src/release.rs +++ b/crates/ordo-platform/src/release.rs @@ -17,6 +17,7 @@ use crate::{ PERM_RELEASE_REQUEST_CREATE, PERM_RELEASE_REQUEST_REJECT, PERM_RELEASE_REQUEST_VIEW, PERM_RELEASE_RESUME, PERM_RELEASE_ROLLBACK, }, + ruleset_draft::inline_sub_rules_into_draft, sync::SyncEvent, AppState, }; diff --git a/crates/ordo-platform/src/release/requests.rs b/crates/ordo-platform/src/release/requests.rs index 550b5246..4fb6a582 100644 --- a/crates/ordo-platform/src/release/requests.rs +++ b/crates/ordo-platform/src/release/requests.rs @@ -202,7 +202,9 @@ pub async fn create_release_request( ) .await? }; - let target_snapshot = draft.draft.clone(); + // Inline sub-rule assets before storing the snapshot so the snapshot is self-contained. + let target_snapshot = + inline_sub_rules_into_draft(&state, &org_id, &project_id, draft.draft.clone()).await?; let approver_users = { let mut items = Vec::new(); diff --git a/crates/ordo-platform/src/ruleset_draft.rs b/crates/ordo-platform/src/ruleset_draft.rs index 441cad94..ce2cffe3 100644 --- a/crates/ordo-platform/src/ruleset_draft.rs +++ b/crates/ordo-platform/src/ruleset_draft.rs @@ -339,9 +339,10 @@ pub async fn publish_draft( .await .map_err(PlatformError::Internal)?; - // Convert studio-format draft to engine format for NATS publish. - // ordo-server expects engine format (steps as HashMap, expression strings). - let engine_json = studio_draft_to_engine_json(&draft.draft)?; + // Inline referenced sub-rule assets, then convert studio → engine format. + let inlined = + inline_sub_rules_into_draft(&state, &org_id, &project_id, draft.draft.clone()).await?; + let engine_json = studio_draft_to_engine_json(&inlined)?; // Publish via NATS let publish_result = publish_via_nats( @@ -521,6 +522,7 @@ pub async fn redeploy( .await .map_err(PlatformError::Internal)?; + // Redeploy uses the stored snapshot (already contains inlined sub-rules). let snapshot_engine_json = studio_draft_to_engine_json(&original.snapshot)?; let result = publish_via_nats( @@ -557,6 +559,108 @@ pub async fn redeploy( // ── Internal helpers ────────────────────────────────────────────────────────── +/// Scan a studio-format ruleset draft, fetch all referenced sub-rule assets from +/// the platform store, and inline them as `subRules` entries. The result is a +/// self-contained studio JSON that `studio_draft_to_engine_json` can convert +/// without any missing sub-rule references. +/// +/// Sub-sub-rules are resolved iteratively up to MAX_SUBRULE_INLINE_DEPTH levels. +pub(crate) async fn inline_sub_rules_into_draft( + state: &AppState, + org_id: &str, + project_id: &str, + draft: serde_json::Value, +) -> ApiResult { + use crate::models::SubRuleScope; + use ordo_protocol::types::{ + ruleset::StudioSubRuleGraph, + step::{StudioStep, StudioStepKind}, + }; + + const MAX_DEPTH: usize = 8; + const MAX_REFS: usize = 64; + + let mut ruleset: StudioRuleSet = serde_json::from_value(draft).map_err(|e| { + PlatformError::bad_request(format!("Draft is not valid studio format: {}", e)) + })?; + + // Collect sub-rule refNames from a step list. + fn collect_refs(steps: &[StudioStep]) -> Vec { + steps + .iter() + .filter_map(|s| { + if let StudioStepKind::SubRule { ref_name, .. } = &s.kind { + Some(ref_name.clone()) + } else { + None + } + }) + .collect() + } + + let mut queue: Vec<(String, usize)> = collect_refs(&ruleset.steps) + .into_iter() + .map(|n| (n, 0)) + .collect(); + let mut visited = std::collections::HashSet::new(); + + while let Some((ref_name, depth)) = queue.pop() { + if depth >= MAX_DEPTH + || visited.contains(&ref_name) + || ruleset.sub_rules.contains_key(&ref_name) + { + continue; + } + if visited.len() >= MAX_REFS { + return Err(PlatformError::bad_request( + "Too many sub-rule references (max 64)", + )); + } + visited.insert(ref_name.clone()); + + // Try project scope first, then org scope. + let asset = state + .store + .get_sub_rule_asset(org_id, SubRuleScope::Project, Some(project_id), &ref_name) + .await + .map_err(PlatformError::Internal)?; + + let asset = match asset { + Some(a) => a, + None => state + .store + .get_sub_rule_asset(org_id, SubRuleScope::Org, None, &ref_name) + .await + .map_err(PlatformError::Internal)? + .ok_or_else(|| { + PlatformError::bad_request(format!("Sub-rule '{}' not found", ref_name)) + })?, + }; + + let sub: StudioRuleSet = serde_json::from_value(asset.draft).map_err(|e| { + PlatformError::bad_request(format!("Sub-rule '{}' has invalid draft: {}", ref_name, e)) + })?; + + // Enqueue nested references. + for nested in collect_refs(&sub.steps) { + queue.push((nested, depth + 1)); + } + + ruleset.sub_rules.insert( + ref_name, + StudioSubRuleGraph { + entry_step: sub.start_step_id, + steps: sub.steps, + input_schema: None, + output_schema: None, + }, + ); + } + + serde_json::to_value(&ruleset) + .map_err(|e| PlatformError::internal(format!("Serialization failed: {}", e))) +} + /// Convert a stored studio-format draft JSON to engine-format JSON for NATS publish. fn studio_draft_to_engine_json(draft: &serde_json::Value) -> ApiResult { let studio: StudioRuleSet = serde_json::from_value(draft.clone()).map_err(|e| { diff --git a/crates/ordo-platform/src/store.rs b/crates/ordo-platform/src/store.rs index c89a7376..e95c0d76 100644 --- a/crates/ordo-platform/src/store.rs +++ b/crates/ordo-platform/src/store.rs @@ -11,8 +11,9 @@ use crate::models::{ ReleasePolicyScope, ReleasePolicyTargetType, ReleaseRequest, ReleaseRequestHistoryEntry, ReleaseRequestSnapshot, ReleaseRequestStatus, ReleaseVersionDiff, Role, RollbackPolicy, RolloutStrategy, RulesetDeployment, RulesetHistoryEntry, RulesetHistorySource, ServerNode, - ServerStatus, TestCase, TestExpectation, UpdateEnvironmentRequest, UpdateReleasePolicyRequest, - UpdateRoleRequest, User, UserRoleAssignment, + ServerStatus, SubRuleAsset, SubRuleAssetMeta, SubRuleScope, TestCase, TestExpectation, + UpdateEnvironmentRequest, UpdateReleasePolicyRequest, UpdateRoleRequest, User, + UserRoleAssignment, }; use anyhow::Result; use serde_json::Value as JsonValue; @@ -32,6 +33,7 @@ mod releases; mod rows; mod rulesets; mod servers; +mod sub_rules; mod users; use self::codec::*; diff --git a/crates/ordo-platform/src/store/sub_rules.rs b/crates/ordo-platform/src/store/sub_rules.rs new file mode 100644 index 00000000..56216310 --- /dev/null +++ b/crates/ordo-platform/src/store/sub_rules.rs @@ -0,0 +1,231 @@ +use super::*; +use std::str::FromStr; + +impl PlatformStore { + pub async fn list_project_sub_rules( + &self, + org_id: &str, + project_id: &str, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, org_id, project_id, scope, name, display_name, description, + draft_seq, draft_updated_at, draft_updated_by, + created_at, created_by + FROM sub_rule_assets + WHERE org_id = $1 AND (scope = 'org' OR project_id = $2) + ORDER BY scope, name", + ) + .bind(org_id) + .bind(project_id) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(row_to_sub_rule_meta).collect() + } + + pub async fn list_org_sub_rules(&self, org_id: &str) -> Result> { + let rows = sqlx::query( + "SELECT id, org_id, project_id, scope, name, display_name, description, + draft_seq, draft_updated_at, draft_updated_by, + created_at, created_by + FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'org' + ORDER BY name", + ) + .bind(org_id) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(row_to_sub_rule_meta).collect() + } + + pub async fn get_sub_rule_asset( + &self, + org_id: &str, + scope: SubRuleScope, + project_id: Option<&str>, + name: &str, + ) -> Result> { + let row = match scope { + SubRuleScope::Org => { + sqlx::query( + "SELECT id, org_id, project_id, scope, name, display_name, description, + draft, input_schema, output_schema, + draft_seq, draft_updated_at, draft_updated_by, + created_at, created_by + FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'org' AND name = $2", + ) + .bind(org_id) + .bind(name) + .fetch_optional(&self.pool) + .await? + } + SubRuleScope::Project => { + let project_id = project_id.ok_or_else(|| { + anyhow::anyhow!("project_id is required for project-scoped sub-rule") + })?; + sqlx::query( + "SELECT id, org_id, project_id, scope, name, display_name, description, + draft, input_schema, output_schema, + draft_seq, draft_updated_at, draft_updated_by, + created_at, created_by + FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'project' AND project_id = $2 AND name = $3", + ) + .bind(org_id) + .bind(project_id) + .bind(name) + .fetch_optional(&self.pool) + .await? + } + }; + + row.map(row_to_sub_rule_asset).transpose() + } + + #[allow(clippy::too_many_arguments)] + pub async fn upsert_sub_rule_asset( + &self, + id: &str, + org_id: &str, + project_id: Option<&str>, + scope: SubRuleScope, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + draft: &JsonValue, + input_schema: &JsonValue, + output_schema: &JsonValue, + expected_seq: Option, + user_id: &str, + ) -> Result { + let existing = self + .get_sub_rule_asset(org_id, scope.clone(), project_id, name) + .await?; + + if let Some(existing) = existing { + if let Some(expected_seq) = expected_seq { + if existing.meta.draft_seq != expected_seq { + return Err(anyhow::anyhow!("conflict")); + } + } + + sqlx::query( + "UPDATE sub_rule_assets SET + display_name = $1, + description = $2, + draft = $3, + input_schema = $4, + output_schema = $5, + draft_seq = draft_seq + 1, + draft_updated_at = NOW(), + draft_updated_by = $6 + WHERE id = $7", + ) + .bind(display_name) + .bind(description) + .bind(sqlx::types::Json(draft)) + .bind(sqlx::types::Json(input_schema)) + .bind(sqlx::types::Json(output_schema)) + .bind(user_id) + .bind(&existing.meta.id) + .execute(&self.pool) + .await?; + } else { + sqlx::query( + "INSERT INTO sub_rule_assets ( + id, org_id, project_id, scope, name, display_name, description, + draft, input_schema, output_schema, draft_seq, + draft_updated_at, draft_updated_by, created_at, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1, NOW(), $11, NOW(), $11)", + ) + .bind(id) + .bind(org_id) + .bind(project_id) + .bind(scope.to_string()) + .bind(name) + .bind(display_name) + .bind(description) + .bind(sqlx::types::Json(draft)) + .bind(sqlx::types::Json(input_schema)) + .bind(sqlx::types::Json(output_schema)) + .bind(user_id) + .execute(&self.pool) + .await?; + } + + self.get_sub_rule_asset(org_id, scope, project_id, name) + .await? + .ok_or_else(|| anyhow::anyhow!("sub-rule asset was not found after upsert")) + } + + pub async fn delete_sub_rule_asset( + &self, + org_id: &str, + scope: SubRuleScope, + project_id: Option<&str>, + name: &str, + ) -> Result { + let result = match scope { + SubRuleScope::Org => { + sqlx::query( + "DELETE FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'org' AND name = $2", + ) + .bind(org_id) + .bind(name) + .execute(&self.pool) + .await? + } + SubRuleScope::Project => { + let project_id = project_id.ok_or_else(|| { + anyhow::anyhow!("project_id is required for project-scoped sub-rule") + })?; + sqlx::query( + "DELETE FROM sub_rule_assets + WHERE org_id = $1 AND scope = 'project' AND project_id = $2 AND name = $3", + ) + .bind(org_id) + .bind(project_id) + .bind(name) + .execute(&self.pool) + .await? + } + }; + + Ok(result.rows_affected() > 0) + } +} + +fn row_to_sub_rule_meta(row: sqlx::postgres::PgRow) -> Result { + let scope: String = row.get("scope"); + Ok(SubRuleAssetMeta { + id: row.get("id"), + org_id: row.get("org_id"), + project_id: row.get("project_id"), + scope: SubRuleScope::from_str(&scope).map_err(anyhow::Error::msg)?, + name: row.get("name"), + display_name: row.get("display_name"), + description: row.get("description"), + draft_seq: row.get("draft_seq"), + draft_updated_at: row.get("draft_updated_at"), + draft_updated_by: row.get("draft_updated_by"), + created_at: row.get("created_at"), + created_by: row.get("created_by"), + }) +} + +fn row_to_sub_rule_asset(row: sqlx::postgres::PgRow) -> Result { + let draft: sqlx::types::Json = row.get("draft"); + let input_schema: sqlx::types::Json = row.get("input_schema"); + let output_schema: sqlx::types::Json = row.get("output_schema"); + + Ok(SubRuleAsset { + meta: row_to_sub_rule_meta(row)?, + draft: draft.0, + input_schema: input_schema.0, + output_schema: output_schema.0, + }) +} diff --git a/crates/ordo-platform/src/sub_rules.rs b/crates/ordo-platform/src/sub_rules.rs new file mode 100644 index 00000000..17935f67 --- /dev/null +++ b/crates/ordo-platform/src/sub_rules.rs @@ -0,0 +1,217 @@ +//! Managed SubRule asset API. +//! +//! Sub-rules are standalone decision graphs referenced by parent rulesets. +//! Their content is snapshotted inline when the parent ruleset is published — +//! no separate sub-rule publish/version flow is needed. + +use crate::{ + error::{ApiResult, PlatformError}, + models::{Claims, SaveSubRuleAssetRequest, SubRuleAsset, SubRuleAssetMeta, SubRuleScope}, + rbac::{require_permission, require_project_permission, PERM_RULESET_EDIT, PERM_RULESET_VIEW}, + AppState, +}; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Extension, Json, +}; +use uuid::Uuid; + +#[derive(Debug, serde::Deserialize)] +pub struct ListProjectSubRulesQuery { + #[serde(default = "default_include_org")] + pub include_org: bool, +} + +fn default_include_org() -> bool { + true +} + +/// GET /api/v1/orgs/:oid/sub-rules +pub async fn list_org_sub_rules( + State(state): State, + Extension(claims): Extension, + Path(org_id): Path, +) -> ApiResult>> { + require_permission(&state, &org_id, &claims.sub, PERM_RULESET_VIEW).await?; + let assets = state + .store + .list_org_sub_rules(&org_id) + .await + .map_err(PlatformError::Internal)?; + Ok(Json(assets)) +} + +/// PUT /api/v1/orgs/:oid/sub-rules/:name +pub async fn save_org_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, name)): Path<(String, String)>, + Json(req): Json, +) -> ApiResult> { + require_permission(&state, &org_id, &claims.sub, PERM_RULESET_EDIT).await?; + let name = normalize_name(&name, &req.name)?; + let asset = state + .store + .upsert_sub_rule_asset( + &Uuid::new_v4().to_string(), + &org_id, + None, + SubRuleScope::Org, + &name, + req.display_name.as_deref(), + req.description.as_deref(), + &req.draft, + &req.input_schema, + &req.output_schema, + req.expected_seq, + &claims.sub, + ) + .await + .map_err(map_conflict)?; + Ok(Json(asset)) +} + +/// GET /api/v1/orgs/:oid/sub-rules/:name +pub async fn get_org_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, name)): Path<(String, String)>, +) -> ApiResult> { + require_permission(&state, &org_id, &claims.sub, PERM_RULESET_VIEW).await?; + let asset = state + .store + .get_sub_rule_asset(&org_id, SubRuleScope::Org, None, &name) + .await + .map_err(PlatformError::Internal)? + .ok_or_else(|| PlatformError::not_found("SubRule not found"))?; + Ok(Json(asset)) +} + +/// DELETE /api/v1/orgs/:oid/sub-rules/:name +pub async fn delete_org_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, name)): Path<(String, String)>, +) -> ApiResult { + require_permission(&state, &org_id, &claims.sub, PERM_RULESET_EDIT).await?; + let deleted = state + .store + .delete_sub_rule_asset(&org_id, SubRuleScope::Org, None, &name) + .await + .map_err(PlatformError::Internal)?; + if !deleted { + return Err(PlatformError::not_found("SubRule not found")); + } + Ok(StatusCode::NO_CONTENT) +} + +/// GET /api/v1/orgs/:oid/projects/:pid/sub-rules +pub async fn list_project_sub_rules( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id)): Path<(String, String)>, + Query(q): Query, +) -> ApiResult>> { + require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_VIEW) + .await?; + let mut assets = state + .store + .list_project_sub_rules(&org_id, &project_id) + .await + .map_err(PlatformError::Internal)?; + if !q.include_org { + assets.retain(|a| a.scope == SubRuleScope::Project); + } + Ok(Json(assets)) +} + +/// PUT /api/v1/orgs/:oid/projects/:pid/sub-rules/:name +pub async fn save_project_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id, name)): Path<(String, String, String)>, + Json(req): Json, +) -> ApiResult> { + require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_EDIT) + .await?; + let name = normalize_name(&name, &req.name)?; + let asset = state + .store + .upsert_sub_rule_asset( + &Uuid::new_v4().to_string(), + &org_id, + Some(&project_id), + SubRuleScope::Project, + &name, + req.display_name.as_deref(), + req.description.as_deref(), + &req.draft, + &req.input_schema, + &req.output_schema, + req.expected_seq, + &claims.sub, + ) + .await + .map_err(map_conflict)?; + Ok(Json(asset)) +} + +/// GET /api/v1/orgs/:oid/projects/:pid/sub-rules/:name +pub async fn get_project_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id, name)): Path<(String, String, String)>, +) -> ApiResult> { + require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_VIEW) + .await?; + let asset = state + .store + .get_sub_rule_asset(&org_id, SubRuleScope::Project, Some(&project_id), &name) + .await + .map_err(PlatformError::Internal)? + .ok_or_else(|| PlatformError::not_found("SubRule not found"))?; + Ok(Json(asset)) +} + +/// DELETE /api/v1/orgs/:oid/projects/:pid/sub-rules/:name +pub async fn delete_project_sub_rule( + State(state): State, + Extension(claims): Extension, + Path((org_id, project_id, name)): Path<(String, String, String)>, +) -> ApiResult { + require_project_permission(&state, &org_id, &project_id, &claims.sub, PERM_RULESET_EDIT) + .await?; + let deleted = state + .store + .delete_sub_rule_asset(&org_id, SubRuleScope::Project, Some(&project_id), &name) + .await + .map_err(PlatformError::Internal)?; + if !deleted { + return Err(PlatformError::not_found("SubRule not found")); + } + Ok(StatusCode::NO_CONTENT) +} + +fn normalize_name(path_name: &str, body_name: &str) -> ApiResult { + let path_name = path_name.trim(); + let body_name = body_name.trim(); + if path_name.is_empty() || body_name.is_empty() { + return Err(PlatformError::bad_request("SubRule name is required")); + } + if path_name != body_name { + return Err(PlatformError::bad_request( + "SubRule path name and body name must match", + )); + } + Ok(path_name.to_string()) +} + +fn map_conflict(err: anyhow::Error) -> PlatformError { + let message = err.to_string(); + if message == "conflict" { + PlatformError::conflict("SubRule draft has changed") + } else { + PlatformError::Internal(err) + } +} diff --git a/crates/ordo-protocol/src/types/ruleset.rs b/crates/ordo-protocol/src/types/ruleset.rs index f21f9110..4931d718 100644 --- a/crates/ordo-protocol/src/types/ruleset.rs +++ b/crates/ordo-protocol/src/types/ruleset.rs @@ -53,4 +53,8 @@ pub struct StudioConfig { pub struct StudioSubRuleGraph { pub entry_step: String, pub steps: Vec, + #[serde(default)] + pub input_schema: Option, + #[serde(default)] + pub output_schema: Option, } diff --git a/deploy/nomad/devcontainer-entrypoint.sh b/deploy/nomad/devcontainer-entrypoint.sh index edd1bd83..a3b15684 100755 --- a/deploy/nomad/devcontainer-entrypoint.sh +++ b/deploy/nomad/devcontainer-entrypoint.sh @@ -144,7 +144,7 @@ start_platform_watch() { -w crates \ -w Cargo.toml \ -w Cargo.lock \ - -x "run -p ordo-platform -- --addr 0.0.0.0:${PLATFORM_PORT} --database-url ${ORDO_DATABASE_URL} --engine-url ${ORDO_ENGINE_URL} --jwt-secret ${ORDO_JWT_SECRET} --templates-dir ${ORDO_PLATFORM_TEMPLATES_DIR}" + -x "run -p ordo-platform --bin ordo-platform -- --addr 0.0.0.0:${PLATFORM_PORT} --database-url ${ORDO_DATABASE_URL} --engine-url ${ORDO_ENGINE_URL} --jwt-secret ${ORDO_JWT_SECRET} --templates-dir ${ORDO_PLATFORM_TEMPLATES_DIR}" ) 2>&1 | tee "$LOG_DIR/ordo-platform.log" & PLATFORM_PID=$! } diff --git a/ordo-editor/apps/docs/.vitepress/config.mts b/ordo-editor/apps/docs/.vitepress/config.mts index f39eed1d..e3cecc82 100644 --- a/ordo-editor/apps/docs/.vitepress/config.mts +++ b/ordo-editor/apps/docs/.vitepress/config.mts @@ -51,7 +51,8 @@ export default withMermaid(defineConfig({ link: '/en/', themeConfig: { nav: [ - { text: 'Guide', link: '/en/guide/getting-started' }, + { text: 'Platform', link: '/en/platform/overview' }, + { text: 'Engine', link: '/en/guide/what-is-ordo' }, { text: 'API', link: '/en/api/http-api' }, { text: 'Reference', link: '/en/reference/cli' }, { text: 'Roadmap', link: '/en/roadmap' }, @@ -62,6 +63,39 @@ export default withMermaid(defineConfig({ }, ], sidebar: { + '/en/platform/': [ + { + text: 'Platform', + items: [ + { text: 'Overview', link: '/en/platform/overview' }, + { text: 'Organizations & Projects', link: '/en/platform/organizations' }, + { text: 'Studio Editor', link: '/en/platform/studio' }, + ] + }, + { + text: 'Modeling', + items: [ + { text: 'Fact Catalog', link: '/en/platform/catalog' }, + { text: 'Decision Contracts', link: '/en/platform/contracts' }, + { text: 'Sub-Rule Assets', link: '/en/platform/sub-rules' }, + ] + }, + { + text: 'Delivery', + items: [ + { text: 'Rule Drafts', link: '/en/platform/drafts' }, + { text: 'Release Pipeline', link: '/en/platform/releases' }, + { text: 'Test Management', link: '/en/platform/testing' }, + ] + }, + { + text: 'Operations', + items: [ + { text: 'Server Registry', link: '/en/platform/server-registry' }, + { text: 'GitHub Integration', link: '/en/platform/github' }, + ] + } + ], '/en/guide/': [ { text: 'Introduction', @@ -146,7 +180,8 @@ export default withMermaid(defineConfig({ description: "高性能规则引擎与可视化编辑器", themeConfig: { nav: [ - { text: '指南', link: '/zh/guide/getting-started' }, + { text: '平台', link: '/zh/platform/overview' }, + { text: '引擎', link: '/zh/guide/what-is-ordo' }, { text: 'API', link: '/zh/api/http-api' }, { text: '参考', link: '/zh/reference/cli' }, { text: '路线图', link: '/zh/roadmap' }, @@ -157,6 +192,39 @@ export default withMermaid(defineConfig({ }, ], sidebar: { + '/zh/platform/': [ + { + text: '平台', + items: [ + { text: '概览', link: '/zh/platform/overview' }, + { text: '组织与项目', link: '/zh/platform/organizations' }, + { text: 'Studio 编辑器', link: '/zh/platform/studio' }, + ] + }, + { + text: '建模', + items: [ + { text: '事实目录', link: '/zh/platform/catalog' }, + { text: '决策契约', link: '/zh/platform/contracts' }, + { text: '子规则资产', link: '/zh/platform/sub-rules' }, + ] + }, + { + text: '交付', + items: [ + { text: '规则草稿', link: '/zh/platform/drafts' }, + { text: '发布流程', link: '/zh/platform/releases' }, + { text: '测试管理', link: '/zh/platform/testing' }, + ] + }, + { + text: '运维', + items: [ + { text: '服务器注册', link: '/zh/platform/server-registry' }, + { text: 'GitHub 集成', link: '/zh/platform/github' }, + ] + } + ], '/zh/guide/': [ { text: '介绍', diff --git a/ordo-editor/apps/docs/en/index.md b/ordo-editor/apps/docs/en/index.md index 921f1421..047df284 100644 --- a/ordo-editor/apps/docs/en/index.md +++ b/ordo-editor/apps/docs/en/index.md @@ -4,7 +4,7 @@ layout: home hero: name: 'Ordo' text: 'Open-Source Decision Platform' - tagline: Author, test, and govern business rules — with Studio, platform governance, and a fast engine under the hood. + tagline: A unified decision infrastructure — Studio for authoring, Platform for governance, Engine for execution. Three layers, clean separation of concerns. image: src: /logo.png alt: Ordo @@ -13,33 +13,65 @@ hero: text: Get Started link: /en/guide/getting-started - theme: alt - text: Try Playground - link: https://ordo-engine.github.io/Ordo/ + text: Platform Docs + link: /en/platform/overview - theme: alt - text: View on GitHub + text: Engine Docs + link: /en/guide/what-is-ordo + - theme: alt + text: GitHub link: https://github.com/Ordo-Engine/Ordo features: - - icon: 🏛️ - title: Decision Platform - details: Org & project management, fact catalog, typed decision contracts, and full version history. Own your decision logic — don't scatter it across codebases and spreadsheets. - - icon: 🎨 - title: Studio - details: Drag-and-drop flow editor, decision tables, one-click template instantiation, and test case management. Author rules without friction. - - icon: 🧪 - title: Test Management - details: Create, run, and export test suites per ruleset. CI-compatible YAML. Know your rules work before they ship. - - icon: ⚡ - title: Fast Engine - details: Sub-microsecond execution with Cranelift JIT. Runs as HTTP · gRPC · WASM · CLI or embedded in any Rust application. - - icon: 🛡️ - title: Governance - details: Typed input/output contracts, audit logging, Ed25519 rule signing, and rollback. Traceable and compliant by default. - - icon: 🔌 - title: Runs Everywhere - details: Single binary server, browser via WebAssembly, embedded in Rust apps. One engine across every deployment target. + - title: Decision Platform + details: Organizations, projects, members & RBAC, fact catalog, concept registry, typed contracts, approval & release pipelines, multi-environment rollouts and rollback — built for team-scale decision governance. + link: /en/platform/overview + linkText: Platform overview + - title: Studio Editor + details: Three authoring modes (flow / form / JSON), decision tables, sub-rules, template instantiation, test suite management, and execution trace panels. + link: /en/platform/studio + linkText: Studio guide + - title: Releases & Environments + details: Draft → review → release → canary → rollback. Configurable approval policies, change diffs, per-environment delivery, every action recorded in the audit log. + link: /en/platform/releases + linkText: Release pipeline + - title: High-Performance Engine + details: Sub-microsecond rule execution. Bytecode VM plus Cranelift JIT, expression optimizer. Reach it over HTTP, gRPC, Unix Socket, or WASM. + link: /en/guide/execution-model + linkText: Execution model + - title: Types & Contracts + details: Project-scoped fact catalog, reusable concepts, typed input/output contracts. Studio and CLI consume the same contract definitions. + link: /en/platform/catalog + linkText: Facts & contracts + - title: Multi-Region Deployment + details: Central platform governance plus regional engine clusters. Server registry, health checks, per-project execution proxy. Single-binary or containerized deployment. + link: /en/platform/server-registry + linkText: Server registry --- +## Architecture + +```mermaid +flowchart TB + Studio["Studio (browser)"] + CLI["ordo-cli"] + SDK["SDK / business app"] + Platform["ordo-platform
governance · drafts · review · release"] + Server["ordo-server cluster
HTTP · gRPC · UDS"] + Core["ordo-core engine
VM + JIT + sub-rules + trace"] + + Studio --> Platform + CLI --> Platform + SDK --> Server + Platform -- "release events (NATS / direct sync)" --> Server + Server --> Core +``` + +The documentation is organized into two tracks: + +- **Platform** — for teams using Ordo Platform / Studio to govern decisions: organization modeling, contracts, release flow, test management. +- **Engine** — for developers integrating ordo-core / ordo-server directly: rule structure, expression syntax, HTTP / gRPC / WASM APIs. + ## Quick Example ```json diff --git a/ordo-editor/apps/docs/zh/index.md b/ordo-editor/apps/docs/zh/index.md index ec74f180..8edbbc80 100644 --- a/ordo-editor/apps/docs/zh/index.md +++ b/ordo-editor/apps/docs/zh/index.md @@ -4,7 +4,7 @@ layout: home hero: name: 'Ordo' text: '开源决策平台' - tagline: 编写、测试、治理业务规则 — Studio 可视化编辑、平台级治理,底层引擎快到感觉不到。 + tagline: 由治理平台与高性能引擎组成的一体化决策基础设施。Studio 编排、平台审计、引擎执行——三层职责清晰分离。 image: src: /logo.png alt: Ordo @@ -13,33 +13,65 @@ hero: text: 开始使用 link: /zh/guide/getting-started - theme: alt - text: 尝试演练场 - link: https://ordo-engine.github.io/Ordo/ + text: 平台篇 + link: /zh/platform/overview + - theme: alt + text: 引擎篇 + link: /zh/guide/what-is-ordo - theme: alt text: GitHub link: https://github.com/Ordo-Engine/Ordo features: - - icon: 🏛️ - title: 决策平台 - details: 组织与项目管理、事实目录、带类型的决策契约,以及完整的版本历史。让团队真正拥有自己的决策逻辑,而不是散落在代码库和电子表格里。 - - icon: 🎨 - title: Studio - details: 拖拽式流程编辑器、决策表、一键实例化模板,以及测试用例管理。低摩擦地编写规则。 - - icon: 🧪 - title: 测试管理 - details: 为每个规则集创建、运行、导出测试套件。兼容 ordo-cli 的 YAML 格式,直接接入 CI/CD。上线前确保规则正确。 - - icon: ⚡ - title: 高性能引擎 - details: 亚微秒级执行,Cranelift JIT 编译。支持 HTTP · gRPC · WASM · CLI,或嵌入任意 Rust 应用。 - - icon: 🛡️ - title: 治理 - details: 带类型的输入/输出契约、审计日志、Ed25519 规则签名与一键回滚。默认可追溯、合规。 - - icon: 🔌 - title: 随处运行 - details: 单二进制服务器、浏览器端 WebAssembly、嵌入式 Rust 集成。同一个引擎覆盖所有部署场景。 + - title: 决策平台 + details: 组织 / 项目 / 成员与角色(RBAC)、事实目录、概念注册、决策契约、审批与发布流水线、多环境与回滚——为团队级决策治理而生。 + link: /zh/platform/overview + linkText: 查看平台文档 + - title: Studio 编辑器 + details: 三种编辑模式(流程图 / 表单 / JSON)、决策表、子规则、模板实例化、测试套件管理与执行追踪面板。 + linkText: 查看 Studio + link: /zh/platform/studio + - title: 发布与环境治理 + details: 草稿 → 审批 → 发布 → 灰度 → 回滚。可配置的审批策略、变更对比、按环境分别下发,所有动作进入审计日志。 + link: /zh/platform/releases + linkText: 发布流程 + - title: 高性能引擎 + details: 亚微秒级规则执行,字节码 VM + Cranelift JIT、表达式优化器。HTTP / gRPC / Unix Socket / WASM 多协议接入。 + link: /zh/guide/execution-model + linkText: 执行模型 + - title: 类型与契约 + details: 项目级事实目录、可复用概念、带类型的输入/输出契约。Studio 与 CLI 共用同一份契约定义。 + link: /zh/platform/catalog + linkText: 事实与契约 + - title: 多区域部署 + details: 平台中央治理 + 区域化引擎集群。服务器注册、健康检查、按项目路由的执行代理,支持单 binary 与容器化部署。 + link: /zh/platform/server-registry + linkText: 服务器注册 --- +## 架构概览 + +```mermaid +flowchart TB + Studio["Studio (浏览器)"] + CLI["ordo-cli"] + SDK["SDK / 业务系统"] + Platform["ordo-platform
治理 · 草稿 · 审批 · 发布"] + Server["ordo-server 集群
HTTP · gRPC · UDS"] + Core["ordo-core 引擎
VM + JIT + 子规则 + 追踪"] + + Studio --> Platform + CLI --> Platform + SDK --> Server + Platform -- "发布事件 (NATS / 直接同步)" --> Server + Server --> Core +``` + +Ordo 的文档分为两大部分: + +- **平台篇**——面向使用 Ordo Platform / Studio 治理决策的团队:组织建模、契约、发布流程、测试管理。 +- **引擎篇**——面向需要直接集成 ordo-core / ordo-server 的开发者:规则结构、表达式语法、HTTP / gRPC / WASM API。 + ## 快速示例 ```json diff --git a/ordo-editor/apps/studio/src/api/platform-client.ts b/ordo-editor/apps/studio/src/api/platform-client.ts index a7963969..c3ecc4a6 100644 --- a/ordo-editor/apps/studio/src/api/platform-client.ts +++ b/ordo-editor/apps/studio/src/api/platform-client.ts @@ -48,6 +48,10 @@ import type { SaveDraftRequest, ServerInfo, SetCanaryRequest, + SaveSubRuleAssetRequest, + SubRuleAsset, + SubRuleAssetMeta, + SubRuleScope, TemplateDetail, TemplateMetadata, TestCase, @@ -734,6 +738,75 @@ export const rulesetDraftApi = { }, }; +// ── Managed SubRule Assets ─────────────────────────────────────────────────── + +export const subRuleApi = { + listProject( + token: string, + orgId: string, + projectId: string, + includeOrg = true + ): Promise { + const qs = includeOrg ? '' : '?include_org=false'; + return request(`/orgs/${orgId}/projects/${projectId}/sub-rules${qs}`, { token }); + }, + + listOrg(token: string, orgId: string): Promise { + return request(`/orgs/${orgId}/sub-rules`, { token }); + }, + + getProject(token: string, orgId: string, projectId: string, name: string): Promise { + return request(`/orgs/${orgId}/projects/${projectId}/sub-rules/${encodeURIComponent(name)}`, { + token, + }); + }, + + getOrg(token: string, orgId: string, name: string): Promise { + return request(`/orgs/${orgId}/sub-rules/${encodeURIComponent(name)}`, { token }); + }, + + saveProject( + token: string, + orgId: string, + projectId: string, + name: string, + req: SaveSubRuleAssetRequest + ): Promise { + return request(`/orgs/${orgId}/projects/${projectId}/sub-rules/${encodeURIComponent(name)}`, { + method: 'PUT', + token, + body: JSON.stringify(req), + }); + }, + + saveOrg( + token: string, + orgId: string, + name: string, + req: SaveSubRuleAssetRequest + ): Promise { + return request(`/orgs/${orgId}/sub-rules/${encodeURIComponent(name)}`, { + method: 'PUT', + token, + body: JSON.stringify(req), + }); + }, + + deleteProject(token: string, orgId: string, projectId: string, name: string): Promise { + return request(`/orgs/${orgId}/projects/${projectId}/sub-rules/${encodeURIComponent(name)}`, { + method: 'DELETE', + token, + }); + }, + + deleteOrg(token: string, orgId: string, name: string): Promise { + return request(`/orgs/${orgId}/sub-rules/${encodeURIComponent(name)}`, { + method: 'DELETE', + token, + }); + }, +}; + // ── RBAC ────────────────────────────────────────────────────────────────────── export const roleApi = { diff --git a/ordo-editor/apps/studio/src/api/types.ts b/ordo-editor/apps/studio/src/api/types.ts index 9430de39..a5d3369f 100644 --- a/ordo-editor/apps/studio/src/api/types.ts +++ b/ordo-editor/apps/studio/src/api/types.ts @@ -467,6 +467,41 @@ export interface DraftConflictResponse { server_seq: number; } +// ── Managed SubRule Assets ─────────────────────────────────────────────────── + +export type SubRuleScope = 'org' | 'project'; + +export interface SubRuleAssetMeta { + id: string; + org_id: string; + project_id: string | null; + scope: SubRuleScope; + name: string; + display_name: string | null; + description: string | null; + draft_seq: number; + draft_updated_at: string; + draft_updated_by: string | null; + created_at: string; + created_by: string | null; +} + +export interface SubRuleAsset extends SubRuleAssetMeta { + draft: RuleSet; + input_schema: unknown[]; + output_schema: unknown[]; +} + +export interface SaveSubRuleAssetRequest { + name: string; + display_name?: string | null; + description?: string | null; + draft: RuleSet; + input_schema?: unknown[]; + output_schema?: unknown[]; + expected_seq?: number; +} + // ── Deployments ─────────────────────────────────────────────────────────────── export type DeploymentStatus = 'queued' | 'success' | 'failed'; diff --git a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue index 9398f9df..68419188 100644 --- a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue +++ b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue @@ -152,6 +152,8 @@ const pageInfo = computed(() => { return { title: t('projectNav.concepts'), subtitle: t('concepts.desc') }; case 'contracts': return { title: t('projectNav.contracts'), subtitle: t('contracts.desc') }; + case 'project-sub-rules': + return { title: t('projectNav.subRules'), subtitle: t('subRules.desc') }; case 'tests': return { title: t('projectNav.tests'), subtitle: t('shell.testsSubtitle') }; case 'versions': diff --git a/ordo-editor/apps/studio/src/components/layout/ProjectLayout.vue b/ordo-editor/apps/studio/src/components/layout/ProjectLayout.vue index c4f1b591..71905021 100644 --- a/ordo-editor/apps/studio/src/components/layout/ProjectLayout.vue +++ b/ordo-editor/apps/studio/src/components/layout/ProjectLayout.vue @@ -45,6 +45,13 @@ const tabs = computed(() => [ to: `${base.value}/contracts`, active: route.path.endsWith('/contracts'), }, + { + value: 'sub-rules', + label: t('projectNav.subRules'), + icon: 'git-branch', + to: `${base.value}/sub-rules`, + active: route.path.endsWith('/sub-rules'), + }, { value: 'tests', label: t('projectNav.tests'), diff --git a/ordo-editor/apps/studio/src/i18n/locales/en.ts b/ordo-editor/apps/studio/src/i18n/locales/en.ts index fe05c4d6..982029c5 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/en.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/en.ts @@ -276,6 +276,7 @@ export default { facts: 'Facts', concepts: 'Concepts', contracts: 'Contracts', + subRules: 'SubRules', tests: 'Tests', versions: 'Versions', trace: 'Trace', @@ -504,6 +505,49 @@ export default { fieldName: 'Field Name', fieldDesc: 'Description', }, + subRules: { + title: 'SubRule Assets', + desc: 'Manage reusable atomic decision graphs referenced by SubRule nodes in ruleset flows.', + createTitle: 'Create SubRule', + createDesc: + 'A SubRule has its own flow graph, decision table, input contract, and output contract. Rulesets only reference managed SubRule assets.', + create: 'Create', + empty: 'No SubRule assets yet', + placeholder: 'Select a SubRule or create a new managed asset', + searchPlaceholder: 'Search by name, description, or scope', + name: 'Name', + namePlaceholder: 'e.g. risk_score', + nameRequired: 'Enter a SubRule name', + displayName: 'Display Name', + displayNamePlaceholder: 'e.g. Risk Score', + description: 'Description', + descriptionPlaceholder: 'Describe the atomic capability this SubRule provides', + assetScope: 'Asset Scope', + scopeProject: 'Project', + scopeOrg: 'Organization', + published: 'Published', + publishedAt: 'Published', + projectAssets: 'Project SubRules', + orgAssets: 'Organization SubRules', + noProjectAssets: 'No project SubRules yet.', + noOrgAssets: 'No organization SubRules yet.', + noDescription: 'No description', + status: 'Status', + unpublished: 'Unpublished', + updatedAt: 'Updated', + loadFailed: 'Failed to load SubRules', + saveFailed: 'Failed to save SubRule', + createSuccess: 'SubRule created', + usageTitle: 'Flow reference', + usageDesc: + 'SubRule nodes store this asset reference. Releases resolve it into a fixed version snapshot.', + defaultTerminalName: 'Return result', + defaultDescription: 'Reusable SubRule decision graph.', + openInEditor: 'Open in Editor', + focusTitle: 'Editing SubRule: {name}', + focusDesc: 'This is a focused reusable graph. Save it before returning to the parent ruleset.', + returnParent: 'Back to parent', + }, versions: { title: 'Version History', desc: 'View ruleset change history and roll back to any previous version (admin+ required)', diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts index 7dc7e311..65082490 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts @@ -270,6 +270,7 @@ export default { facts: '事实目录', concepts: '概念注册', contracts: '决策契约', + subRules: '子规则', tests: '测试', versions: '版本历史', trace: '执行追踪', @@ -496,6 +497,48 @@ export default { fieldName: '字段名', fieldDesc: '描述', }, + subRules: { + title: '子规则资产', + desc: '管理可复用的原子决策图,供规则流程中的子规则节点引用。', + createTitle: '创建子规则', + createDesc: + '子规则拥有自己的节点图、决策表、输入契约和输出契约。规则集只引用已管理的子规则资产。', + create: '创建', + empty: '暂无子规则资产', + placeholder: '选择一个子规则,或创建一个新的子规则资产', + searchPlaceholder: '按名称、描述或范围搜索', + name: '名称', + namePlaceholder: '例如 risk_score', + nameRequired: '请输入子规则名称', + displayName: '显示名称', + displayNamePlaceholder: '例如 风险评分', + description: '描述', + descriptionPlaceholder: '说明这个子规则提供的原子能力', + assetScope: '资产范围', + scopeProject: '项目级', + scopeOrg: '组织级', + published: '已发布', + publishedAt: '发布时间', + projectAssets: '项目级子规则', + orgAssets: '组织级子规则', + noProjectAssets: '暂无项目级子规则。', + noOrgAssets: '暂无组织级子规则。', + noDescription: '暂无描述', + status: '状态', + unpublished: '未发布', + updatedAt: '更新时间', + loadFailed: '加载子规则失败', + saveFailed: '保存子规则失败', + createSuccess: '子规则已创建', + usageTitle: '流程引用', + usageDesc: '规则流程中的子规则节点会保存这个资产引用;发布时会解析为确定版本快照。', + defaultTerminalName: '返回结果', + defaultDescription: '可复用的子规则决策图。', + openInEditor: '在编辑器中打开', + focusTitle: '正在编辑子规则:{name}', + focusDesc: '这是一个可复用的聚焦图,返回父规则前请先保存。', + returnParent: '返回父规则', + }, versions: { title: '版本历史', desc: '查看规则集的变更历史,支持回滚到任意历史版本(需 admin+)', diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts index bd7ceb67..3663becf 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts @@ -270,6 +270,7 @@ export default { facts: '事實目錄', concepts: '概念註冊', contracts: '決策契約', + subRules: '子規則', tests: '測試', versions: '版本歷史', trace: '執行追蹤', @@ -496,6 +497,48 @@ export default { fieldName: '欄位名', fieldDesc: '描述', }, + subRules: { + title: '子規則資產', + desc: '管理可重用的原子決策圖,供規則流程中的子規則節點引用。', + createTitle: '建立子規則', + createDesc: + '子規則擁有自己的節點圖、決策表、輸入契約與輸出契約。規則集只引用已管理的子規則資產。', + create: '建立', + empty: '暫無子規則資產', + placeholder: '選擇一個子規則,或建立一個新的子規則資產', + searchPlaceholder: '依名稱、描述或範圍搜尋', + name: '名稱', + namePlaceholder: '例如 risk_score', + nameRequired: '請輸入子規則名稱', + displayName: '顯示名稱', + displayNamePlaceholder: '例如 風險評分', + description: '描述', + descriptionPlaceholder: '說明這個子規則提供的原子能力', + assetScope: '資產範圍', + scopeProject: '專案級', + scopeOrg: '組織級', + published: '已發佈', + publishedAt: '發佈時間', + projectAssets: '專案級子規則', + orgAssets: '組織級子規則', + noProjectAssets: '暫無專案級子規則。', + noOrgAssets: '暫無組織級子規則。', + noDescription: '暫無描述', + status: '狀態', + unpublished: '未發佈', + updatedAt: '更新時間', + loadFailed: '載入子規則失敗', + saveFailed: '儲存子規則失敗', + createSuccess: '子規則已建立', + usageTitle: '流程引用', + usageDesc: '規則流程中的子規則節點會保存這個資產引用;發佈時會解析為確定版本快照。', + defaultTerminalName: '返回結果', + defaultDescription: '可重用的子規則決策圖。', + openInEditor: '在編輯器中開啟', + focusTitle: '正在編輯子規則:{name}', + focusDesc: '這是一個可重用的聚焦圖,返回父規則前請先儲存。', + returnParent: '返回父規則', + }, versions: { title: '版本歷史', desc: '查看規則集的變更歷史,支援回滾到任意歷史版本(需 admin+)', diff --git a/ordo-editor/apps/studio/src/router/index.ts b/ordo-editor/apps/studio/src/router/index.ts index 31f2fb8e..42727c7d 100644 --- a/ordo-editor/apps/studio/src/router/index.ts +++ b/ordo-editor/apps/studio/src/router/index.ts @@ -140,6 +140,11 @@ const router = createRouter({ name: 'contracts', component: () => import('@/views/project/ContractView.vue'), }, + { + path: 'sub-rules', + name: 'project-sub-rules', + component: () => import('@/views/project/SubRulesView.vue'), + }, { path: 'tests', name: 'tests', diff --git a/ordo-editor/apps/studio/src/stores/project.ts b/ordo-editor/apps/studio/src/stores/project.ts index 39cd3753..64a3471e 100644 --- a/ordo-editor/apps/studio/src/stores/project.ts +++ b/ordo-editor/apps/studio/src/stores/project.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; -import { projectApi, rulesetDraftApi } from '@/api/platform-client'; +import { projectApi, rulesetDraftApi, subRuleApi } from '@/api/platform-client'; import { normalizeRuleset } from '@/utils/ruleset'; import { useAuthStore } from './auth'; import { useOrgStore } from './org'; @@ -10,6 +10,7 @@ import type { ProjectRuleset, ProjectRulesetMeta, RuleSetInfo, + SubRuleScope, } from '@/api/types'; import type { RuleSet } from '@ordo-engine/editor-core'; @@ -21,6 +22,10 @@ export interface OpenTab { dirty: boolean; /** Platform draft sequence number for optimistic locking */ draft_seq: number; + /** 'sub_rule' when this tab holds a managed SubRule asset draft */ + kind?: 'sub_rule'; + /** SubRule asset scope (only set when kind === 'sub_rule') */ + subRuleScope?: SubRuleScope; } export const useProjectStore = defineStore('project', () => { @@ -133,6 +138,32 @@ export const useProjectStore = defineStore('project', () => { activeTabName.value = name; } + async function openSubRule(name: string, scope: SubRuleScope = 'project') { + if (!auth.token || !currentProject.value) return; + const tabName = `§${name}`; + const existing = openTabs.value.find((t) => t.name === tabName); + if (existing) { + activeTabName.value = tabName; + return; + } + const org = orgStore.currentOrg; + if (!org) throw new Error('No active org'); + const asset = + scope === 'org' + ? await subRuleApi.getOrg(auth.token, org.id, name) + : await subRuleApi.getProject(auth.token, org.id, currentProject.value.id, name); + const ruleset = normalizeRuleset(asset.draft, name); + openTabs.value.push({ + name: tabName, + ruleset, + dirty: false, + draft_seq: asset.draft_seq, + kind: 'sub_rule', + subRuleScope: scope, + }); + activeTabName.value = tabName; + } + function updateActiveRuleset(ruleset: RuleSet) { const tab = openTabs.value.find((t) => t.name === activeTabName.value); if (tab) { @@ -160,6 +191,29 @@ export const useProjectStore = defineStore('project', () => { const org = orgStore.currentOrg; if (!org) throw new Error('No active org'); + if (tab.kind === 'sub_rule') { + const assetName = name.startsWith('§') ? name.slice(1) : name; + const asset = + tab.subRuleScope === 'org' + ? await subRuleApi.saveOrg(auth.token, org.id, assetName, { + name: assetName, + draft: tab.ruleset as any, + input_schema: [], + output_schema: [], + expected_seq: tab.draft_seq, + }) + : await subRuleApi.saveProject(auth.token, org.id, currentProject.value.id, assetName, { + name: assetName, + draft: tab.ruleset as any, + input_schema: [], + output_schema: [], + expected_seq: tab.draft_seq, + }); + tab.dirty = false; + tab.draft_seq = asset.draft_seq; + return null; + } + const result = await rulesetDraftApi.save(auth.token, org.id, currentProject.value.id, name, { ruleset: tab.ruleset as any, expected_seq: tab.draft_seq, @@ -244,6 +298,7 @@ export const useProjectStore = defineStore('project', () => { selectProject, fetchRulesets, openRuleset, + openSubRule, updateActiveRuleset, setTabRuleset, saveRuleset, diff --git a/ordo-editor/apps/studio/src/styles/ordo-theme.css b/ordo-editor/apps/studio/src/styles/ordo-theme.css index 4c9b6e6c..f7446e94 100644 --- a/ordo-editor/apps/studio/src/styles/ordo-theme.css +++ b/ordo-editor/apps/studio/src/styles/ordo-theme.css @@ -108,6 +108,7 @@ --ordo-node-decision: var(--ordo-warning); --ordo-node-action: var(--ordo-accent); --ordo-node-terminal: var(--ordo-success); + --ordo-node-sub-rule: #5b708a; --ordo-keyword: var(--ordo-accent); --ordo-variable: #d4860e; @@ -201,6 +202,7 @@ --ordo-node-decision: var(--ordo-warning); --ordo-node-action: var(--ordo-accent); --ordo-node-terminal: var(--ordo-success); + --ordo-node-sub-rule: #8fa7c1; --ordo-keyword: var(--ordo-accent); --ordo-variable: #ffd27f; diff --git a/ordo-editor/apps/studio/src/views/editor/EditorView.vue b/ordo-editor/apps/studio/src/views/editor/EditorView.vue index e0033098..2906a82c 100644 --- a/ordo-editor/apps/studio/src/views/editor/EditorView.vue +++ b/ordo-editor/apps/studio/src/views/editor/EditorView.vue @@ -10,7 +10,7 @@ import { useEnvironmentStore } from '@/stores/environment'; import { useRbacStore } from '@/stores/rbac'; import ChangeHistoryPanel from '@/components/ChangeHistoryPanel.vue'; import TestCasePanel from './TestCasePanel.vue'; -import { rulesetHistoryApi } from '@/api/platform-client'; +import { rulesetHistoryApi, subRuleApi } from '@/api/platform-client'; import DraftConflictDialog from '@/components/project/DraftConflictDialog.vue'; import { normalizeRuleset } from '@/utils/ruleset'; import { getCurrentVersionDisplay, stripVersionSuffix } from '@/utils/ruleset-version'; @@ -19,6 +19,7 @@ import type { DraftConflictResponse, RulesetHistoryEntry, RulesetHistorySource, + SubRuleAssetMeta, } from '@/api/types'; import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'; import { @@ -34,6 +35,8 @@ import { generateId, Step, type RuleSet, + type SubRuleStep, + type SubRuleAssetOption, type DecisionTable, } from '@ordo-engine/editor-vue'; @@ -50,6 +53,7 @@ const { t } = useI18n(); const LOCAL_HISTORY_LIMIT = 120; const HISTORY_SYNC_DELAY_MS = 700; const EDIT_HISTORY_COMMIT_DELAY_MS = 450; +const pendingSubRuleAssets = new Set(); const orgId = computed(() => route.params.orgId as string); const projectId = computed(() => route.params.projectId as string); @@ -481,6 +485,9 @@ const creating = ref(false); const newName = ref(''); const newType = ref<'flow' | 'table'>('flow'); const saving = ref(false); +const subRuleAssets = ref([]); +const subRuleAssetsLoaded = ref(false); +const subRuleParentTabs = new Map(); const conflictState = ref<{ rulesetName: string; localDraft: RuleSet; @@ -529,6 +536,27 @@ const requiresVersionBump = computed( () => !!activePublishedVersion.value && activePublishedVersion.value === activeDraftVersion.value ); +const subRuleAssetOptions = computed(() => + subRuleAssets.value.map((asset) => ({ + name: asset.name, + scope: asset.scope, + displayName: asset.display_name, + description: asset.description, + })) +); + +const activeSubRuleName = computed(() => { + const tab = projectStore.activeTab; + if (tab?.kind !== 'sub_rule') return null; + return tab.name.startsWith('§') ? tab.name.slice(1) : tab.name; +}); + +const activeSubRuleParentName = computed(() => { + const tab = projectStore.activeTab; + if (!tab || tab.kind !== 'sub_rule') return null; + return subRuleParentTabs.get(tab.name) ?? null; +}); + // ── Table support ────────────────────────────────────────────────────────────── const decisionTables = ref>({}); @@ -574,19 +602,34 @@ onMounted(async () => { await rbacStore.fetchRoles(orgId.value); await rbacStore.fetchMyRoles(orgId.value); await environmentStore.fetchEnvironments(orgId.value, projectId.value); + await refreshSubRuleAssets(); - // Open ruleset from URL param + // Open ruleset or sub-rule from URL param if (rulesetNameParam.value) { - await openRuleset(rulesetNameParam.value); + await openTabFromParam(rulesetNameParam.value); } else if (projectStore.rulesets.length > 0 && projectStore.openTabs.length === 0) { await openRuleset(projectStore.rulesets[0].name); } }); +async function openTabFromParam(name: string) { + if (name.startsWith('§')) { + const refName = name.slice(1); + try { + await projectStore.openSubRule(refName, 'project'); + tabModes.set(name, 'flow'); + } catch { + await openRuleset(projectStore.rulesets[0]?.name ?? ''); + } + } else { + await openRuleset(name); + } +} + watch( () => rulesetNameParam.value, async (name) => { - if (name) await openRuleset(name); + if (name) await openTabFromParam(name); } ); @@ -678,9 +721,178 @@ function handleRulesetChange(ruleset: RuleSet) { const tab = projectStore.activeTab; if (!tab) return; - const action = buildHistoryAction(tab.ruleset, ruleset); - updateRulesetState(tab.name, ruleset); - scheduleEditHistoryEntry(tab.name, ruleset, action); + const { ruleset: normalizedRuleset, refsToCreate } = normalizeSubRuleReferences( + tab.name, + ruleset + ); + const action = buildHistoryAction(tab.ruleset, normalizedRuleset); + updateRulesetState(tab.name, normalizedRuleset); + scheduleEditHistoryEntry(tab.name, normalizedRuleset, action); + + for (const refName of refsToCreate) { + void ensureProjectSubRuleAsset(refName); + } +} + +function normalizeSubRuleReferences(parentRulesetName: string, ruleset: RuleSet) { + let changed = false; + const refsToCreate: string[] = []; + + const steps = ruleset.steps.map((step) => { + if (step.type !== 'sub_rule') return step; + + const subRuleStep = step as SubRuleStep; + const generatedName = + subRuleStep.refName.trim() || `${sanitizeAssetName(parentRulesetName)}_${subRuleStep.id}`; + const assetRef: NonNullable = { + ...(subRuleStep.assetRef ?? { scope: 'project' as const }), + scope: subRuleStep.assetRef?.scope ?? ('project' as const), + name: subRuleStep.assetRef?.name?.trim() || generatedName, + }; + + const needsPatch = + !subRuleStep.refName.trim() || + !subRuleStep.assetRef || + !subRuleStep.assetRef.name?.trim() || + subRuleStep.assetRef.name !== assetRef.name; + + if (needsPatch) { + changed = true; + refsToCreate.push(generatedName); + return { + ...subRuleStep, + refName: generatedName, + assetRef, + }; + } + + return subRuleStep; + }); + + return { + ruleset: changed ? { ...ruleset, steps } : ruleset, + refsToCreate, + }; +} + +function sanitizeAssetName(name: string) { + return ( + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') || 'sub_rule' + ); +} + +function createDefaultSubRuleDraft(name: string): RuleSet { + const terminal = Step.terminal({ + id: 'return_result', + name: t('subRules.defaultTerminalName'), + code: 'OK', + message: { + type: 'literal', + value: '', + valueType: 'string', + }, + output: [], + position: { x: 160, y: 120 }, + }); + + return { + config: { + name, + version: '0.1.0', + description: t('subRules.defaultDescription'), + enableTrace: true, + }, + startStepId: terminal.id, + steps: [terminal], + groups: [], + metadata: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; +} + +async function refreshSubRuleAssets() { + if (!auth.token || !orgId.value || !projectId.value) return; + try { + subRuleAssets.value = await subRuleApi.listProject( + auth.token, + orgId.value, + projectId.value, + true + ); + subRuleAssetsLoaded.value = true; + } catch (e: any) { + subRuleAssetsLoaded.value = false; + MessagePlugin.warning(e.message || t('subRules.loadFailed')); + } +} + +function hasProjectSubRuleAsset(name: string) { + return subRuleAssets.value.some((asset) => asset.scope === 'project' && asset.name === name); +} + +async function ensureProjectSubRuleAsset(name: string) { + if (!auth.token || !orgId.value || !projectId.value) return; + const key = `${projectId.value}:${name}`; + if (pendingSubRuleAssets.has(key)) return; + + if (!subRuleAssetsLoaded.value) { + await refreshSubRuleAssets(); + } + if (hasProjectSubRuleAsset(name)) return; + + pendingSubRuleAssets.add(key); + try { + await subRuleApi.saveProject(auth.token, orgId.value, projectId.value, name, { + name, + display_name: name, + description: t('subRules.defaultDescription'), + draft: createDefaultSubRuleDraft(name), + input_schema: [], + output_schema: [], + expected_seq: 0, + }); + await refreshSubRuleAssets(); + } catch (e: any) { + MessagePlugin.warning(e.message || t('subRules.saveFailed')); + } finally { + pendingSubRuleAssets.delete(key); + } +} + +async function handleOpenSubRule(refName: string) { + if (!refName) return; + const parentTabName = projectStore.activeTab?.name ?? null; + const scope = + (projectStore.activeTab?.ruleset.steps as any[])?.find( + (s: any) => s.type === 'sub_rule' && s.refName === refName + )?.assetRef?.scope ?? 'project'; + if (scope === 'project') { + await ensureProjectSubRuleAsset(refName); + } + try { + await projectStore.openSubRule(refName, scope); + const tabName = `§${refName}`; + if (parentTabName && parentTabName !== tabName) { + subRuleParentTabs.set(tabName, parentTabName); + } + tabModes.set(tabName, 'flow'); + router.replace(`${projectBase.value}/editor/${encodeURIComponent(tabName)}`); + } catch (e: any) { + MessagePlugin.error(e.message || t('subRules.loadFailed')); + } +} + +function returnToSubRuleParent() { + const parentName = activeSubRuleParentName.value; + if (!parentName) return; + switchToTab(parentName); + router.replace(`${projectBase.value}/editor/${encodeURIComponent(parentName)}`); } function handleVersionChange(event: Event) { @@ -743,6 +955,9 @@ async function handleSave(name: string) { if (tab) { savedRulesetSnapshots.set(name, serializeRuleset(tab.ruleset)); projectStore.setTabRuleset(name, cloneRuleset(tab.ruleset), false); + if (tab.kind === 'sub_rule') { + await refreshSubRuleAssets(); + } pushHistoryEntry(name, tab.ruleset, t('historyPanel.actionSaveCheckpoint'), 'save'); await flushHistoryQueue(name); } @@ -788,6 +1003,7 @@ async function resolveConflictUseLocal() { function openReleaseCenter() { if (!projectStore.activeTab) return; + if (projectStore.activeTab.kind === 'sub_rule') return; router.push({ name: 'project-release-request-create', params: { @@ -1124,8 +1340,22 @@ onUnmounted(() => { :class="{ 'is-active': tab.name === projectStore.activeTabName }" @click="switchToTab(tab.name)" > - - {{ tab.name }} + + {{ + tab.name.startsWith('§') ? tab.name.slice(1) : tab.name + }} + sub