diff --git a/README.md b/README.md index 37b76c6b..fb340293 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — Feature Flags](#whats-new-2026-06-21--feature-flags) - [What's new (2026-06-21) — Text Diff, Patch & Three-Way Merge](#whats-new-2026-06-21--text-diff-patch--three-way-merge) - [What's new (2026-06-21) — Calendar Recurrence Rules (RRULE)](#whats-new-2026-06-21--calendar-recurrence-rules-rrule) - [What's new (2026-06-21) — Statistics & A/B Significance](#whats-new-2026-06-21--statistics--ab-significance) @@ -120,6 +121,12 @@ --- +## What's new (2026-06-21) — Feature Flags + +Toggle behavior with targeting & rollout. Full reference: [`docs/source/Eng/doc/new_features/v68_features_doc.rst`](docs/source/Eng/doc/new_features/v68_features_doc.rst). + +- **`FlagStore` / `evaluate_flag` / `is_enabled` / `assign_variant`** (`AC_evaluate_flag`, `AC_flag_enabled`): `decision_table` is one-shot DMN and `ab_locator` is locator A/B — neither is a product flag store with sticky % rollout. This adds an OpenFeature-shaped engine: targeting rules (`eq`/`in`/`semver_*`…), weighted variants, kill switch, and consistent-hash bucketing (`sha256(key.salt.context_key)`) so a subject is **sticky**. Returns `{value, variant, reason}` (`TARGETING_MATCH`/`SPLIT`/`DISABLED`/`ERROR`). Pure-stdlib, deterministic. + ## What's new (2026-06-21) — Text Diff, Patch & Three-Way Merge Apply and merge text diffs. Full reference: [`docs/source/Eng/doc/new_features/v67_features_doc.rst`](docs/source/Eng/doc/new_features/v67_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index c102275f..6e7edef5 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — 功能旗标](#本次更新-2026-06-21--功能旗标) - [本次更新 (2026-06-21) — 文本 Diff、应用与三方合并](#本次更新-2026-06-21--文本-diff应用与三方合并) - [本次更新 (2026-06-21) — 日历周期规则(RRULE)](#本次更新-2026-06-21--日历周期规则rrule) - [本次更新 (2026-06-21) — 统计与 A/B 显著性](#本次更新-2026-06-21--统计与-ab-显著性) @@ -119,6 +120,12 @@ --- +## 本次更新 (2026-06-21) — 功能旗标 + +以目标规则与推出切换行为。完整参考:[`docs/source/Zh/doc/new_features/v68_features_doc.rst`](../docs/source/Zh/doc/new_features/v68_features_doc.rst)。 + +- **`FlagStore` / `evaluate_flag` / `is_enabled` / `assign_variant`**(`AC_evaluate_flag`、`AC_flag_enabled`):`decision_table` 是一次性 DMN,`ab_locator` 是定位器 A/B —— 两者都不是带黏性 % 推出的产品旗标存储库。本功能补上 OpenFeature 形状引擎:目标规则(`eq`/`in`/`semver_*`…)、加权变体、kill switch,以及一致哈希分桶(`sha256(key.salt.context_key)`)使主体具**黏性**。返回 `{value, variant, reason}`(`TARGETING_MATCH`/`SPLIT`/`DISABLED`/`ERROR`)。纯标准库、确定。 + ## 本次更新 (2026-06-21) — 文本 Diff、应用与三方合并 应用并合并文本 diff。完整参考:[`docs/source/Zh/doc/new_features/v67_features_doc.rst`](../docs/source/Zh/doc/new_features/v67_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 89b8dade..d266a708 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — 功能旗標](#本次更新-2026-06-21--功能旗標) - [本次更新 (2026-06-21) — 文字 Diff、套用與三方合併](#本次更新-2026-06-21--文字-diff套用與三方合併) - [本次更新 (2026-06-21) — 行事曆週期規則(RRULE)](#本次更新-2026-06-21--行事曆週期規則rrule) - [本次更新 (2026-06-21) — 統計與 A/B 顯著性](#本次更新-2026-06-21--統計與-ab-顯著性) @@ -119,6 +120,12 @@ --- +## 本次更新 (2026-06-21) — 功能旗標 + +以目標規則與推出切換行為。完整參考:[`docs/source/Zh/doc/new_features/v68_features_doc.rst`](../docs/source/Zh/doc/new_features/v68_features_doc.rst)。 + +- **`FlagStore` / `evaluate_flag` / `is_enabled` / `assign_variant`**(`AC_evaluate_flag`、`AC_flag_enabled`):`decision_table` 是一次性 DMN,`ab_locator` 是定位器 A/B —— 兩者都不是帶黏性 % 推出的產品旗標儲存庫。本功能補上 OpenFeature 形狀引擎:目標規則(`eq`/`in`/`semver_*`…)、加權變體、kill switch,以及一致雜湊分桶(`sha256(key.salt.context_key)`)使主體具**黏性**。回傳 `{value, variant, reason}`(`TARGETING_MATCH`/`SPLIT`/`DISABLED`/`ERROR`)。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-21) — 文字 Diff、套用與三方合併 套用並合併文字 diff。完整參考:[`docs/source/Zh/doc/new_features/v67_features_doc.rst`](../docs/source/Zh/doc/new_features/v67_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v68_features_doc.rst b/docs/source/Eng/doc/new_features/v68_features_doc.rst new file mode 100644 index 00000000..55f57dc9 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v68_features_doc.rst @@ -0,0 +1,58 @@ +Feature Flags +============= + +``decision_table`` is a one-shot DMN evaluator and ``ab_locator`` measures +locator outcomes — neither is a product feature-flag store with sticky +percentage rollout. This adds an OpenFeature-shaped flag engine: typed flags +with targeting rules, weighted variants, a kill switch, and consistent-hash +bucketing so a given subject always lands in the same variant. + +Pure standard library (``hashlib`` + ``re`` + ``json``); deterministic; imports +no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import FlagStore, evaluate_flag, is_enabled + + store = FlagStore.from_dict({"flags": { + "new-checkout": { + "variants": {"on": True, "off": False}, + "default_variant": "off", "off_variant": "off", + "targeting": [ + {"conditions": {"country": {"op": "in", "value": ["US", "CA"]}}, + "serve": "on"}, + {"conditions": {"plan": {"op": "eq", "value": "premium"}}, + "serve": {"rollout": {"on": 50, "off": 50}}}, + ], + "fallthrough": {"rollout": {"on": 10, "off": 90}}, + }, + }}) + + evaluate_flag(store, "new-checkout", {"country": "US"}) + # {"flag_key": "new-checkout", "variant": "on", "value": True, + # "reason": "TARGETING_MATCH"} + + if is_enabled(store, "new-checkout", {"targeting_key": "user-123"}): + ... + +Evaluation order mirrors OpenFeature/Unleash/LaunchDarkly: a disabled flag +serves ``off_variant`` (reason ``DISABLED``); an unknown flag returns the +caller default (reason ``ERROR``); targeting rules are tried in order +(``TARGETING_MATCH``); otherwise the fallthrough applies (``DEFAULT`` / +``SPLIT``). Targeting operators include ``eq``/``ne``/``lt``/``gt``/``in``/ +``not_in``/``contains`` and ``semver_*``. Percentage rollout is a +consistent-hash bucket of ``sha256("{key}.{salt}.{context_key}")`` so a subject +is **sticky** — it always gets the same variant. ``percentage_bucket`` and +``assign_variant`` are exposed for direct use. + +Executor commands +----------------- + +``AC_evaluate_flag`` takes ``flags`` (a store mapping or JSON string), a +``key`` and optional ``context``; it returns ``{value, variant, reason}``. +``AC_flag_enabled`` returns ``{enabled}``. Both are exposed as MCP tools +(``ac_evaluate_flag`` / ``ac_flag_enabled``) and as Script Builder commands +under **Flow**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 7a29c2ba..612602c6 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -90,6 +90,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v65_features_doc doc/new_features/v66_features_doc doc/new_features/v67_features_doc + doc/new_features/v68_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v68_features_doc.rst b/docs/source/Zh/doc/new_features/v68_features_doc.rst new file mode 100644 index 00000000..d96ad5a5 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v68_features_doc.rst @@ -0,0 +1,50 @@ +功能旗標(Feature Flags) +======================== + +``decision_table`` 是一次性的 DMN 評估器,``ab_locator`` 衡量定位器成效 —— 兩者都不是帶有黏性 +百分比推出的產品功能旗標儲存庫。本功能補上 OpenFeature 形狀的旗標引擎:具型別的旗標、目標規則、 +加權變體、kill switch,以及一致雜湊分桶,讓同一主體永遠落在同一變體。 + +純標準函式庫(``hashlib`` + ``re`` + ``json``);具決定性;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import FlagStore, evaluate_flag, is_enabled + + store = FlagStore.from_dict({"flags": { + "new-checkout": { + "variants": {"on": True, "off": False}, + "default_variant": "off", "off_variant": "off", + "targeting": [ + {"conditions": {"country": {"op": "in", "value": ["US", "CA"]}}, + "serve": "on"}, + {"conditions": {"plan": {"op": "eq", "value": "premium"}}, + "serve": {"rollout": {"on": 50, "off": 50}}}, + ], + "fallthrough": {"rollout": {"on": 10, "off": 90}}, + }, + }}) + + evaluate_flag(store, "new-checkout", {"country": "US"}) + # {"flag_key": "new-checkout", "variant": "on", "value": True, + # "reason": "TARGETING_MATCH"} + + if is_enabled(store, "new-checkout", {"targeting_key": "user-123"}): + ... + +評估順序對齊 OpenFeature/Unleash/LaunchDarkly:停用的旗標提供 ``off_variant``(原因 +``DISABLED``);未知旗標回傳呼叫端預設(原因 ``ERROR``);目標規則依序嘗試(``TARGETING_MATCH``); +否則套用 fallthrough(``DEFAULT`` / ``SPLIT``)。目標運算子包含 ``eq``/``ne``/``lt``/``gt``/ +``in``/``not_in``/``contains`` 與 ``semver_*``。百分比推出是 +``sha256("{key}.{salt}.{context_key}")`` 的一致雜湊分桶,因此主體具**黏性** —— 永遠得到相同變體。 +``percentage_bucket`` 與 ``assign_variant`` 亦可直接使用。 + +執行器命令 +---------- + +``AC_evaluate_flag`` 接受 ``flags``(store 對映或 JSON 字串)、``key`` 與選用的 ``context``,回傳 +``{value, variant, reason}``。``AC_flag_enabled`` 回傳 ``{enabled}``。兩者皆以 MCP 工具 +(``ac_evaluate_flag`` / ``ac_flag_enabled``)以及 Script Builder 中 **Flow** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index e569ef4c..201bc048 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -90,6 +90,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v65_features_doc doc/new_features/v66_features_doc doc/new_features/v67_features_doc + doc/new_features/v68_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index dba30819..38772c88 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -346,6 +346,11 @@ from je_auto_control.utils.text_diff import ( MergeResult, PatchApplyError, apply_unified, three_way_merge, unified_diff, ) +# Feature flags with targeting rules + deterministic rollout +from je_auto_control.utils.feature_flags import ( + Flag, FlagStore, assign_variant, evaluate_flag, is_enabled, + percentage_bucket, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -834,6 +839,8 @@ def start_autocontrol_gui(*args, **kwargs): "Recurrence", "next_occurrence", "occurrences", "parse_rrule", "MergeResult", "PatchApplyError", "apply_unified", "three_way_merge", "unified_diff", + "Flag", "FlagStore", "assign_variant", "evaluate_flag", "is_enabled", + "percentage_bucket", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 2255ef70..260eb51b 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1155,6 +1155,26 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Validate JSON against a JSON Schema; returns {ok, errors}.", )) + specs.append(CommandSpec( + "AC_evaluate_flag", "Flow", "Feature Flag: Evaluate", + fields=( + FieldSpec("flags", FieldType.STRING, + placeholder='{"flags": {"f": {"variants": {...}}}}'), + FieldSpec("key", FieldType.STRING, placeholder="new-checkout"), + FieldSpec("context", FieldType.STRING, optional=True, + placeholder='{"targeting_key": "user1", "country": "US"}'), + ), + description="Evaluate a feature flag; returns {value, variant, reason}.", + )) + specs.append(CommandSpec( + "AC_flag_enabled", "Flow", "Feature Flag: Enabled?", + fields=( + FieldSpec("flags", FieldType.STRING), + FieldSpec("key", FieldType.STRING, placeholder="new-checkout"), + FieldSpec("context", FieldType.STRING, optional=True), + ), + description="Boolean feature-flag check; returns {enabled}.", + )) specs.append(CommandSpec( "AC_unified_diff", "Data", "Text: Unified Diff", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index abd78074..246b09c9 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2928,6 +2928,30 @@ def _rate_limit(name: str, rate: float = 1.0, capacity: float = 1.0, "wait": round(bucket.time_until_available(float(n)), 4)} +def _evaluate_flag(flags: Any, key: str, context: Any = None) -> Dict[str, Any]: + """Adapter: evaluate a feature flag (flags/context dict or JSON string).""" + import json + from je_auto_control.utils.feature_flags import FlagStore, evaluate_flag + if isinstance(flags, str): + flags = json.loads(flags) + if isinstance(context, str): + context = json.loads(context) + return evaluate_flag(FlagStore.from_dict(flags), key, context or {}) + + +def _flag_enabled(flags: Any, key: str, context: Any = None, + default: bool = False) -> Dict[str, Any]: + """Adapter: boolean feature-flag check.""" + import json + from je_auto_control.utils.feature_flags import FlagStore, is_enabled + if isinstance(flags, str): + flags = json.loads(flags) + if isinstance(context, str): + context = json.loads(context) + store = FlagStore.from_dict(flags) + return {"enabled": is_enabled(store, key, context or {}, bool(default))} + + def _unified_diff(a: str, b: str) -> Dict[str, Any]: """Adapter: unified diff transforming text a into b.""" from je_auto_control.utils.text_diff import unified_diff @@ -3854,6 +3878,8 @@ def __init__(self): "AC_ab_significance": _ab_significance, "AC_rrule_occurrences": _rrule_occurrences, "AC_rrule_next": _rrule_next, + "AC_evaluate_flag": _evaluate_flag, + "AC_flag_enabled": _flag_enabled, "AC_unified_diff": _unified_diff, "AC_apply_unified": _apply_unified, "AC_three_way_merge": _three_way_merge, diff --git a/je_auto_control/utils/feature_flags/__init__.py b/je_auto_control/utils/feature_flags/__init__.py new file mode 100644 index 00000000..3c04ae71 --- /dev/null +++ b/je_auto_control/utils/feature_flags/__init__.py @@ -0,0 +1,10 @@ +"""Feature-flag evaluation with targeting rules and deterministic rollout.""" +from je_auto_control.utils.feature_flags.feature_flags import ( + Flag, FlagStore, assign_variant, evaluate_flag, is_enabled, + percentage_bucket, +) + +__all__ = [ + "Flag", "FlagStore", "assign_variant", "evaluate_flag", "is_enabled", + "percentage_bucket", +] diff --git a/je_auto_control/utils/feature_flags/feature_flags.py b/je_auto_control/utils/feature_flags/feature_flags.py new file mode 100644 index 00000000..504edcf7 --- /dev/null +++ b/je_auto_control/utils/feature_flags/feature_flags.py @@ -0,0 +1,163 @@ +"""Runtime feature flags with targeting rules and deterministic rollout. + +``decision_table`` is a one-shot DMN evaluator and ``ab_locator`` measures +locator outcomes — neither is a product feature-flag store with sticky +percentage rollout. This adds an OpenFeature-shaped flag engine: typed flags +with targeting rules, weighted variants, a kill switch, and consistent-hash +bucketing so a given subject always lands in the same variant. + +Pure standard library (``hashlib`` + ``re`` + ``json``); deterministic; +imports no ``PySide6``. +""" +import hashlib +import json +import os +import re +from dataclasses import dataclass, field +from typing import Any, Dict, Mapping, Optional + + +def _semver(value: Any) -> tuple: + return tuple(int(part) for part in re.findall(r"\d+", str(value))[:3]) + + +_OPS = { + "eq": lambda a, b: a == b, "ne": lambda a, b: a != b, + "lt": lambda a, b: a < b, "le": lambda a, b: a <= b, + "gt": lambda a, b: a > b, "ge": lambda a, b: a >= b, + "in": lambda a, b: a in b, "not_in": lambda a, b: a not in b, + "contains": lambda a, b: b in a, + "semver_gt": lambda a, b: _semver(a) > _semver(b), + "semver_ge": lambda a, b: _semver(a) >= _semver(b), + "semver_lt": lambda a, b: _semver(a) < _semver(b), +} + + +@dataclass(frozen=True) +class Flag: + """A single feature flag definition.""" + + key: str + variants: Dict[str, Any] = field(default_factory=dict) + default_variant: str = "" + off_variant: str = "" + enabled: bool = True + targeting: tuple = () + fallthrough: Any = None + + +class FlagStore: + """An in-memory set of feature flags (plain data, injectable).""" + + def __init__(self, flags: Mapping[str, Flag]) -> None: + self._flags = dict(flags) + + def get(self, key: str) -> Optional[Flag]: + """Return the flag named ``key`` or ``None``.""" + return self._flags.get(key) + + @classmethod + def from_dict(cls, spec: Mapping[str, Any]) -> "FlagStore": + """Build a store from a ``{"flags": {...}}`` (or bare) mapping.""" + raw = spec.get("flags", spec) if isinstance(spec, Mapping) else {} + flags = {} + for key, body in (raw or {}).items(): + flags[key] = Flag( + key=key, + variants=dict(body.get("variants", {})), + default_variant=body.get("default_variant", ""), + off_variant=body.get("off_variant", + body.get("default_variant", "")), + enabled=bool(body.get("enabled", True)), + targeting=tuple(body.get("targeting", ())), + fallthrough=body.get("fallthrough"), + ) + return cls(flags) + + @classmethod + def from_file(cls, path: str) -> "FlagStore": + """Load a flag store from a JSON file (path is realpath-checked).""" + safe = os.path.realpath(path) + with open(safe, "r", encoding="utf-8") as handle: + return cls.from_dict(json.load(handle)) + + +def percentage_bucket(key: str, context_key: str, *, salt: str = "", + buckets: int = 100) -> int: + """Return a stable bucket in ``[0, buckets)`` for (key, context_key).""" + basis = f"{key}.{salt}.{context_key}".encode("utf-8") + digest = hashlib.sha256(basis).hexdigest()[:15] + return int(digest, 16) % max(1, buckets) + + +def assign_variant(key: str, weights: Mapping[str, int], context_key: str, *, + salt: str = "") -> str: + """Deterministically pick a weighted variant for ``context_key``.""" + total = sum(weights.values()) + if total <= 0: + return next(iter(weights)) + bucket = percentage_bucket(key, context_key, salt=salt, buckets=total) + cumulative = 0 + name = "" + for name, weight in weights.items(): + cumulative += weight + if bucket < cumulative: + return name + return name + + +def _rule_matches(rule: Mapping[str, Any], context: Mapping[str, Any]) -> bool: + for attr, condition in rule.get("conditions", {}).items(): + operator = _OPS.get(condition.get("op", "eq")) + if operator is None: + return False + try: + if not operator(context.get(attr), condition.get("value")): + return False + except TypeError: + return False + return True + + +def _result(flag: Flag, variant: str, reason: str) -> Dict[str, Any]: + return {"flag_key": flag.key, "variant": variant, + "value": flag.variants.get(variant), "reason": reason} + + +def _context_key(context: Mapping[str, Any]) -> str: + return str(context.get("targeting_key") or context.get("key") or "") + + +def _serve(flag: Flag, serve: Any, context: Mapping[str, Any], + reason: str) -> Dict[str, Any]: + if isinstance(serve, Mapping) and "rollout" in serve: + variant = assign_variant(flag.key, serve["rollout"], + _context_key(context)) + return _result(flag, variant, "SPLIT") + return _result(flag, serve, reason) + + +def evaluate_flag(store: FlagStore, key: str, + context: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: + """Evaluate ``key`` for ``context``; return {value, variant, reason}.""" + context = context or {} + flag = store.get(key) + if flag is None: + return {"flag_key": key, "variant": None, "value": None, + "reason": "ERROR"} + if not flag.enabled: + return _result(flag, flag.off_variant, "DISABLED") + for rule in flag.targeting: + if _rule_matches(rule, context): + return _serve(flag, rule.get("serve"), context, "TARGETING_MATCH") + if flag.fallthrough is not None: + return _serve(flag, flag.fallthrough, context, "DEFAULT") + return _result(flag, flag.default_variant, "DEFAULT") + + +def is_enabled(store: FlagStore, key: str, + context: Optional[Mapping[str, Any]] = None, + default: bool = False) -> bool: + """Boolean shortcut over :func:`evaluate_flag`.""" + value = evaluate_flag(store, key, context).get("value") + return default if value is None else bool(value) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 0cd504a9..4ba6ab9e 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,34 @@ def rate_limit_tools() -> List[MCPTool]: ] +def feature_flag_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_evaluate_flag", + description=("Evaluate a feature 'flags' store for 'key' under an " + "evaluation 'context'. Returns {value, variant, " + "reason}."), + input_schema=schema( + {"flags": {"type": "object"}, "key": {"type": "string"}, + "context": {"type": "object"}}, + ["flags", "key"]), + handler=h.evaluate_flag, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_flag_enabled", + description=("Boolean feature-flag check for 'key' in 'flags' under " + "'context'. Returns {enabled}."), + input_schema=schema( + {"flags": {"type": "object"}, "key": {"type": "string"}, + "context": {"type": "object"}, "default": {"type": "boolean"}}, + ["flags", "key"]), + handler=h.flag_enabled, + annotations=READ_ONLY, + ), + ] + + def text_diff_tools() -> List[MCPTool]: return [ MCPTool( @@ -4691,6 +4719,7 @@ def media_assert_tools() -> List[MCPTool]: jsonpath_tools, json_schema_tools, vuln_scan_tools, vex_tools, license_policy_tools, jwt_tools, rate_limit_tools, json_patch_tools, search_index_tools, stats_tools, recurrence_tools, text_diff_tools, + feature_flag_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 1aa6dc5d..411d2df2 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1664,6 +1664,19 @@ def rrule_next(rule, dtstart, now=None): return {"next": moment.isoformat() if moment else None} +def evaluate_flag(flags, key, context=None): + from je_auto_control.utils.feature_flags import ( + FlagStore, evaluate_flag as _ev) + store = FlagStore.from_dict(flags) if isinstance(flags, dict) else flags + return _ev(store, key, context or {}) + + +def flag_enabled(flags, key, context=None, default=False): + from je_auto_control.utils.feature_flags import FlagStore, is_enabled + store = FlagStore.from_dict(flags) if isinstance(flags, dict) else flags + return {"enabled": is_enabled(store, key, context or {}, bool(default))} + + def unified_diff(a, b): from je_auto_control.utils.text_diff import unified_diff as _diff return {"diff": _diff(a, b)} diff --git a/test/unit_test/headless/test_feature_flags_batch.py b/test/unit_test/headless/test_feature_flags_batch.py new file mode 100644 index 00000000..0c7f3d64 --- /dev/null +++ b/test/unit_test/headless/test_feature_flags_batch.py @@ -0,0 +1,122 @@ +"""Headless tests for the feature-flag engine. Pure stdlib, no Qt imports.""" +import json + +import je_auto_control as ac +from je_auto_control.utils.feature_flags import ( + FlagStore, assign_variant, evaluate_flag, is_enabled, percentage_bucket) + +SPEC = { + "flags": { + "checkout": { + "type": "boolean", "enabled": True, + "variants": {"on": True, "off": False}, + "default_variant": "off", "off_variant": "off", + "targeting": [ + {"conditions": {"country": {"op": "in", "value": ["US", "CA"]}}, + "serve": "on"}, + {"conditions": {"plan": {"op": "eq", "value": "premium"}}, + "serve": {"rollout": {"on": 50, "off": 50}}}, + ], + "fallthrough": {"rollout": {"on": 10, "off": 90}}, + }, + "killed": { + "enabled": False, "variants": {"on": True, "off": False}, + "default_variant": "on", "off_variant": "off", + }, + } +} + + +def _store(): + return FlagStore.from_dict(SPEC) + + +def test_targeting_match(): + result = evaluate_flag(_store(), "checkout", {"country": "US"}) + assert result["value"] is True and result["reason"] == "TARGETING_MATCH" + + +def test_rollout_split_reason(): + result = evaluate_flag(_store(), "checkout", + {"plan": "premium", "targeting_key": "user1"}) + assert result["reason"] == "SPLIT" and result["variant"] in ("on", "off") + + +def test_fallthrough_split(): + result = evaluate_flag(_store(), "checkout", {"targeting_key": "userX"}) + assert result["reason"] == "SPLIT" + + +def test_kill_switch(): + result = evaluate_flag(_store(), "killed", {}) + assert result["reason"] == "DISABLED" and result["value"] is False + assert is_enabled(_store(), "killed", {}) is False + + +def test_unknown_flag(): + assert evaluate_flag(_store(), "missing", {})["reason"] == "ERROR" + assert is_enabled(_store(), "missing", {}, default=True) is True + + +def test_is_enabled_shortcut(): + assert is_enabled(_store(), "checkout", {"country": "CA"}) is True + + +def test_bucket_is_deterministic_and_in_range(): + assert percentage_bucket("f", "u1") == percentage_bucket("f", "u1") + assert 0 <= percentage_bucket("f", "u1") < 100 + + +def test_assign_variant_sticky_and_distributed(): + assert assign_variant("f", {"on": 50, "off": 50}, "stable") == \ + assign_variant("f", {"on": 50, "off": 50}, "stable") + on = sum(1 for i in range(2000) + if assign_variant("f", {"on": 50, "off": 50}, f"u{i}") == "on") + assert 850 < on < 1150 # ~50% + + +def test_semver_targeting(): + spec = {"flags": {"f": { + "variants": {"on": True, "off": False}, "default_variant": "off", + "targeting": [{"conditions": {"ver": {"op": "semver_ge", + "value": "2.0.0"}}, "serve": "on"}], + }}} + store = FlagStore.from_dict(spec) + assert evaluate_flag(store, "f", {"ver": "2.1.0"})["value"] is True + assert evaluate_flag(store, "f", {"ver": "1.9.0"})["value"] is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_evaluate_flag", + {"flags": json.dumps(SPEC), "key": "checkout", + "context": json.dumps({"country": "US"})}, + ]]) + payload = next(v for v in rec.values() if isinstance(v, dict)) + assert payload["value"] is True + + rec2 = ac.execute_action([[ + "AC_flag_enabled", + {"flags": json.dumps(SPEC), "key": "checkout", + "context": json.dumps({"country": "CA"})}, + ]]) + assert next(v for v in rec2.values() if isinstance(v, dict))["enabled"] is True + + +def test_wiring(): + assert {"AC_evaluate_flag", "AC_flag_enabled"} <= ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_evaluate_flag", "ac_flag_enabled"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_evaluate_flag", "AC_flag_enabled"} <= cmds + + +def test_facade_exports(): + for attr in ("FlagStore", "Flag", "evaluate_flag", "is_enabled", + "assign_variant", "percentage_bucket"): + assert hasattr(ac, attr) + assert attr in ac.__all__